diff --git a/compiler/src/generate/app.ts b/compiler/src/generate/app.ts
index 0e3c39e..f5376ce 100644
--- a/compiler/src/generate/app.ts
+++ b/compiler/src/generate/app.ts
@@ -2250,10 +2250,36 @@ ${head}
`;
}
+const TRANSPARENT_BG = "rgba(0, 0, 0, 0)";
+
+/** Decide what background (if any) the clone's `html` element should paint.
+ *
+ * Per CSS 2.1 §14.2, when `html` is transparent the `body` background propagates
+ * to the canvas and paints *beneath* every descendant — including negative-z-index
+ * layers (full-bleed hero video/image/canvas backdrops). The moment `html` paints
+ * an opaque background, `body` stops propagating and paints its own in-flow block
+ * background, which sits *above* negative-z descendants and buries them.
+ *
+ * So we only give `html` a background when the SOURCE `html` actually painted one.
+ * A transparent source `html` stays transparent (returns null → no rule emitted), so
+ * the captured body background propagates to the canvas exactly as in the source.
+ * The white fallback applies only when BOTH html and body are transparent, otherwise
+ * the UA-default canvas would show through. Deterministic: pure function of the IR. */
+export function resolveHtmlBg(pv: { htmlBg?: string; bodyBg?: string } | undefined): string | null {
+ const srcHtmlBg = pv?.htmlBg && pv.htmlBg !== TRANSPARENT_BG ? pv.htmlBg : null;
+ const srcBodyBg = pv?.bodyBg && pv.bodyBg !== TRANSPARENT_BG ? pv.bodyBg : null;
+ return srcHtmlBg ?? (srcBodyBg ? null : "#ffffff");
+}
+
+/** `html { background: … }` rule, or empty string when html should stay transparent. */
+export function htmlBgRule(htmlBg: string | null): string {
+ return htmlBg !== null ? `html { background: ${htmlBg}; }\n` : "";
+}
+
function generateGlobalsCss(ir: IR, fontGraph: FontGraph, tokensCss: string): string {
const cw = ir.doc.canonicalViewport;
const pv = ir.doc.perViewport[cw];
- const htmlBg = pv?.htmlBg && pv.htmlBg !== "rgba(0, 0, 0, 0)" ? pv.htmlBg : (pv?.bodyBg ?? "#ffffff");
+ const htmlBg = resolveHtmlBg(pv);
// If the SOURCE page never scrolls horizontally (its scrollWidth fits the
// viewport at every captured width), neither should the clone. JS-driven
// widgets (custom-element carousels, sliders) position children off-axis via
@@ -2271,8 +2297,7 @@ ${fontGraph.css}
${tokensCss}
/* page base */
-html { background: ${htmlBg}; }
-body { font-family: ${SYSTEM_FALLBACK}; }${clip}
+${htmlBgRule(htmlBg)}body { font-family: ${SYSTEM_FALLBACK}; }${clip}
`;
}
@@ -2643,7 +2668,7 @@ export function generateApp(input: GenerateInput, tokensCss: string): { pageTsx:
if (tw) {
const cw = ir.doc.canonicalViewport;
const pv = ir.doc.perViewport[cw];
- const htmlBg = pv?.htmlBg && pv.htmlBg !== "rgba(0, 0, 0, 0)" ? pv.htmlBg : (pv?.bodyBg ?? "#ffffff");
+ const htmlBg = resolveHtmlBg(pv);
const noHScroll = Object.entries(ir.doc.perViewport).every(([vp, d]) => d.scrollWidth <= Number(vp) * 1.03);
const clip = noHScroll ? "\nhtml, body { overflow-x: clip; }" : "";
const globals = tailwindGlobalsCss({
diff --git a/compiler/src/generate/tailwind.ts b/compiler/src/generate/tailwind.ts
index cd905e9..c3d09bf 100644
--- a/compiler/src/generate/tailwind.ts
+++ b/compiler/src/generate/tailwind.ts
@@ -1273,7 +1273,7 @@ export function buildTailwind(ir: IR, assetMap: Map, colorVar?:
* + breakpoint bindings, our token :root, and our reset/fonts/page-base inside @layer base
* so utilities override them. */
export function tailwindGlobalsCss(opts: {
- reset: string; fontCss: string; tokensCss: string; htmlBg: string; bodyFont: string;
+ reset: string; fontCss: string; tokensCss: string; htmlBg: string | null; bodyFont: string;
clip: string; colorTokens: string[]; viewports: number[]; canonical: number;
}): string {
const screens = [
@@ -1301,8 +1301,7 @@ ${opts.tokensCss}
${opts.reset}
/* fonts */
${opts.fontCss}
-html { background: ${opts.htmlBg}; }
-body { font-family: ${opts.bodyFont}; }${opts.clip}
+${opts.htmlBg !== null ? `html { background: ${opts.htmlBg}; }\n` : ""}body { font-family: ${opts.bodyFont}; }${opts.clip}
}
`;
}
diff --git a/compiler/src/site/generateSite.ts b/compiler/src/site/generateSite.ts
index 3dc3aaf..d73265e 100644
--- a/compiler/src/site/generateSite.ts
+++ b/compiler/src/site/generateSite.ts
@@ -15,7 +15,7 @@ import { generateCss, RESET_CSS } from "../generate/css.js";
import { generateInteractionCss } from "../generate/interactionCss.js";
import { buildRuntimeSpecs, wiresJsx, dittoWireImportPath, DITTO_WIRE_TSX, interactionRejectedSet } from "../generate/interactive.js";
import { buildLottieSpec, lottieHasContent, lottieWireJsx, dittoLottieImportPath, DITTO_LOTTIE_TSX } from "../generate/lottie.js";
-import { renderChildrenJsx, renderAttrs, buildComponentRegistry, componentPreamble, componentFiles, componentImports, componentDataDecls, summarizeComponents, fileBase, generateViteConfig, generateViteIndexHtml, viteGlobalsCss, cnImportLine, CN_UTILS_MODULE, PACKAGE_JSON, PACKAGE_JSON_TW, PACKAGE_JSON_VITE, PACKAGE_JSON_VITE_TW, TSCONFIG_JSON, TSCONFIG_JSON_VITE, NEXT_CONFIG, injectLottieDep, type AppFramework, type LinkRewrite, type ExtractedComponent, type RenderCtx } from "../generate/app.js";
+import { renderChildrenJsx, renderAttrs, buildComponentRegistry, componentPreamble, componentFiles, componentImports, componentDataDecls, summarizeComponents, fileBase, generateViteConfig, generateViteIndexHtml, viteGlobalsCss, cnImportLine, resolveHtmlBg, htmlBgRule, CN_UTILS_MODULE, PACKAGE_JSON, PACKAGE_JSON_TW, PACKAGE_JSON_VITE, PACKAGE_JSON_VITE_TW, TSCONFIG_JSON, TSCONFIG_JSON_VITE, NEXT_CONFIG, injectLottieDep, type AppFramework, type LinkRewrite, type ExtractedComponent, type RenderCtx } from "../generate/app.js";
import { buildTailwind, tailwindGlobalsCss, createColorInterner, colorDefsCssOf, type TailwindOutput } from "../generate/tailwind.js";
import type { InteractionCapture } from "../capture/interactions.js";
import type { IRChild } from "../normalize/ir.js";
@@ -91,10 +91,10 @@ function unionFontCss(routes: RouteArtifact[]): string {
/** Shared page-base bits (entry html background + overflow-x clip) — same rationale as
* single-page generation; used by both the plain-CSS and Tailwind globals. */
-function pageBaseOf(entry: RouteArtifact): { htmlBg: string; clip: string } {
+function pageBaseOf(entry: RouteArtifact): { htmlBg: string | null; clip: string } {
const cw = entry.ir.doc.canonicalViewport;
const pv = entry.ir.doc.perViewport[cw];
- const htmlBg = pv?.htmlBg && pv.htmlBg !== "rgba(0, 0, 0, 0)" ? pv.htmlBg : (pv?.bodyBg ?? "#ffffff");
+ const htmlBg = resolveHtmlBg(pv);
const noHScroll = Object.entries(entry.ir.doc.perViewport).every(([vp, d]) => d.scrollWidth <= Number(vp) * 1.03);
return { htmlBg, clip: noHScroll ? "\nhtml, body { overflow-x: clip; }" : "" };
}
@@ -112,8 +112,7 @@ ${paletteCss}
${tokensToCss(entry.tokens, true)}
/* page base */
-html { background: ${htmlBg}; }
-body { font-family: ${SYSTEM_FALLBACK}; }${clip}
+${htmlBgRule(htmlBg)}body { font-family: ${SYSTEM_FALLBACK}; }${clip}
`;
}
diff --git a/compiler/test/htmlBg.test.ts b/compiler/test/htmlBg.test.ts
new file mode 100644
index 0000000..34f64d2
--- /dev/null
+++ b/compiler/test/htmlBg.test.ts
@@ -0,0 +1,44 @@
+import { describe, it } from "node:test";
+import assert from "node:assert/strict";
+import { resolveHtmlBg, htmlBgRule } from "../src/generate/app.js";
+
+// Regression: when the source layers a full-bleed backdrop at z-index<0 behind a
+// body-propagated canvas background, emitting an opaque `html { background }`
+// flips CSS 2.1 §14.2 background propagation and buries that backdrop under the
+// body box. `html` must only paint when the SOURCE html actually painted.
+describe("resolveHtmlBg — source-faithful canvas propagation", () => {
+ it("transparent html + colored body → no html background (body propagates to canvas)", () => {
+ const htmlBg = resolveHtmlBg({ htmlBg: "rgba(0, 0, 0, 0)", bodyBg: "rgb(246, 244, 238)" });
+ assert.equal(htmlBg, null);
+ assert.equal(htmlBgRule(htmlBg), "");
+ });
+
+ it("colored html → html background kept", () => {
+ const htmlBg = resolveHtmlBg({ htmlBg: "rgb(10, 20, 30)", bodyBg: "rgb(246, 244, 238)" });
+ assert.equal(htmlBg, "rgb(10, 20, 30)");
+ assert.equal(htmlBgRule(htmlBg), "html { background: rgb(10, 20, 30); }\n");
+ });
+
+ it("both transparent → #ffffff fallback (never a UA-default canvas)", () => {
+ const htmlBg = resolveHtmlBg({ htmlBg: "rgba(0, 0, 0, 0)", bodyBg: "rgba(0, 0, 0, 0)" });
+ assert.equal(htmlBg, "#ffffff");
+ assert.equal(htmlBgRule(htmlBg), "html { background: #ffffff; }\n");
+ });
+
+ it("missing perViewport entry → #ffffff fallback", () => {
+ const htmlBg = resolveHtmlBg(undefined);
+ assert.equal(htmlBg, "#ffffff");
+ });
+
+ it("undefined html + colored body (only body captured) → no html rule", () => {
+ // Body-only styling is the common case the old fallback existed for; it must
+ // still leave html transparent so the body color reaches the canvas.
+ const htmlBg = resolveHtmlBg({ bodyBg: "rgb(0, 0, 0)" });
+ assert.equal(htmlBg, null);
+ });
+
+ it("deterministic: identical input yields identical output", () => {
+ const pv = { htmlBg: "rgba(0, 0, 0, 0)", bodyBg: "rgb(1, 2, 3)" };
+ assert.equal(resolveHtmlBg(pv), resolveHtmlBg({ ...pv }));
+ });
+});