From ea75afd0b1a228261827baba060366320c20d4a8 Mon Sep 17 00:00:00 2001 From: JpMaxMan Date: Sat, 12 Sep 2026 21:37:01 -0500 Subject: [PATCH 1/2] fix(core): keep portal sheets' :root token defaults from overriding host tokens Portal sheets are injected into document.head so portaled widget markup (e.g. my-orders-tickets' datepicker) is styled. my-orders-tickets-widget's CSS carries design-token defaults on :root (`--color_primary: #000000; --color_secondary: #00a2ff; ...`). Inside a shadow root :root never matches, but in document.head it matches the host's , and because the sheet is appended after the host's own token block, equal specificity lets the widget repaint the whole site: black navbar, blue accents on every page that mounts my-tickets. Rewrite :root selectors in portal sheets to :where(:root) before injection. Zero specificity means any host :root declaration wins regardless of order, while the defaults still apply to portaled markup on hosts that declare none. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01H91jC95F3EjBfzk5DVqRVb --- .../src/core/__tests__/widget-shadow.test.ts | 33 ++++++++++++++++++- packages/widgets/src/core/widget-shadow.ts | 23 ++++++++++++- 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/packages/widgets/src/core/__tests__/widget-shadow.test.ts b/packages/widgets/src/core/__tests__/widget-shadow.test.ts index 934207c..da91f57 100644 --- a/packages/widgets/src/core/__tests__/widget-shadow.test.ts +++ b/packages/widgets/src/core/__tests__/widget-shadow.test.ts @@ -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'; @@ -66,6 +66,37 @@ 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{}'); + }); }); describe('bridge lifecycle', () => { diff --git a/packages/widgets/src/core/widget-shadow.ts b/packages/widgets/src/core/widget-shadow.ts index e453ba9..361734a 100644 --- a/packages/widgets/src/core/widget-shadow.ts +++ b/packages/widgets/src/core/widget-shadow.ts @@ -69,8 +69,29 @@ function injectFontFaces(sheet: VendorSheet): void { /** Portal-sheet ids already injected into document.head. */ const injectedPortalIds = new Set(); +/** + * 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 . + * 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. + */ +export function demoteRootSelectors(css: string): string { + return css.replace(/(? Date: Sat, 12 Sep 2026 21:44:14 -0500 Subject: [PATCH 2/2] fix(core): scope :root demotion to selector preludes Review feedback (CodeRabbit): the regex rewrote any `:root` text, so a portal sheet with `content: ":root"` or an escaped `.\:root` selector would be altered. Replace it with a small scanner that rewrites only selector preludes (the text a `{` closes), skipping strings, comments, url() and escapes, at any nesting depth. Verified against the real my-tickets sheet: every :root is wrapped and the text is otherwise byte-identical. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01H91jC95F3EjBfzk5DVqRVb --- .../src/core/__tests__/widget-shadow.test.ts | 24 ++++++ packages/widgets/src/core/widget-shadow.ts | 75 ++++++++++++++++++- 2 files changed, 98 insertions(+), 1 deletion(-) diff --git a/packages/widgets/src/core/__tests__/widget-shadow.test.ts b/packages/widgets/src/core/__tests__/widget-shadow.test.ts index da91f57..03a1edd 100644 --- a/packages/widgets/src/core/__tests__/widget-shadow.test.ts +++ b/packages/widgets/src/core/__tests__/widget-shadow.test.ts @@ -97,6 +97,30 @@ describe('demoteRootSelectors', () => { 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', () => { diff --git a/packages/widgets/src/core/widget-shadow.ts b/packages/widgets/src/core/widget-shadow.ts index 361734a..689c099 100644 --- a/packages/widgets/src/core/widget-shadow.ts +++ b/packages/widgets/src/core/widget-shadow.ts @@ -80,9 +80,82 @@ const injectedPortalIds = new Set(); * `: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 { - return css.replace(/(? 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 {