Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 56 additions & 1 deletion packages/widgets/src/core/__tests__/widget-shadow.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { describe, it, expect, afterEach } from 'vitest';
import { createWidgetShadow } from '../widget-shadow';
import { createWidgetShadow, demoteRootSelectors } from '../widget-shadow';
import type { WidgetManifest } from '../manifest';
import type { VendorSheet } from '../vendor-sheet';
import { registerHostConfig } from '../host-config';
Expand Down Expand Up @@ -66,6 +66,61 @@ describe('createWidgetShadow vendor sheets', () => {
expect(tags).toHaveLength(1);
expect(tags[0].textContent).toContain('.portal-probe');
});

it("does not let a portal sheet's :root token defaults override the host's tokens", () => {
// Host brand tokens, declared before the widget mounts (the normal SSR order).
const hostTokens = document.createElement('style');
hostTokens.textContent = ':root { --color_primary: #8dc63f; }';
document.head.appendChild(hostTokens);

mount({
portalSheets: [
makeSheet('tokens', { css: ':root {\n --color_primary: #000000;\n}\n.tokens-probe { color: var(--color_primary); }' }),
],
});

expect(getComputedStyle(document.documentElement).getPropertyValue('--color_primary').trim()).toBe('#8dc63f');
const injected = document.head.querySelector('style[data-widget-portal-css="tokens"]');
expect(injected?.textContent).toContain(':where(:root)');
hostTokens.remove();
});
});

describe('demoteRootSelectors', () => {
it('wraps :root selectors, including in selector lists and compounds', () => {
expect(demoteRootSelectors(':root{--a:1}')).toBe(':where(:root){--a:1}');
expect(demoteRootSelectors('html, :root .x{}')).toBe('html, :where(:root) .x{}');
expect(demoteRootSelectors(':root[data-theme=DARK]{}')).toBe(':where(:root)[data-theme=DARK]{}');
});

it('is idempotent and leaves unrelated text alone', () => {
expect(demoteRootSelectors(':where(:root){}')).toBe(':where(:root){}');
expect(demoteRootSelectors('.rooted{} .x:root-ish{}')).toBe('.rooted{} .x:root-ish{}');
});

it('rewrites selectors inside at-rules and nested rules', () => {
expect(demoteRootSelectors('@media (min-width: 1px) { :root { --a: 1; } }'))
.toBe('@media (min-width: 1px) { :where(:root) { --a: 1; } }');
expect(demoteRootSelectors('.x { color: red; :root & { color: blue; } }'))
.toBe('.x { color: red; :where(:root) & { color: blue; } }');
});

it('never touches declaration values, strings, comments, url() or escaped selectors', () => {
const untouched = [
'.a::before { content: ":root"; }',
".a::before { content: ':root { }'; }",
'.\\:root { color: red; }',
'/* :root { --x: 1 } */ .a { color: red; }',
'.a { background: url(/img/:root.png); }',
'.a { --label: :root; }',
];
for (const css of untouched) expect(demoteRootSelectors(css)).toBe(css);
});

it('keeps the rewrite alongside untouched neighbours in one sheet', () => {
expect(demoteRootSelectors(':root { --a: 1; } .a::before { content: ":root {"; } .b { }'))
.toBe(':where(:root) { --a: 1; } .a::before { content: ":root {"; } .b { }');
});
});

describe('bridge lifecycle', () => {
Expand Down
96 changes: 95 additions & 1 deletion packages/widgets/src/core/widget-shadow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,102 @@ function injectFontFaces(sheet: VendorSheet): void {
/** Portal-sheet ids already injected into document.head. */
const injectedPortalIds = new Set<string>();

/**
* Widget stylesheets ship design-token defaults on `:root` (my-orders-tickets:
* `:root { --color_primary: #000000; --color_secondary: #00a2ff; … }`). Inside a
* shadow root `:root` never matches, so they are inert there — but a portal
* sheet is injected into document.head, where they match the host's <html>.
* Appended after the host's own token block, equal specificity lets the widget
* repaint the whole host site with its defaults.
*
* `:where(:root)` has zero specificity: any host `:root { … }` wins regardless
* of order, and the defaults still apply to portaled markup when the host
* declares nothing.
*
* Only selector preludes are rewritten — the text a `{` closes. Declaration
* values (ended by `;` or `}`), strings, comments, `url(…)` and escaped
* characters (`.\:root`) pass through untouched, at any nesting depth
* (`@media`, `@supports`, CSS nesting).
*/
export function demoteRootSelectors(css: string): string {
let out = '';
let segment = ''; // text since the last structural `{`, `}` or `;`
for (let i = 0; i < css.length; ) {
const opaque = opaqueTokenLength(css, i);
if (opaque > 0) {
segment += css.slice(i, i + opaque);
i += opaque;
continue;
}
const ch = css[i];
if (ch === '{') {
out += rewriteRootInPrelude(segment) + ch;
segment = '';
} else if (ch === '}' || ch === ';') {
out += segment + ch;
segment = '';
} else {
segment += ch;
}
i += 1;
}
return out + segment;
}

/** Wrap each unescaped, unquoted `:root` pseudo-class in one selector prelude. */
function rewriteRootInPrelude(prelude: string): string {
let result = '';
for (let i = 0; i < prelude.length; ) {
const opaque = opaqueTokenLength(prelude, i);
if (opaque > 0) {
result += prelude.slice(i, i + opaque);
i += opaque;
} else if (
prelude.startsWith(':root', i) &&
!/[\w-]/.test(prelude[i + 5] ?? '') &&
!result.endsWith(':where(')
) {
result += ':where(:root)';
i += 5;
} else {
result += prelude[i];
i += 1;
}
}
return result;
}

/**
* Length of the token at `at` whose contents are never structure or selectors:
* an escape (`\:`), a string, a comment or `url(…)`. 0 when `at` starts none.
*/
function opaqueTokenLength(text: string, at: number): number {
const ch = text[at];
if (ch === '\\') return Math.min(2, text.length - at);
if (ch === '"' || ch === "'") {
let j = at + 1;
while (j < text.length && text[j] !== ch) j += text[j] === '\\' ? 2 : 1;
return Math.min(j + 1, text.length) - at;
}
if (ch === '/' && text[at + 1] === '*') {
const end = text.indexOf('*/', at + 2);
return (end === -1 ? text.length : end + 2) - at;
}
if (/^url\(/i.test(text.slice(at, at + 4))) {
let j = at + 4;
while (j < text.length && text[j] !== ')') j += Math.max(1, opaqueTokenLength(text, j));
return Math.min(j + 1, text.length) - at;
}
return 0;
}

function injectPortalSheet(sheet: VendorSheet): void {
injectHeadStyleOnce(injectedPortalIds, 'data-widget-portal-css', sheet.id, sheet.css);
injectHeadStyleOnce(
injectedPortalIds,
'data-widget-portal-css',
sheet.id,
demoteRootSelectors(sheet.css),
);
}

// Unregistered custom elements default to display:inline, which collapses
Expand Down