Skip to content

fix: inline the knowledge base CSS and layer sub-app CSS so a fragment head swap cannot mangle it - #99

Merged
oto-macenauer-absa merged 5 commits into
masterfrom
fix/css-isolation-web-fragment
Sep 16, 2026
Merged

oto-macenauer-absa merged 5 commits into
masterfrom
fix/css-isolation-web-fragment

Conversation

@oto-macenauer-absa

Copy link
Copy Markdown
Collaborator

Closes #98.

What

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 <head> and has lost, relocated and duplicated head <link>/<style> nodes across ClientRouter swaps (web-fragments/web-fragments#297). The knowledge base stylesheet was one of those links; a sub-app's link outliving its page restyled the knowledge base's own markup.

Two defences, neither depending on what the runtime does to <head>:

  • Knowledge base CSS inlined into every <body> (?inline import in Base.astro), never a head <link>. The body is replaced whole on every navigation, so the CSS is present exactly when its page is. build.inlineStylesheets: 'always' folds Astro's own route-announcer rule in too — no page links a stylesheet of its own any more. dist/style.css is still published for outside consumers, now written from the inline block.
  • Sub-app CSS layered, knowledge base regions fenced. Every sub-app stylesheet (files in copyAssets(), inline <style> in transform.js) is wrapped in @layer kb-app, below the knowledge base's rules and utilities. The masthead and catalog (.kb-shell) sit behind an all: revert fence in @layer kb-reset, with the few Preflight defaults they rely on restated; SVG is excluded because revert discards presentation attributes (every icon went solid black in the first cut). The order theme, base, kb-app, kb-reset, components, utilities is emitted by the layout head, atop every rewritten sub-app stylesheet and in knowledge-base.css, so it holds whichever the browser parses first, and it keeps Tailwind's Preflight below the app's own CSS (docs headings keep their theme size). src/utils/css-layers.js owns the contract.

Known limit: !important in a sub-app sheet outranks the fence (importance inverts layer order).

Note on reproduction

The Express test host here (both embeddings, piercing: false) does not reproduce the mangling — the swap from #97 keeps the head clean in it. The production host is a different runtime, so this makes the knowledge base independent of head handling rather than fixing one runtime's behaviour.

Tests

  • New tests/css-isolation.spec.js: the round trip in bound and unbound embeddings leaves catalog + masthead computed styles identical to first load (baseline pins the icon outline); a leak injected on purpose — the fixture stylesheets plus a hostile layered sheet appended to the fragment head — applies outside the fence and changes nothing inside; a docs page keeps its heading sizes.
  • transform.spec.js: layerSubAppCss() — @charset dropped, leading @imports hoisted and layered, later ones untouched, idempotent.
  • build-integrity.spec.js: no page links an Astro stylesheet from its head, every page opens its body with the inline stylesheet, the layer order opens every head, every served sub-app stylesheet is wrapped.
  • standalone.spec.js assertions follow the new shape.

Embedded suite 180/180, standalone 22/22, astro check clean.

Docs

CLAUDE.md gains a CSS Isolation section, AGENTS.md a rule for the invariant, astro.config.mjs comments updated.

🤖 Generated with Claude Code

…t head swap cannot mangle it

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 <head> and has lost,
relocated and duplicated head <link>/<style> nodes across ClientRouter
swaps (web-fragments #297); the knowledge base stylesheet was one of
those links, and a sub-app's link outliving its page restyled the
knowledge base's own markup.

Two defences, neither depending on what the runtime does to <head>:

- The knowledge base stylesheet is inlined into every <body> (a
  `?inline` import in Base.astro) and never linked from <head>; the body
  is replaced whole on every navigation, so the CSS is present exactly
  when its page is. `build.inlineStylesheets: 'always'` folds Astro's
  own route-announcer rule in too, so no page links a stylesheet of its
  own. dist/style.css is still published, now from the inline block.

- Every sub-app stylesheet — files in copyAssets(), inline <style>
  blocks in transform.js — is wrapped in the `kb-app` cascade layer,
  below the knowledge base's rules and utilities, and the masthead and
  catalog (`.kb-shell`) sit behind an `all: revert` fence in the
  `kb-reset` layer (SVG excluded: revert discards presentation
  attributes and every icon went solid black). Layer order
  `theme, base, kb-app, kb-reset, components, utilities` is emitted by
  the layout head, atop every rewritten sub-app stylesheet and in
  knowledge-base.css, so it holds whichever the browser parses first,
  and keeps Tailwind's Preflight below the app's own CSS.

tests/css-isolation.spec.js reproduces the round trip in both
embeddings and injects a leak on purpose; transform.spec.js covers the
wrapper; build-integrity and standalone assertions follow the new shape.

Closes #98

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@oto-macenauer-absa oto-macenauer-absa added bug Something isn't working enhancement New feature or request labels Sep 16, 2026
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 <head> — 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 <noreply@anthropic.com>
@oto-macenauer-absa

Copy link
Copy Markdown
Collaborator Author

Follow-up: report of the mkdocs page losing its CSS on client navigation

Reported after this PR: embedded, browsing the example docs' showcase page and switching to the mkdocs part leaves the docs page without its CSS; a hard reload of the same URL renders fine.

Not reproduced locally. Checked with the test host (Express gateway, piercing: false), on both the vendored fixture and the real knowledge-base-docs-example artifact (kb-docs.tar.gz, incl. publishing/ and test-page/):

Variation Result
Chromium, bound and unbound embedding, showcase → Read the Docs → sidebar pages → Library → back every nav state has the same stylesheet nodes and computed styles as a hard load of the same URL
Chromium with Element.moveBefore deleted (older-browser fallback of the head swap) same
Firefox 153 same
Head <style>/<link> nodes moved after wf-body on the docs page (a head-relocating runtime) docs page unchanged
WebKit 26.5 (Playwright) the page crashes on the showcase → docs hop — but identically on master built the same way, so it predates this PR

One real hole found and closed in 6e67c45: Tailwind drops a bare @layer a, b; statement from its output, so the order written in knowledge-base.css never reached the compiled sheet. The inline block therefore opened with Tailwind's own layers and named kb-app nowhere; a runtime that parses the body block before the layout's <head> would have ordered kb-app above the fence. Base.astro now prepends LAYER_ORDER to the block, and build-integrity asserts it. (This cannot produce the reported symptom — it would weaken the fence, not unstyle the docs page — but it needed fixing.)

To pin the report down I need, from the environment where it happens: browser + version; the host's web-fragments version and whether the fragment is registered with piercing on; and, after the broken navigation, this pasted into the DevTools console with the wf:knowledge-base frame selected as the context:

[...document.querySelectorAll('link[rel~=stylesheet], style')].map(n => [n.localName, n.parentNode.localName, n.getAttribute('href') ?? n.textContent.slice(0, 40), n.sheet ? n.sheet.cssRules.length : 'NO SHEET', n.sheet?.disabled ? 'disabled' : ''])

plus getComputedStyle(document.querySelector('main h1')).fontSize — on the broken state and after the reload.

oto-macenauer-absa and others added 2 commits September 16, 2026 21:08
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@oto-macenauer-absa

Copy link
Copy Markdown
Collaborator Author

Reproduced, real host — the reported symptom is a same-route re-navigation, not covered by 977362b/c6b1f35

Ran the new docs pages reached by repeated client navigation spec (977362b, c6b1f35) on fix/css-isolation-web-fragment — all 8 css-isolation.spec.js tests pass locally, confirming they're green.

However, that test only visits distinct new routes each hop (showcase → docs overview → customising → adding-pages). Against a real web-fragments host (Angular gateway with piercing on — not the repo's Express test harness), I reproduced the original symptom with a different trigger: clicking a link back to the page that's already the current route (e.g. Customising → Customising again, or Overview → some other page → back to that page a second time).

Repro

await page.goto('http://localhost:4200/knowledge-base/user-guide/docs/');
await page.locator('#sidebar > a:nth-child(2)').click(); // -> Customising
// h1: 36px / weight 800 — correct

await page.locator('#sidebar > a:nth-child(2)').click(); // -> Customising again, same route
// h1: 16px / weight 400 — Tailwind Preflight wins, docs styling lost

What changes on the second hop

Diffing the fragment's shadow tree before/after:

  • Working hop: wf-document > html > body > style×2 (tailwind sheet still 82/1 rules, @layer order intact)
  • Broken hop: body is replaced by a wf-body custom element in the same position, plus an extra injected <div class="astro-route-announcer">. Stylesheet node count, rule counts, and the @layer theme, base, kb-app, kb-reset, components, utilities; statement are byte-for-byte identical to the working case — so this isn't sheet loss, it's the elements the sheets apply to changing shape/identity under <html>.

This is consistent with reframed re-parenting content into a synthetic <wf-body> instead of reusing the existing <body> specifically when the ClientRouter treats the navigation as a same-URL/no-op swap, which desyncs the cascade-layer application for the sub-app subtree even though the KB shell's own styling (masthead/catalog, fixed by this PR) stays fine.

Suggested next step

Extend css-isolation.spec.js (or a follow-up test) to include a repeat click on the currently-active docs sidebar link, asserting document.body stays <body> (not <wf-body>) and computed styles match the hard-load baseline. Then look at src/scripts/embedded-transitions.js's swap handling for the case where the target URL equals the current URL — that's the likely place <wf-body> gets substituted for <body>.

Happy to open a PR with the failing test + fix if useful.

…ed host

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 <link rel="stylesheet"> has
already been moved once, by reframed portalling the server-rendered host
into the <web-fragment>'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
<web-fragment> 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 <noreply@anthropic.com>
@oto-macenauer-absa

Copy link
Copy Markdown
Collaborator Author

Reproduced and fixed in 0f0c769 — piercing was the missing variable, and it is moveBefore, not same-route

Thanks, the pierced host was the key. Reproduced here with the test host switched to piercing: true (new KB_PIERCING=true flag on tests/host/server.mjs): on the pierced host the docs pages lose their CSS on the first client navigation already (h1 36px/800 → 16px/400, sidebar 256px sticky → full-width static), not only on a same-route repeat. Every hop after that is broken too; a reload fixes it.

Cause

After the navigation the docs <link rel="stylesheet"> is still in wf-head and still has a 2-rule .sheet — but the shadow root's styleSheets no longer lists it. Present in the tree, not applied.

On a pierced page that link has already been moved once: reframed portals the server-rendered <web-fragment-host> into the <web-fragment>'s shadow root with moveBefore(). The head swap in embedded-transitions.js then moved it a second time with moveBefore() (to keep the new page's head order), and Chromium drops the sheet from the applied stylesheets on that second move. The client-rendered harness never hit it because there the link had only ever been inserted. Deleting moveBefore from the page (so both reframed and the swap fall back to remove+insert) made all hops render correctly — that was the confirming experiment.

The body → wf-body observation in your diff is reframed's normal adoption of the SSR tree (html/head/body → wf-* after the first swap); it is not what breaks the cascade.

Fix

The swap now never moves a reused head node. Reused nodes are anchors; only new nodes are inserted, each ahead of the next anchor. Head order no longer matters for the cascade — since 6e67c45 every stylesheet (layout head, inline KB block, every rewritten sub-app sheet) declares the layer order itself — so keeping the old anchors' relative order costs nothing.

Tests

  • New Playwright project chromium-pierced (host on :4202 with KB_PIERCING=true): every embedded test now runs against both hosts — 364/364 green (182 × 2). The repeated-navigation test (catalog → showcase → docs → Customising → Adding Pages, strictly router-only, each page compared with a hard load) failed on the pierced host before the fix, exactly with the symptom above, and passes after.
  • The sheet inventory in that test now compares applied / not-applied rather than rule counts: a pierced page carries every inline rule twice (reframed re-inserts them when portalling), which changes nothing visible.

Host gotcha found on the way (README)

initializeWebFragments() must run before anything renders <web-fragment>. With piercing on, a <web-fragment> created earlier finds the server-rendered host before web-fragment-host is defined and fails with portalHost is not a function; the fragment then silently falls back to client rendering (which is why the harness looked fine at first). An Angular host calling it in main.ts before bootstrapApplication() is fine; the test host now defers its router stand-in for the same reason.

@oto-macenauer-absa
oto-macenauer-absa merged commit bb8a0f0 into master Sep 16, 2026
8 checks passed
@oto-macenauer-absa
oto-macenauer-absa deleted the fix/css-isolation-web-fragment branch September 16, 2026 20:20
oto-macenauer-absa added a commit that referenced this pull request Sep 23, 2026
… fix stale pierced CSS (#100)

Follow-up to #99. There are three commits, and each one can be reviewed
on its own.

## 1. `fix`: absolute CSS URLs, and no stale portal copies in a pierced
fragment

Measured with the embedded harness in all three modes: pierced, bound
and unbound.

- **The first app's CSS followed the visitor everywhere (pierced
only).** When reframed portals a server-rendered fragment, it copies
every linked sub-app stylesheet into an adopted constructed sheet. It
drops the copy only once it sees the sheet fetched again. On the
`moveBefore()` path the `<link>` is never re-fetched, so the copy stays
for the life of the fragment. `embedded-transitions.js` now drops the
knowledge base's own copies before the first swap. It recognises them by
the layer-order statement that every built sub-app stylesheet opens
with.
- **Relative `url()` in sub-app CSS returned 404s.** The copy resolves
URLs against the host document, and the host router strips the trailing
slash. So `showcase.css` requested
`/knowledge-base/docs/assets/images/hero.png`. `copyAssets()` now makes
every `url()` and `@import` absolute, resolved against the stylesheet's
own URL. It uses `rewriteCssUrls()`, shared with the inline-style
rewrite.
- **Considered and rejected: serving scripts and styles from a separate
path prefix.** The gateway routes requests by `sec-fetch-dest`, not by
path. A build retargeted to `/__wf/knowledge-base/` reproduced both
failures unchanged.

## 2. `feat(contract)`: rule IDs and a checker for published docs

- **`contract/RULES.md`** lists every rule a published docs site is
checked against, by ID (`KB-HTML-003` …), with its severity, the reason
and the fix. `actions/lib/rules.js` is the code side, and the self-test
fails if the two disagree.
- **`actions/lib/check.js`** replaces `verify-html.js`. It parses HTML
with htmlparser2 and CSS with postcss. The existing checks keep their
severity and wording, now prefixed with the rule ID.
- **New rules, all warnings**, so no repo pinned to `@v1` starts
failing:
- inline `on*` handlers and `javascript:` URLs, which never run under
`script-src 'self'`;
  - a site-level `<header>`;
  - links to non-HTML files in the app;
  - `!important`;
  - dark mode;
  - scripts, stylesheets or fonts from another origin;
  - fetches of page-relative URLs.
- **`actions/lib/check-cli.js`** runs the same checks without
publishing, for a docs repo's PR CI or an agent. It supports `--json`
and `--strict`.

## 3. `feat(skills)`: audit mode for `kb-docs-add`

An already-onboarded repo now takes the audit path in
`references/audit.md`:
1. Check the workflow setup.
2. Build what the workflow publishes.
3. Run the checker from a scratch clone.
4. Fix each finding at its source, by rule ID.
5. Report what the checker cannot see.

## Testing

- **`npm test`: 372 of 372 passed** on the tree for commits 1 and 2.
Commit 3 only touches the skill, its fixtures and `CLAUDE.md`, and
`tests/skill.spec.js` passes on it (30 of 30).
- **The new tests fail without the fix in commit 1.** In the pierced
project: `build-integrity` (relative `url()`) and `css-isolation`
("starting on an app page").
- **`npm run selftest` in `actions/` passes.** It includes the new
`check.selftest.js`: one case per rule, a clean site that must produce
no findings, the CLI, and the RULES.md ↔ rules.js sync check.
- **The checker on real artifacts:**
  - the vendored `docs-example` fixture: 0 errors, 28 genuine warnings;
  - the single-page bundle: no findings.
- **Eval 4:** `tests/fixtures/kb-docs-add/onboarded-mkdocs` is an
onboarded mkdocs repo with problems planted in it.
- Built with mkdocs 1.6.1, it gives 3 errors and 18 warnings. The fixes
the skill prescribes bring it to no findings.
- Two runs of an agent following only the skill met 12 of 13 and then 14
of 14 assertions. The miss in the first run was an assertion that was
too strict.

## Notes for reviewers

- **Nothing reaches docs repos until a new `v1.x` tag moves `v1`.**
Until then, `audit.md` tells agents to clone without `--branch`.
- **`htmlparser2` and `postcss` become direct dependencies of
`actions/`,** at the versions its lockfile already carried.
- **The knowledge base build does not run the checker yet.** It doesn't
install the `actions/` dependencies, so that needs a decision on how.

Refs #67. The checker now reports inline handlers (`KB-HTML-005`), but
the build still does not strip them.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Embedded: catalog and masthead CSS mangled after Library → app → Library; make knowledge base CSS immune to reframed head swaps

2 participants