From 47e86ff936705f7a7fa1a46db01ef249872e9af3 Mon Sep 17 00:00:00 2001 From: Oto Macenauer Date: Wed, 16 Sep 2026 16:42:58 +0200 Subject: [PATCH 1/5] fix: inline the knowledge base CSS and layer sub-app CSS so a fragment head swap cannot mangle it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Embedded as a web fragment, Library → app → Library left the catalog rendered under the app's stylesheet, and the masthead on the next app wore two themes. reframed owns the fragment's and has lost, relocated and duplicated head / + - {/* Inter is self-hosted: its @font-face lives in knowledge-base.css, which the - layout already imports, so there is nothing to link here (#54). */} {/* Embedded as a web fragment, the ClientRouter needs two corrections — a view transition on the host document, and a swap that keeps reframed's wf-* tree flat. Standalone the module is inert. Bundled to a file, never @@ -83,6 +92,10 @@ const bodyClasses = [ + {/* The knowledge base stylesheet, Inter's self-hosted @font-face included. + First in the body so it is parsed before any content it styles — and in + the body at all for the reason in the header comment. */} + diff --git a/src/pages/index.astro b/src/pages/index.astro index 34f3a1e..90630e1 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -16,7 +16,9 @@ const buildDate = new Date().toISOString().slice(0, 10); -
+ {/* `kb-shell`: the catalog is fenced off from any sub-app stylesheet that + outlived its page inside a web fragment — see knowledge-base.css. */} +
diff --git a/src/styles/knowledge-base.css b/src/styles/knowledge-base.css index a5d70ac..35c7f89 100644 --- a/src/styles/knowledge-base.css +++ b/src/styles/knowledge-base.css @@ -1,9 +1,73 @@ +/* ── Cascade layers ───────────────────────────────────────────────────────── + Must match LAYER_ORDER in src/utils/css-layers.js — that file explains the + order. In short: every documentation app's CSS is wrapped in `kb-app`, which + sits below the knowledge base's own rules and utilities, and `kb-reset` + holds the fence below that keeps leaked sub-app CSS out of the knowledge + base's own regions. Declared ahead of Tailwind's import so this sheet + establishes the same order as the layout's and every rewritten + sub-app stylesheet, whichever the browser happens to parse first. */ +@layer theme, base, kb-app, kb-reset, components, utilities; + @import "tailwindcss"; /* Scan templates for class names */ @source "../src/templates/**/*.js"; @source "../scripts/**/*.js"; +/* ── Fence around the knowledge base's own regions ────────────────────────── + `.kb-shell` marks the masthead and the landing catalog: the markup the + knowledge base owns on a page that otherwise belongs to a documentation app. + Inside a web fragment that app's stylesheets can outlive their page (see + css-layers.js), and a docs theme's `h1 {…}`, `p {…}`, `a {…}` would restyle + the masthead and, on the way back to the catalog, the cards. + + `all: revert` rolls every property back to the browser default, discarding + whatever author rule won — and because this layer sits above `kb-app`, the + author rule it discards is the leaked one. The knowledge base's own rules + are unlayered or in `utilities`, both above `kb-reset`, so they apply on top + as before. Custom properties are not touched by `all`, so the design tokens + still flow in from :host/wf-html. + + The revert also discards Tailwind's Preflight, which lives in `base`; the + handful of Preflight defaults the shell relies on are restated right after. + SVG elements and their subtrees are excluded: `revert` would discard their + presentation attributes (`fill="none"`, `stroke="currentColor"` on the + itself) along with author CSS — every icon turned solid black when + only the subtree was excluded — and no sub-app rule needs fencing inside + an icon. */ +@layer kb-reset { + .kb-shell, + .kb-shell :not(svg, svg *), + .kb-shell ::before, + .kb-shell ::after { + all: revert; + } + .kb-shell, + .kb-shell :not(svg, svg *), + .kb-shell ::before, + .kb-shell ::after { + box-sizing: border-box; + margin: 0; + padding: 0; + border: 0 solid; + } + .kb-shell :is(h1, h2, h3, h4, h5, h6) { + font-size: inherit; + font-weight: inherit; + } + .kb-shell a { + color: inherit; + text-decoration: inherit; + } + .kb-shell :is(img, svg, video, canvas, audio, iframe, embed, object) { + display: block; + vertical-align: middle; + } + .kb-shell :is(ol, ul, menu) { + list-style: none; + } +} + /* ── Typeface ─────────────────────────────────────────────────────────────── Inter, self-hosted from @fontsource-variable/inter. diff --git a/src/templates/shadow-compat.js b/src/templates/shadow-compat.js index d177808..fec0126 100644 --- a/src/templates/shadow-compat.js +++ b/src/templates/shadow-compat.js @@ -4,9 +4,8 @@ // // @tailwindcss/vite strips custom element selectors (wf-html, wf-document) during // its CSS optimisation pass, making it impossible to target shadow DOM elements -// via the external style.css file alone. Injecting these rules as an inline -// ). +// +// WHY LAYERS +// +// Inside a web fragment the knowledge base's head is managed by reframed, and +// reframed does not reliably remove a previous page's /`)); + } + }); + + test('every sub-app stylesheet is served wrapped in the sub-app cascade layer', () => { + // Below the knowledge base's own rules, so a sheet that outlives its page + // inside a web fragment cannot restyle the masthead or the catalog. + const appCss = ['user-guide', 'guide-mirror', 'platform-overview', 'release-process'] + .flatMap((slug) => filesWithExt(join(DIST, slug), '.css')); + expect(appCss.length, 'no sub-app CSS in dist/').toBeGreaterThan(0); + for (const file of appCss) { + const css = readFileSync(file, 'utf8'); + const rel = relative(DIST, file); + expect(css.startsWith(LAYER_ORDER), `${rel} does not open with the layer order`).toBe(true); + expect(css, `${rel} is not wrapped in the sub-app layer`).toContain(`@layer ${SUB_APP_LAYER}{`); + } + // The fixture docs theme is itself Tailwind output declaring theme/base/ + // components/utilities — nested inside kb-app they stay the app's own. + const docs = read('user-guide/docs/style.css'); + const block = docs.indexOf(`@layer ${SUB_APP_LAYER}{`); + expect(docs.indexOf('@layer theme', block + 1), "the app's own layers must sit inside the block").toBeGreaterThan(block); + }); + test('every CSS asset carries a content hash', () => { const astroDir = join(DIST, '_astro'); const unhashed = (existsSync(astroDir) ? readdirSync(astroDir) : []) diff --git a/tests/css-isolation.spec.js b/tests/css-isolation.spec.js new file mode 100644 index 0000000..fd86b22 --- /dev/null +++ b/tests/css-isolation.spec.js @@ -0,0 +1,263 @@ +/** + * tests/css-isolation.spec.js + * + * The knowledge base's own CSS — masthead and catalog — must survive whatever + * the web-fragments runtime does to the fragment's across ClientRouter + * navigation. reframed has lost, relocated and duplicated head /

x

', + "", + ), 'docs'); + const head = headHtml.match(/')).headHtml).toContain(''); + }); + + test('@charset is dropped and @import hoisted ahead of the layer block, itself layered', () => { + const css = layerSubAppCss( + '@charset "utf-8";\n@import url("a.css");\n@import "b.css" screen;\n' + + '@import url(c.css) layer(theme) supports(display:grid);\n@import url(d.css) layer;\nh1{color:red}', + ); + wrapped(css); + expect(css).not.toContain('@charset'); + const block = css.indexOf(`@layer ${SUB_APP_LAYER}{`); + for (const line of [ + `@import url("a.css") layer(${SUB_APP_LAYER});`, + `@import "b.css" layer(${SUB_APP_LAYER}) screen;`, + `@import url(c.css) layer(${SUB_APP_LAYER}.theme) supports(display:grid);`, + `@import url(d.css) layer(${SUB_APP_LAYER});`, + ]) { + const at = css.indexOf(line); + expect(at, `missing: ${line}`).toBeGreaterThan(-1); + expect(at, `${line} must precede the layer block`).toBeLessThan(block); + } + expect(css.slice(block)).toContain('h1{color:red}'); + }); + + test('an @import after a rule is invalid CSS already and is not activated by hoisting', () => { + const css = layerSubAppCss('p{a:b}\n@import url(late.css);'); + expect(css.indexOf('@import')).toBeGreaterThan(css.indexOf(`@layer ${SUB_APP_LAYER}{`)); + }); + + test("the app's own @layer rules nest inside the sub-app layer untouched", () => { + const css = layerSubAppCss('@layer theme, base;\n@layer base{h1{x:1}}\np{a:b}'); + wrapped(css); + expect(css).toContain('@layer theme, base;\n@layer base{h1{x:1}}'); + }); + + test('wrapping is idempotent and strips a BOM', () => { + const once = layerSubAppCss('body{margin:0}'); + expect(once.charCodeAt(0)).not.toBe(0xfeff); + expect(layerSubAppCss(once)).toBe(once); + }); test('rewrites URL-bearing meta content', () => { const { headHtml } = run(doc('

x

', '')); From 6e67c45bede24488138a4eb069b2cc1782c42055 Mon Sep 17 00:00:00 2001 From: Oto Macenauer Date: Wed, 16 Sep 2026 17:13:57 +0200 Subject: [PATCH 2/5] fix: prepend the layer order to the inline knowledge base stylesheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tailwind drops a bare `@layer a, b;` statement from its output, so the order written at the top of knowledge-base.css never reached the compiled sheet: the inline block opened with Tailwind's own `theme`, `base`, … layers and named `kb-app` nowhere. Any runtime that parses the body block before the layout's — a head-relocating reframed, for one — would then declare `kb-app` last, above the `kb-reset` fence. Base.astro now prepends LAYER_ORDER to the inline block, the stylesheet carries a note instead of the dead statement, and build-integrity asserts every page's inline block opens with the order. Co-Authored-By: Claude Fable 5.1 --- src/layouts/Base.astro | 9 +++++++-- src/styles/knowledge-base.css | 14 ++++++++------ tests/build-integrity.spec.js | 4 ++++ 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro index 052c6ff..b7b540f 100644 --- a/src/layouts/Base.astro +++ b/src/layouts/Base.astro @@ -94,8 +94,13 @@ const bodyClasses = [ {/* The knowledge base stylesheet, Inter's self-hosted @font-face included. First in the body so it is parsed before any content it styles — and in - the body at all for the reason in the header comment. */} - + the body at all for the reason in the header comment. The layer order is + prepended here rather than written in knowledge-base.css: Tailwind drops + a bare `@layer a, b;` statement from its output, so the compiled sheet + alone would establish `theme, base, …, kb-reset` and never name `kb-app`, + and a runtime that parses this block before the would then order + the sub-app layer above the fence. */} + diff --git a/src/styles/knowledge-base.css b/src/styles/knowledge-base.css index 35c7f89..ed2cc9d 100644 --- a/src/styles/knowledge-base.css +++ b/src/styles/knowledge-base.css @@ -1,13 +1,15 @@ /* ── Cascade layers ───────────────────────────────────────────────────────── - Must match LAYER_ORDER in src/utils/css-layers.js — that file explains the - order. In short: every documentation app's CSS is wrapped in `kb-app`, which + Layer order is LAYER_ORDER in src/utils/css-layers.js — that file explains + it. In short: every documentation app's CSS is wrapped in `kb-app`, which sits below the knowledge base's own rules and utilities, and `kb-reset` holds the fence below that keeps leaked sub-app CSS out of the knowledge - base's own regions. Declared ahead of Tailwind's import so this sheet - establishes the same order as the layout's and every rewritten - sub-app stylesheet, whichever the browser happens to parse first. */ -@layer theme, base, kb-app, kb-reset, components, utilities; + base's own regions. + The order statement is NOT written here: Tailwind drops a bare + `@layer a, b;` from its output, so it would silently vanish. Base.astro + prepends it to this sheet's inline block instead, and the layout's + and every rewritten sub-app stylesheet carry it too, so the order holds + whichever the browser parses first. */ @import "tailwindcss"; /* Scan templates for class names */ diff --git a/tests/build-integrity.spec.js b/tests/build-integrity.spec.js index c34f88c..bff0176 100644 --- a/tests/build-integrity.spec.js +++ b/tests/build-integrity.spec.js @@ -450,6 +450,10 @@ test.describe('stylesheet emission', () => { const css = kbInlineStylesheet(html); expect(css, `${rel}: the inline stylesheet is not the compiled knowledge base CSS`).toContain('.kb-masthead'); expect(css, `${rel}: the inline stylesheet must carry the fence layer`).toContain('@layer kb-reset'); + // Tailwind drops a bare `@layer a, b;` from its output, so the order has + // to be prepended by the layout — and it has to be there, or a runtime + // that parses this block before the head orders `kb-app` above the fence. + expect(css.startsWith(LAYER_ORDER), `${rel}: the inline stylesheet must open with the layer order`).toBe(true); // Body-first: parsed before any content it styles. expect(html.indexOf('data-kb-stylesheet'), `${rel}: the stylesheet must open the body`) .toBeLessThan(html.indexOf('id="kb-masthead"')); From 977362b5b32f4409ca9ae1da0e328a41d0ea84b2 Mon Sep 17 00:00:00 2001 From: Oto Macenauer Date: Wed, 16 Sep 2026 21:08:40 +0200 Subject: [PATCH 3/5] test: docs pages reached by repeated client navigation match a hard load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit catalog → showcase → docs → Customising → Adding Pages by ClientRouter, in both embeddings; each page's sub-app content styling and stylesheet inventory must equal a hard load of the same URL taken afterwards in the same browser. Pins the reported symptom — the mkdocs part losing its CSS after client navigation — without pinning literal values. Co-Authored-By: Claude Fable 5.1 --- tests/css-isolation.spec.js | 125 ++++++++++++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) diff --git a/tests/css-isolation.spec.js b/tests/css-isolation.spec.js index fd86b22..4878acc 100644 --- a/tests/css-isolation.spec.js +++ b/tests/css-isolation.spec.js @@ -160,6 +160,131 @@ for (const [mode, open] of [['bound', gotoBoundFragment], ['unbound', gotoFragme }); } +// ───────────────────────────────────────────────────────────────────────────── +// The other direction: the sub-app's OWN pages must be styled after a chain of +// client navigations exactly as they are on a hard load. The reference is the +// hard load of the same URL, taken afterwards in the same browser, so the test +// pins no literal values — only that the ClientRouter path (head swap, body +// swap, layered sub-app stylesheet kept or re-fetched) ends in the same place +// the streaming path does. + +/** Computed styles of the sub-app's content: the element after the masthead in wf-body. */ +const APP_PROBES = { + '': ['display', 'flex-direction', 'grid-template-columns', 'max-width', 'font-family', 'color'], + 'nav': ['display', 'position', 'width', 'background-color', 'border-right-width'], + 'nav a': ['display', 'color', 'font-size', 'padding-left', 'text-decoration-line'], + 'main': ['max-width', 'padding-left', 'padding-top', 'margin-left'], + 'h1': ['font-size', 'font-weight', 'line-height', 'margin-bottom', 'color', 'letter-spacing'], + 'h2': ['font-size', 'font-weight', 'margin-top', 'margin-bottom', 'color'], + 'p': ['font-size', 'line-height', 'margin-bottom', 'color'], + 'main a': ['color', 'text-decoration-line'], + 'ul': ['list-style-type', 'padding-left', 'margin-bottom'], + 'li': ['margin-bottom', 'display'], + 'code': ['font-family', 'font-size', 'background-color', 'padding-left', 'border-radius'], + 'pre': ['background-color', 'padding-top', 'border-radius', 'overflow-x', 'font-size'], + 'table': ['border-collapse', 'width'], + 'th': ['font-weight', 'text-align', 'border-bottom-width'], + 'img': ['max-width', 'display'], + '.sc-hero': ['background-color', 'padding-top'], + '.sc-hero__title': ['font-size', 'color', 'font-weight'], +}; + +async function appSnapshot(page) { + return page.evaluate((probes) => { + function fragmentRoot(root) { + if (root.querySelector('wf-document')) return root; + for (const el of root.querySelectorAll('*')) { + if (el.shadowRoot) { const f = fragmentRoot(el.shadowRoot); if (f) return f; } + } + return null; + } + const root = fragmentRoot(document); + if (!root) return null; + // Stylesheet inventory: every link/style in the fragment tree, with its rule count. + const sheets = [...root.querySelectorAll('link[rel~="stylesheet"], style')].map((n) => { + let rules; try { rules = n.sheet ? n.sheet.cssRules.length : 'no sheet'; } catch { rules = 'opaque'; } + const id = n.getAttribute('href') ?? n.textContent.slice(0, 24).replace(/\s+/g, ' '); + return `${n.localName}@${n.parentNode.localName ?? 'shadow'}:${id}:${rules}${n.sheet?.disabled ? ':disabled' : ''}`; + }); + // The sub-app's content: the first element after the masthead that is not a style. + let app = root.querySelector('#kb-masthead')?.nextElementSibling; + while (app && app.localName === 'style') app = app.nextElementSibling; + const styles = {}; + for (const [selector, props] of Object.entries(probes)) { + const el = selector ? app?.querySelector(selector) : app; + if (!el) { styles[selector] = null; continue; } + const cs = getComputedStyle(el); + styles[selector] = Object.fromEntries(props.map((p) => [p, cs.getPropertyValue(p)])); + } + return { sheets, styles, h1: app?.querySelector('h1')?.textContent.replace(/\s+/g, ' ').trim() ?? null }; + }, APP_PROBES); +} + +/** Waits for the sub-app content's own

