From edb44b5c0e9d3f3fb9b0433abb04fc3d60c1fcb5 Mon Sep 17 00:00:00 2001 From: fosferon <145221245+fosferon@users.noreply.github.com> Date: Sun, 23 Aug 2026 11:31:30 +0300 Subject: [PATCH 1/3] fix(publisher): scope baseline CSS to cascade layers so imported sheets win MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unlayered CSS outranks every cascade layer regardless of specificity or source order. The publisher emitted `reset` and `framework` unlayered, so the CMS baseline silently overrode any imported stylesheet that uses `@layer` — which is every Tailwind v4 build, whose entire output is wrapped in `@layer theme/base/components/utilities`. The effect on a site brought in through Site Import: the imported CSS shipped intact but inert. Pages rendered with the CMS's own tokens — `--primary` bleeding through as unexplained blocks, the font stack falling back to system-ui, surfaces and spacing collapsing — while the author's design was present in the bundle and simply outranked. Wrap the baseline in `instatic-reset` and `instatic-framework`, with the layer-order statement emitted at the top of `reset` (the first stylesheet on the page) so relative order is fixed before either layer is populated. Imported sheets declare their own layers afterwards and therefore win. Deliberately unchanged: - `style` (Styles-panel + imported class rules) stays unlayered, so explicit authoring decisions keep beating both baseline and imports. - `userStyles` is not re-wrapped; nesting a kept Tailwind sheet inside our layer would re-invert the precedence this fixes. Measured on a 29-route Astro/Tailwind v4 site imported into a clean install, comparing rendered height against the source site: mean fidelity 54.7% -> 95.6%, with 24/29 routes landing within 8% of the original and 14 matching exactly. Inter went from 4 styled elements to 2,310; the brand colour from 0 occurrences to 104. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FPTjUK1YJcwK83zYoANRbb --- server/publish/siteCssBundle.ts | 42 ++++++++++++++++++++- src/__tests__/server/siteCssBundle.test.ts | 43 ++++++++++++++++++++++ 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/server/publish/siteCssBundle.ts b/server/publish/siteCssBundle.ts index daa7346dc..c607c368e 100644 --- a/server/publish/siteCssBundle.ts +++ b/server/publish/siteCssBundle.ts @@ -111,6 +111,38 @@ export function buildPublishedSiteCssBundle( } } +/** + * Cascade layers for the CMS's own baseline CSS, lowest priority first. + * + * Unlayered CSS beats every cascade layer, regardless of specificity or source + * order. Emitting `reset` and `framework` unlayered therefore made them + * outrank any imported stylesheet that uses `@layer` — which is every Tailwind + * v4 build, whose entire output is wrapped in `@layer theme/base/utilities/…`. + * A site imported through the Site Import wizard rendered with the CMS's own + * tokens (`--primary`, font stack, layout defaults) silently overriding the + * imported design, while the imported CSS shipped intact but inert. + * + * Naming these layers puts the baseline below imported CSS. Order within the + * document follows first appearance, and `reset` is the first stylesheet on + * the page, so the declaration below fixes the order for both. + * + * Deliberately NOT layered: `style` (author-authored class CSS from the + * Styles panel + import). Those are explicit authoring decisions and stay + * unlayered so they keep winning over both the baseline and imported sheets. + */ +const BASELINE_LAYERS = { reset: 'instatic-reset', framework: 'instatic-framework' } as const + +/** `@layer a, b;` — fixes relative order before either layer is populated. */ +const BASELINE_LAYER_ORDER = `@layer ${BASELINE_LAYERS.reset}, ${BASELINE_LAYERS.framework};` + +/** + * Wrap a CSS body in a named cascade layer. Empty bodies still emit the block + * so the layer is registered in the document's layer order either way. + */ +function inCascadeLayer(layer: string, css: string): string { + return `@layer ${layer} {\n${css}\n}` +} + /** Build the three page-invariant bundle files from scratch. */ function computePageInvariantBundles( site: SiteDocument, @@ -118,8 +150,14 @@ function computePageInvariantBundles( options: ResponsiveCssOptions, ): PageInvariantBundles { return { - reset: makeBundleFile('reset', PUBLISHER_RESET_CSS), - framework: makeBundleFile('framework', buildFrameworkCss(site, registry)), + reset: makeBundleFile( + 'reset', + `${BASELINE_LAYER_ORDER}\n${inCascadeLayer(BASELINE_LAYERS.reset, PUBLISHER_RESET_CSS)}`, + ), + framework: makeBundleFile( + 'framework', + inCascadeLayer(BASELINE_LAYERS.framework, buildFrameworkCss(site, registry)), + ), style: makeBundleFile('style', collectClassCSS(site, options)), } } diff --git a/src/__tests__/server/siteCssBundle.test.ts b/src/__tests__/server/siteCssBundle.test.ts index 453e66f6d..eb3511eca 100644 --- a/src/__tests__/server/siteCssBundle.test.ts +++ b/src/__tests__/server/siteCssBundle.test.ts @@ -339,4 +339,47 @@ describe('buildSiteCssBundle', () => { expect(before.framework.content).not.toContain('.bg-primary') expect(after.framework.content).toContain('.bg-primary') }) + + /** + * Cascade layering. Unlayered CSS outranks every cascade layer regardless of + * specificity or source order, so emitting the CMS baseline unlayered made it + * silently override any imported stylesheet that uses `@layer` — i.e. every + * Tailwind v4 build, whose whole output is layered. The imported CSS shipped + * intact but inert, and the site rendered with CMS tokens instead. + */ + describe('cascade layers', () => { + it('declares the baseline layer order in reset, before either layer is used', () => { + const { reset } = buildSiteCssBundle(makeSite(), registry) + const order = reset.content.indexOf('@layer instatic-reset, instatic-framework;') + expect(order).toBe(0) + // The order statement must precede the block that populates the layer. + expect(order).toBeLessThan(reset.content.indexOf('@layer instatic-reset {')) + }) + + it('wraps reset and framework in the baseline layers', () => { + const site = makeSite() + site.pages = [makePage({ root: { moduleId: 'base.text', props: { text: 'Hi' } } })] + + const { reset, framework } = buildSiteCssBundle(site, registry) + expect(reset.content).toContain('@layer instatic-reset {') + expect(framework.content).toContain('@layer instatic-framework {') + // Module CSS travels inside the framework bundle, so it is layered too. + expect(framework.content).toContain('h1 { color: black; }') + }) + + it('leaves author-authored class CSS unlayered so it still wins', () => { + // `style` carries Styles-panel and imported class rules — explicit + // authoring decisions that must outrank both the baseline and any + // imported sheet. Unlayered is what buys that. + const { style } = buildSiteCssBundle(makeSite(), registry) + expect(style.content).not.toContain('@layer') + }) + + it('keeps user stylesheets unwrapped so their own @layer rules survive', () => { + // A kept Tailwind sheet arrives already layered. Wrapping it again would + // nest its layers under ours and re-invert the very precedence we fixed. + const { userStyles } = buildSiteCssBundle(makeSite(), registry) + expect(userStyles.content).not.toContain('@layer instatic-') + }) + }) }) From 14b8a00339cb9718de8c1f70d93e6983e5cd5d30 Mon Sep 17 00:00:00 2001 From: fosferon <145221245+fosferon@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:32:58 +0300 Subject: [PATCH 2/3] fix(canvas): scope baseline CSS to cascade layers in the editor iframe too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same defect as the published bundle, second code path. ClassStyleInjector wraps everything `buildCanvasClassCSS` returns in `@layer user-authored`, and UserStylesheetInjector puts imported stylesheets in that same layer. Rules sitting DIRECTLY in `user-authored` outrank rules in its nested sublayers. The canvas baseline was emitted unlayered, so once wrapped it sat directly in `user-authored` while an imported Tailwind sheet — whose output is entirely `@layer theme/base/utilities` — ended up one level deeper as `user-authored.theme` etc. The baseline won everything. Net effect: the editor canvas previewed CMS defaults even after the published page was fixed. `:where(body) { font-family: system-ui }` beat the site's own font, sections collapsed, and an imported site rendered as near-blank containers — uneditable, because you cannot edit what you cannot see. The published page and the canvas disagreed, which is exactly what the canvas exists to prevent. Name the sublayers so the iframe reproduces the published cascade: reset -> `instatic-reset`, fonts + framework root -> `instatic-framework`, order statement first. Author class CSS stays unlayered within `user-authored`, mirroring the published bundle's unlayered `style`. Measured on a 29-route Tailwind v4 site imported into a clean install, desktop canvas frame vs the published page (6403px): before 3429px, body font system-ui after 6503px, body font Inter Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FPTjUK1YJcwK83zYoANRbb --- .../canvas/classStyleInjector.test.ts | 33 +++++++++++++++++++ src/admin/pages/site/canvas/canvasClassCss.ts | 24 ++++++++++++-- 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/src/__tests__/canvas/classStyleInjector.test.ts b/src/__tests__/canvas/classStyleInjector.test.ts index 405f34a3a..79ed28eac 100644 --- a/src/__tests__/canvas/classStyleInjector.test.ts +++ b/src/__tests__/canvas/classStyleInjector.test.ts @@ -91,6 +91,39 @@ describe('generateCanvasClassCSS', () => { expect(css).not.toMatch(/\[data-breakpoint-id\][^{]*\{[^}]*color:\s*#000/) }) + /** + * Cascade layers. Everything this builder returns is wrapped in + * `@layer user-authored` by ClassStyleInjector, and imported stylesheets land + * in that same layer via UserStylesheetInjector. Rules sitting DIRECTLY in + * `user-authored` outrank rules in its nested sublayers, so an unlayered + * reset here beat every rule of an imported Tailwind sheet (whose output is + * entirely layered, nested one level deeper once wrapped). The canvas then + * previewed CMS defaults — `:where(body) { font-family: system-ui }` winning + * over the site's own font — while the published page rendered correctly. + */ + it('declares the baseline layer order before populating either layer', () => { + const css = generateCanvasClassCSS({}, []) + const order = css.indexOf('@layer instatic-reset, instatic-framework;') + expect(order).toBe(0) + expect(order).toBeLessThan(css.indexOf('@layer instatic-reset {')) + }) + + it('wraps the publisher reset inside the reset layer', () => { + const css = generateCanvasClassCSS({}, []) + const layerStart = css.indexOf('@layer instatic-reset {') + expect(layerStart).toBeGreaterThan(-1) + // The reset's rules must sit inside the layer block, not beside it. + expect(css.indexOf(':where(body)')).toBeGreaterThan(layerStart) + }) + + it('leaves author class CSS unlayered so it still wins', () => { + const css = generateCanvasClassCSS({ hero: makeClass('hero', { color: 'red' }) }, []) + const ruleAt = css.search(/\.hero\b/) + expect(ruleAt).toBeGreaterThan(-1) + // Nothing re-opens a baseline layer after the class rules begin. + expect(css.slice(ruleAt)).not.toContain('@layer instatic-') + }) + it('uses the viewport context media query for canvas breakpoint styles', () => { const css = generateCanvasClassCSS( { diff --git a/src/admin/pages/site/canvas/canvasClassCss.ts b/src/admin/pages/site/canvas/canvasClassCss.ts index 886aa4069..e0067469e 100644 --- a/src/admin/pages/site/canvas/canvasClassCss.ts +++ b/src/admin/pages/site/canvas/canvasClassCss.ts @@ -35,6 +35,19 @@ function buildCanvasClassCSS( ): string { const blocks: string[] = [] + // Baseline layer order, mirroring the published bundle (see + // `server/publish/siteCssBundle.ts`). Everything this function returns is + // wrapped in `@layer user-authored` by ClassStyleInjector, and the imported + // stylesheets land in that same layer via UserStylesheetInjector. Rules + // sitting DIRECTLY in `user-authored` outrank rules in its nested sublayers, + // so an unlayered reset here beat every rule of an imported Tailwind sheet + // (whose output is entirely `@layer theme/base/utilities`, nested one level + // deeper once wrapped). The canvas then previewed CMS defaults — + // `:where(body) { font-family: system-ui }` winning over the site's own + // font — while the published page, which layers these correctly, did not. + // Naming the sublayers restores the published cascade inside the iframe. + blocks.push('@layer instatic-reset, instatic-framework;') + // Publisher reset, identical to what `publishPage()` ships. Each canvas // breakpoint frame is its own iframe with its own ``, so we use the // unscoped reset (low-specificity `:where(body) { ... }` rules) rather @@ -43,20 +56,25 @@ function buildCanvasClassCSS( // `body { color: var(--color-fg) }` wins over the reset's `:where(body)` // baseline, the way it does on the live site. Editor chrome lives outside // the iframe so the reset can't leak into the toolbars / panels. - blocks.push(PUBLISHER_RESET_CSS) + blocks.push(`@layer instatic-reset {\n${PUBLISHER_RESET_CSS}\n}`) // Fonts go first (after the reset) so `@font-face` declarations exist before // any rule that references the family — browsers tolerate the reverse order, // but the ordering keeps generated CSS easier to inspect. + // + // `@font-face` is layer-neutral (it registers a family rather than matching + // an element), so grouping it with the framework costs nothing. + const baseline: string[] = [] const fontsCss = generateFontsCss(fonts) - if (fontsCss) blocks.push(fontsCss) + if (fontsCss) baseline.push(fontsCss) const frameworkCss = generateFrameworkRootCss({ colors: frameworkColors, typography: frameworkTypography, spacing: frameworkSpacing, preferences: frameworkPreferences, }) - if (frameworkCss) blocks.push(frameworkCss) + if (frameworkCss) baseline.push(frameworkCss) + if (baseline.length) blocks.push(`@layer instatic-framework {\n${baseline.join('\n\n')}\n}`) // The registry CSS is the publisher's own generator — the canvas ships the // exact bytes a publish would (rule order, condition/viewport cascade, and From ec0059bbb819b830da8b7107f580438f8fa5fc02 Mon Sep 17 00:00:00 2001 From: fosferon <145221245+fosferon@users.noreply.github.com> Date: Thu, 27 Aug 2026 00:27:59 +0300 Subject: [PATCH 3/3] fix(publisher): allow data: and https: font sources in the base CSP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The base policy set `img-src` and `media-src` to `'self' data: https:` but emitted no `font-src` at all, so fonts fell back to `default-src 'self'` and every `data:` or cross-origin `@font-face` was blocked. This is the same argument the `media-src` comment already makes, and it bites more often than either sibling: base64 font faces are routine in third-party CSS — icon fonts especially — so a site brought in through Site Import ships them without the author ever having written one. Ours arrived inside a vendored Swiper stylesheet. The failure is near-invisible. The stylesheet is intact, the rule parses, the element is correct; the text simply renders in the fallback family and the only evidence is a console line naming a multi-kilobyte data URI. Verified: 6668 pass (the one failure is the pre-existing collabRelayIntegration read-only-edit test, unchanged on clean main), build and lint clean. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FPTjUK1YJcwK83zYoANRbb --- src/__tests__/publisher/cspPlan.test.ts | 20 +++++++++++++++++--- src/core/publisher/cspPlan.ts | 8 ++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/__tests__/publisher/cspPlan.test.ts b/src/__tests__/publisher/cspPlan.test.ts index e9cfea458..dc9e96852 100644 --- a/src/__tests__/publisher/cspPlan.test.ts +++ b/src/__tests__/publisher/cspPlan.test.ts @@ -31,10 +31,11 @@ describe('CspPlan — serialization is deterministic and sorted', () => { it('sorts directives by name and sources within each directive', () => { const plan = createBaseCspPlan({ anyScriptTag: false }) const csp = serializeCsp(plan) - // Directives alphabetical: default-src < frame-src < img-src < media-src - // < script-src < style-src < worker-src + // Directives alphabetical: default-src < font-src < frame-src < img-src + // < media-src < script-src < style-src < worker-src expect(csp).toBe( - "default-src 'self'; frame-src 'none'; img-src 'self' data: https:; " + + "default-src 'self'; font-src 'self' data: https:; frame-src 'none'; " + + "img-src 'self' data: https:; " + "media-src 'self' data: https:; " + "script-src 'none'; style-src 'self' 'unsafe-inline'; worker-src 'none';", ) @@ -52,6 +53,19 @@ describe('CspPlan — serialization is deterministic and sorted', () => { expect(media).toBe(img) }) + it('lets a base64 @font-face load, exactly like a data: image', () => { + // Icon fonts routinely inline their faces as base64. Without an explicit + // `font-src` the browser fell back to `default-src 'self'` and refused + // every one — and the only symptom was a console line, because the + // stylesheet is intact and the text just renders in the fallback family. + // An imported site ships these without the author ever writing one. + const csp = serializeCsp(createBaseCspPlan({ anyScriptTag: true })) + expect(csp).toContain("font-src 'self' data: https:;") + const img = /img-src ([^;]+);/.exec(csp)?.[1] + const font = /font-src ([^;]+);/.exec(csp)?.[1] + expect(font).toBe(img) + }) + it('produces a byte-identical policy regardless of source insertion order', () => { const a = createBaseCspPlan({ anyScriptTag: true, importmapSha: 'ABC123' }) const b = createBaseCspPlan({ anyScriptTag: true, importmapSha: 'ABC123' }) diff --git a/src/core/publisher/cspPlan.ts b/src/core/publisher/cspPlan.ts index 9b6f33f64..0a033027c 100644 --- a/src/core/publisher/cspPlan.ts +++ b/src/core/publisher/cspPlan.ts @@ -91,6 +91,14 @@ export function createBaseCspPlan(opts: { // invisible in the markup: the element is correct, the URL resolves, and // only the console says why nothing happens. setCspDirective(plan, 'media-src', ["'self'", 'data:', 'https:']) + // Fonts, for the third time and the same reason — but this one bites more + // often than the other two. Base64 `@font-face` sources are routine in + // third-party CSS (icon fonts especially), so a site brought in through + // Site Import ships them without the author ever having written one. + // Falling back to `default-src 'self'` blocked every such face, and the + // only symptom was a console line: the stylesheet is intact, the rule + // parses, the text just silently renders in the fallback family. + setCspDirective(plan, 'font-src', ["'self'", 'data:', 'https:']) setCspDirective(plan, 'frame-src', ["'none'"]) setCspDirective(plan, 'worker-src', opts.anyScriptTag ? ["'self'", 'blob:'] : ["'none'"]) return plan