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__/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/__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/__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-') + }) + }) }) 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 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