(the masthead has none on an app page). */ +async function waitForAppH1(page, text) { + await expect.poll(async () => (await appSnapshot(page))?.h1, { timeout: 15_000 }).toBe(text); +} + +const DOCS = { + overview: { path: '/knowledge-base/user-guide/docs/', h1: 'Knowledge Base Docs Example' }, + customising: { path: '/knowledge-base/user-guide/docs/customising/', h1: 'Customising the Template' }, + addingPages: { path: '/knowledge-base/user-guide/docs/adding-pages/', h1: 'Adding Pages' }, +}; + +for (const [mode, open] of [['bound', gotoBoundFragment], ['unbound', gotoFragment]]) { + test.describe(`docs pages reached by repeated client navigation, ${mode} embedding`, () => { + test('catalog → showcase → docs → Customising → Adding Pages: each page styled as on a hard load', async ({ page }) => { + test.setTimeout(90_000); + await open(page, '/knowledge-base/'); + await waitForCatalog(page); + + const visited = []; + const record = async (label, path) => { + await page.waitForTimeout(500); // let the swap's stylesheet fetches settle + const snap = await appSnapshot(page); + expect(snap, `${label}: no fragment tree`).not.toBeNull(); + visited.push({ label, path, snap }); + }; + + // Catalog card → the app's showcase index. + await clickInFragment(page, CARD.userGuide); + await waitForApp(page); + await record('showcase', '/knowledge-base/user-guide/'); + + // Showcase → the mkdocs part. The sub-app's own link, not the masthead's. + await clickInFragment(page, `#showcase-root a[href="${DOCS.overview.path}"]`); + await waitForAppH1(page, DOCS.overview.h1); + await record('docs overview', DOCS.overview.path); + + // Sidebar hops within the docs. + await clickInFragment(page, `a[href="${DOCS.customising.path}"]`); + await waitForAppH1(page, DOCS.customising.h1); + await record('customising', DOCS.customising.path); + + await clickInFragment(page, `a[href="${DOCS.addingPages.path}"]`); + await waitForAppH1(page, DOCS.addingPages.h1); + await record('adding pages', DOCS.addingPages.path); + + // Sanity on the last page, so a null-everywhere snapshot cannot pass. + const last = visited.at(-1).snap; + expect(last.styles['h1'], 'docs h1 not found').not.toBeNull(); + expect(last.styles['nav a'], 'docs sidebar link not found').not.toBeNull(); + expect(last.sheets.some((s) => s.includes('/user-guide/docs/style.css')), 'docs stylesheet missing after navigation').toBe(true); + + // Now the reference: every URL hard-loaded, and it must look the same. + for (const { label, path, snap } of visited) { + await open(page, path); + if (path === '/knowledge-base/user-guide/') await waitForApp(page); + else await waitForAppH1(page, Object.values(DOCS).find((d) => d.path === path).h1); + await page.waitForTimeout(500); + const hard = await appSnapshot(page); + expect(snap.styles, `${label}: computed styles after client navigation differ from a hard load of ${path}`).toEqual(hard.styles); + expect(snap.sheets, `${label}: stylesheet nodes after client navigation differ from a hard load of ${path}`).toEqual(hard.sheets); + } + }); + }); +} + // ───────────────────────────────────────────────────────────────────────────── test.describe('a sub-app stylesheet that outlives its page', () => { /** From c6b1f3590b0b3fd0e07b45139d2974c2ae356be7 Mon Sep 17 00:00:00 2001 From: Oto Macenauer Date: Wed, 16 Sep 2026 21:13:18 +0200 Subject: [PATCH 4/5] test: the repeated docs navigation must be router-only, never a reload Arms the fragment probe before the first click and, after every hop, requires the host document alive, the reframed iframe unchanged, and exactly N ClientRouter swaps and N host view transitions for N hops. Co-Authored-By: Claude Fable 5.1 --- tests/css-isolation.spec.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/css-isolation.spec.js b/tests/css-isolation.spec.js index 4878acc..a01b259 100644 --- a/tests/css-isolation.spec.js +++ b/tests/css-isolation.spec.js @@ -24,6 +24,7 @@ import { test, expect } from '@playwright/test'; import { gotoBoundFragment, gotoFragment, waitForFragmentText, fragmentFrame, queryInShadow, countInShadow, + armFragmentProbe, fragmentProbe, hostStillAlive, } from './support/fragment.js'; /** What the masthead and the catalog look like: computed style per selector. */ @@ -237,10 +238,20 @@ for (const [mode, open] of [['bound', gotoBoundFragment], ['unbound', gotoFragme test.setTimeout(90_000); await open(page, '/knowledge-base/'); await waitForCatalog(page); + // Sentinels on the host window and inside the reframed iframe, plus + // counters for ClientRouter swaps and host-document view transitions: + // every hop below must be a router transition, never a document load. + await armFragmentProbe(page); const visited = []; const record = async (label, path) => { await page.waitForTimeout(500); // let the swap's stylesheet fetches settle + const hops = visited.length + 1; + expect(await hostStillAlive(page), `${label}: the host document reloaded`).toBe(true); + const probe = await fragmentProbe(page); + expect(probe.alive, `${label}: the reframed iframe was reloaded or recreated`).toBe(true); + expect(probe.swaps, `${label}: ClientRouter swaps after ${hops} hop(s)`).toBe(hops); + expect(probe.hostViewTransitions, `${label}: host view transitions after ${hops} hop(s)`).toBe(hops); const snap = await appSnapshot(page); expect(snap, `${label}: no fragment tree`).not.toBeNull(); visited.push({ label, path, snap }); From 0f0c769c82893bd88d3a29d559c3fb887cbcf222 Mon Sep 17 00:00:00 2001 From: Oto Macenauer Date: Wed, 16 Sep 2026 22:09:09 +0200 Subject: [PATCH 5/5] fix: never move a reused head node in the fragment swap; test a pierced host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reproduced the report — the mkdocs pages losing their CSS on client navigation, fine on reload — with server-side piercing on, the mode a production Angular SSR gateway runs in. It shows on the very first navigation, not only on a same-route repeat. Cause: on a pierced page the sub-app's has already been moved once, by reframed portalling the server-rendered host into the 's shadow root with moveBefore(). The head swap in embedded-transitions.js then moved it again with moveBefore() to keep the new page's order, and Chromium drops the sheet from the shadow root's applied stylesheets on that second move: the element keeps its .sheet, styleSheets no longer lists it, the page renders unstyled until a reload. The client-rendered harness never hit it because there the link had only been inserted, never moved. The swap now leaves every reused head node exactly where it is and inserts only new nodes, each ahead of the next reused one. Head order no longer matters for the cascade — every stylesheet declares the layer order itself — so nothing is lost by keeping the old anchors' order. tests/host/server.mjs gains KB_PIERCING=true, playwright.config.js a chromium-pierced project on :4202, so every embedded test now runs against both hosts. The host stand-in's router script is deferred: a created before web-fragment-host is defined cannot adopt a pierced host (portalHost is not a function) and silently falls back to client rendering — the same ordering an Angular host gets from calling initializeWebFragments() in main.ts. The css-isolation sheet inventory compares applied/not-applied rather than rule counts, since a pierced page carries every inline rule twice. Co-Authored-By: Claude Fable 5.1 --- CLAUDE.md | 13 ++++++++---- README.md | 10 ++++++++- playwright.config.js | 19 +++++++++++++++++ src/scripts/embedded-transitions.js | 33 ++++++++++++++++++++--------- tests/css-isolation.spec.js | 12 ++++++++--- tests/host/server.mjs | 15 ++++++++++--- tests/web-fragment.spec.js | 3 ++- 7 files changed, 83 insertions(+), 22 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 108c813..1432605 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -77,7 +77,7 @@ Orchestrator: `scripts/build-vite.js`. Flags: `--local`, `--headless`. - `src/components/Masthead.astro` — Persistent Knowledge base header + Library/current-app sub-nav (all pages, both modes) - `src/components/AppCard.astro`, `src/components/AppIcon.astro` — Catalog card and its icon - `src/templates/shadow-compat.js` — Shadow-DOM design-token styles, injected into the body by the layout -- `src/scripts/embedded-transitions.js` — Loaded by the layout; inert standalone. Inside a web fragment it runs Astro's view transition on the host document (the iframe's is never painted) and replaces Astro's swap with one that targets reframed's `wf-html`/`wf-head`/`wf-body`, because the default swap nests a new `wf-html` per navigation and leaks every stylesheet +- `src/scripts/embedded-transitions.js` — Loaded by the layout; inert standalone. Inside a web fragment it runs Astro's view transition on the host document (the iframe's is never painted) and replaces Astro's swap with one that targets reframed's `wf-html`/`wf-head`/`wf-body`, because the default swap nests a new `wf-html` per navigation and leaks every stylesheet. Its head diff never moves a reused node: on a pierced page a `` already moved once by reframed's portal falls out of the applied stylesheets when moved again - `src/utils/config.js` — `PATH_PREFIX`/`BASE_PATH`, `isHeadlessBuild()` and `REGISTRY_FILE` — the build-wide constants - `src/utils/registry.js` — Registry validation, manifest reading/validation, expansion map. Shared by the build and by Astro so both resolve the same registry - `scripts/build-vite.js` — Build orchestrator (4 steps: prepare, hoist, copy assets, astro build) @@ -153,9 +153,14 @@ Self-contained Playwright E2E — `npm test` auto-starts everything (no external 2. **:4201 host** — `tests/host/server.mjs`, a minimal Express "wrapping web-fragment application" (`FragmentGateway` + `getNodeMiddleware`) that proxies/embeds the :3000 fragment on a single origin via ``. - -Tests drive the host origin (`http://localhost:4201`). Suites (`tests/`), all four -commands listed in `AGENTS.md`: +3. **:4202 pierced host** — the same server with `KB_PIERCING=true`: server-side piercing + on, as a production Angular SSR gateway runs. The first page then arrives as SSR markup + that reframed adopts and portals — a different starting tree for the ClientRouter, and + the only place the sub-app CSS ever got lost on navigation (a reused head `` moved a + second time drops out of the applied stylesheets; see `embedded-transitions.js`). + +Every embedded test runs twice, as Playwright projects `chromium` (:4201) and +`chromium-pierced` (:4202). Suites (`tests/`), all four commands listed in `AGENTS.md`: - `build-integrity.spec.js` — `dist/` output: both apps enumerated, absolute URL rewriting, headless markup and the per-app `"headless"` override, the knowledge base stylesheet inlined into every body (no page links one from its head) plus its stable `dist/style.css` diff --git a/README.md b/README.md index 6487cc9..2d43d5f 100644 --- a/README.md +++ b/README.md @@ -251,7 +251,7 @@ const gateway = new FragmentGateway(); gateway.registerFragment({ fragmentId: 'knowledge-base', endpoint: 'http://localhost:3000', // the fragment server - piercing: false, + piercing: false, // or true (SSR piercing) — both are tested routePatterns: [ '/knowledge-base/:_*', // landing + sub-app pages + assets '/__wf/knowledge-base/:_*', // fragment asset prefix @@ -310,6 +310,14 @@ import { initializeWebFragments } from 'web-fragments'; initializeWebFragments(); ``` +`initializeWebFragments()` must run before anything renders a ``. +With piercing on, a `` created earlier finds the server-rendered +`` before that element is defined and fails with +`portalHost is not a function`; the fragment then falls back to client +rendering. (`tests/host/server.mjs` loads its router stand-in with `defer` for +exactly this reason.) `piercing: true` — how an Angular SSR gateway usually +embeds — is supported and is what the `chromium-pierced` test project runs. + Per fragment page this then happens: the ClientRouter pushes `/knowledge-base/…`, Angular sees the `popstate` and navigates there, matches the same `**` route config and reuses the component (the default `RouteReuseStrategy`), so the diff --git a/playwright.config.js b/playwright.config.js index a0c7037..72068d6 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -56,12 +56,31 @@ export default defineConfig({ stdout: 'pipe', stderr: 'pipe', }, + { + // The same host with server-side piercing on — how a production Angular + // SSR gateway embeds the fragment. The first page then arrives as SSR + // markup that reframed adopts and portals, a different starting point + // for the ClientRouter than client rendering; the docs pages losing + // their CSS on the first navigation only ever happened here. + command: 'node tests/host/server.mjs', + env: { HOST_PORT: '4202', KB_PIERCING: 'true' }, + port: 4202, + reuseExistingServer: !process.env.CI, + timeout: 30_000, + stdout: 'pipe', + stderr: 'pipe', + }, ], + // Every embedded test runs against both hosts. projects: [ { name: 'chromium', use: { ...devices['Desktop Chrome'] }, }, + { + name: 'chromium-pierced', + use: { ...devices['Desktop Chrome'], baseURL: 'http://localhost:4202' }, + }, ], }); diff --git a/src/scripts/embedded-transitions.js b/src/scripts/embedded-transitions.js index 95f0172..7871625 100644 --- a/src/scripts/embedded-transitions.js +++ b/src/scripts/embedded-transitions.js @@ -81,19 +81,32 @@ function swapHead(head, newHead) { const next = counterpart(old, newHead); if (next && !reuse.has(next)) reuse.set(next, old); } - // Walk the new head in order, so the rebuilt head keeps the new page's - // order: the layout's `@layer base;` has to stay ahead of the sub-app's - // stylesheets after every navigation, not only on first load. - const canMove = typeof head.moveBefore === 'function'; - const placed = new Set(); + // A reused node is never moved, not even with moveBefore(). On a pierced + // page the sub-app's has already been moved once — + // reframed portals the server-rendered host into the 's + // shadow root with moveBefore() — and moving it a second time makes + // Chromium drop the sheet from the shadow root's applied stylesheets: the + // element keeps its .sheet, styleSheets no longer lists it, and the page + // renders without its CSS until a reload. (Removing and re-appending would + // re-fetch and re-apply it, at the price of a flash.) So reused nodes are + // anchors, and each new node is inserted ahead of the next anchor. The + // anchors keep the old page's relative order among themselves, which is + // fine: every stylesheet declares the cascade layer order itself, so + // nothing depends on which one the browser parses first. + const kept = new Set(); + let anchor = head.firstChild; for (const next of [...newHead.children]) { - const node = reuse.get(next) ?? next; - if (node === next || !canMove) head.append(node); - else head.moveBefore(node, null); // keeps a stylesheet's state; append would re-apply it - placed.add(node); + const old = reuse.get(next); + if (old) { + kept.add(old); + anchor = old.nextSibling; + } else { + head.insertBefore(next, anchor); + kept.add(next); + } } for (const old of [...head.children]) { - if (!placed.has(old)) old.remove(); + if (!kept.has(old)) old.remove(); } } diff --git a/tests/css-isolation.spec.js b/tests/css-isolation.spec.js index a01b259..611d691 100644 --- a/tests/css-isolation.spec.js +++ b/tests/css-isolation.spec.js @@ -201,11 +201,17 @@ async function appSnapshot(page) { } const root = fragmentRoot(document); if (!root) return null; - // Stylesheet inventory: every link/style in the fragment tree, with its rule count. + // Stylesheet inventory: every link/style in the fragment tree, and whether + // it is applied — a member of the shadow root's styleSheets with rules. + // Not the rule count: a pierced page has every inline rule twice (reframed + // re-inserts them when it portals the server-rendered host), and that + // changes nothing about how the page renders. + const applied = new Set([...root.styleSheets]); const sheets = [...root.querySelectorAll('link[rel~="stylesheet"], style')].map((n) => { - let rules; try { rules = n.sheet ? n.sheet.cssRules.length : 'no sheet'; } catch { rules = 'opaque'; } + let rules; try { rules = n.sheet ? n.sheet.cssRules.length : 0; } catch { rules = 'opaque'; } const id = n.getAttribute('href') ?? n.textContent.slice(0, 24).replace(/\s+/g, ' '); - return `${n.localName}@${n.parentNode.localName ?? 'shadow'}:${id}:${rules}${n.sheet?.disabled ? ':disabled' : ''}`; + const state = !n.sheet ? 'no sheet' : !applied.has(n.sheet) ? 'NOT APPLIED' : rules === 0 ? 'empty' : 'applied'; + return `${n.localName}@${n.parentNode.localName ?? 'shadow'}:${id}:${state}${n.sheet?.disabled ? ':disabled' : ''}`; }); // The sub-app's content: the first element after the masthead that is not a style. let app = root.querySelector('#kb-masthead')?.nextElementSibling; diff --git a/tests/host/server.mjs b/tests/host/server.mjs index c339542..eb5a0c9 100644 --- a/tests/host/server.mjs +++ b/tests/host/server.mjs @@ -43,8 +43,13 @@ const gateway = new FragmentGateway(); gateway.registerFragment({ fragmentId: 'knowledge-base', endpoint: KB_ENDPOINT, - // Client-rendered embed (no SSR piercing) — matches the Astro fragment recipe. - piercing: false, + // Client-rendered embed (no SSR piercing) by default — the Astro fragment + // recipe. KB_PIERCING=true switches to server-side piercing, the mode a + // production Angular SSR gateway runs in: the first page then arrives as + // SSR markup inside a declarative shadow root and reframed adopts it, which + // is a different starting tree for the ClientRouter than the client-rendered + // wf-html/wf-head/wf-body one. + piercing: process.env.KB_PIERCING === 'true', // One pattern for pages + _astro assets + ClientRouter fetches, one for the // /__wf/ knowledge base CSS route the sub-app HTML references. routePatterns: ['/knowledge-base/:_*', '/__wf/knowledge-base/:_*'], @@ -115,7 +120,11 @@ function shell({ routes, fragmentSrc = null }) { import { initializeWebFragments } from 'web-fragments'; initializeWebFragments(); - + + `; } diff --git a/tests/web-fragment.spec.js b/tests/web-fragment.spec.js index 6ed3e5d..a1f6969 100644 --- a/tests/web-fragment.spec.js +++ b/tests/web-fragment.spec.js @@ -24,7 +24,8 @@ function collectBadResponses(page) { const bad = []; page.on('response', (res) => { const url = res.url(); - if (!url.startsWith('http://localhost:4201/')) return; // ignore external (fonts, etc.) + // Same origin as the host page — whichever host project this runs against. + if (!url.startsWith(new URL(page.url()).origin + '/')) return; // ignore external (fonts, etc.) if (res.status() >= 400) bad.push(`${res.status()} ${url}`); }); return bad;