diff --git a/compiler/src/generate/app.ts b/compiler/src/generate/app.ts index 650998e..f1c936b 100644 --- a/compiler/src/generate/app.ts +++ b/compiler/src/generate/app.ts @@ -2539,7 +2539,10 @@ export function generateApp(input: GenerateInput, tokensCss: string): { pageTsx: const mode = input.humanizeMode ?? "tailwind"; // Tailwind mode (default): translate each node's exact decls to utility classes. // CSS mode: dedup into shared semantic CSS classes. Both fidelity-neutral. - const tw = humanize && mode === "tailwind" ? buildTailwind(ir, assetMap, input.colorVar, { interaction: input.interaction, reflow: input.reflow }) : undefined; + // Lottie mount boxes are pinned to their captured height (replaced-like) so the runtime player's + // aspect-sized svg fills a definite box instead of inflating. The spec item cid IS the mount cid. + const lottieMounts = new Set(lottieSpec.items.map((it) => it.cid)); + const tw = humanize && mode === "tailwind" ? buildTailwind(ir, assetMap, input.colorVar, { interaction: input.interaction, reflow: input.reflow, lottieMounts }) : undefined; const classMap = humanize && mode === "css" ? buildClassMap(ir, assetMap, input.colorVar, input.primitives, input.tokenResolver) : undefined; const cleanRecipeClass = recipeResponsiveClassCleaner(ir, input.recipeReport, { tailwind: !!tw }); const classOf = tw ? (cid: string) => cleanRecipeClass(cid, tw.classOf.get(cid)) : classMap ? (cid: string) => classMap.classOf.get(cid) : undefined; diff --git a/compiler/src/generate/css.ts b/compiler/src/generate/css.ts index e63de00..0ae8a84 100644 --- a/compiler/src/generate/css.ts +++ b/compiler/src/generate/css.ts @@ -867,6 +867,24 @@ function aspectHeightLaw(node: IRNode, viewports: number[]): Record | null { + const out: Record = {}; + let any = false; + for (const vp of viewports) { + const nb = node.bboxByVp[vp]; + if (!nb || !node.visibleByVp[vp] || (node.computedByVp[vp]?.display || "") === "none") continue; + if (!(nb.height > 0)) continue; + out[vp] = fmtPx(nb.height); + any = true; + } + return any ? out : null; +} + function mediaHeightGeometry(node: IRNode, viewports: number[]): Pick { const heightByVp = viewportHeightLaw(node, viewports); if (heightByVp) return { heightByVp }; @@ -1980,6 +1998,24 @@ function heightFlows(node: IRNode, parentNode: IRNode | undefined, viewports: nu if (pos !== "static" && pos !== "relative") return false; if ((cs.overflowY || cs.overflow || "visible") !== "visible") return false; if ((cs.display || "") === "none") continue; + // The sizing probe proved this height AUTHORED-EXPLICIT: height:auto did NOT reproduce the box + // (hAuto:false) and the box is not a fill child of its own parent (hFill:false). The measured + // px is then load-bearing — dropping it collapses the box. This overrides the structural flow + // reasoning below, which can read the height as content-driven via a CIRCULAR pair (two nested + // authored-height boxes each "explaining" the other's extent) and drop both. Bail so the + // authored height survives. (heightProbeDrops, OR'd with this at the call site, also refuses to + // drop an hAuto:false box — so the two signals agree and the authored px is kept.) + const sz = node.sizingByVp?.[vp]; + if (sz && sz.hAuto === false && sz.hFill === false) return false; + // A flex COLUMN that DISTRIBUTES free space (justify-content space-between/around/evenly, or the + // packed alignments that pin the last child away from the top): the box height is LOAD-BEARING — + // it sets the free space the children spread through. The content-extent check below would read + // the last child reaching the box bottom as "content-sized", but that extent only equals the box + // bottom BECAUSE the distribution pushed it there. Dropping the height collapses the gaps. + if (/flex/.test(cs.display || "") && /column/.test(cs.flexDirection || "row") && + /^(space-between|space-around|space-evenly|center|flex-end|end)$/.test(cs.justifyContent || "")) { + return false; + } // A flex COLUMN whose in-flow child fills it via flex-grow: the box's height is LOAD-BEARING, // not content-derived. The content-extent check below would read the box as content-sized — but // that extent IS the grown child, which only reaches the box bottom BECAUSE the box has this @@ -3015,7 +3051,7 @@ export function keyframesCss(ir: IR, assetMap: Map, includeNode? * per-node CSS emitter (generateCss) and the semantic class-map emitter (classMap.ts). * `includeNode` scopes which nodes are emitted (multi-route shared layout) while still * recursing so inheritance diffing against parents stays correct. */ -export function collectNodeRules(ir: IR, assetMap: Map, includeNode?: (id: string) => boolean, colorVar?: (value: string) => string | null, tokenResolver?: TokenResolver, reflow = false): Map { +export function collectNodeRules(ir: IR, assetMap: Map, includeNode?: (id: string) => boolean, colorVar?: (value: string) => string | null, tokenResolver?: TokenResolver, reflow = false, lottieMounts?: ReadonlySet): Map { const bands = computeBands(ir.doc.viewports, ir.doc.canonicalViewport); const baseVp = ir.doc.canonicalViewport; const rules = new Map(); @@ -3194,7 +3230,10 @@ export function collectNodeRules(ir: IR, assetMap: Map, includeN // Large one-row media grids need a definite height/aspect law before the single `1fr` row is // safe. Multi-row grids are handled by the older equal-track detector. const singleRowsByVp = !isContents ? singleFluidGridRow(node, sampleVps) : null; - const mediaGeometry = singleRowsByVp ? mediaHeightGeometry(node, sampleVps) : {}; + // A lottie mount pins its captured height (replaced-like); it wins over the flow/media laws so the + // runtime player's aspect-sized svg fills a definite box instead of inflating past its neighbours. + const lottieHeight = !isContents && lottieMounts?.has(node.id) ? lottieMountHeight(node, sampleVps) : null; + const mediaGeometry = lottieHeight ? { heightByVp: lottieHeight } : singleRowsByVp ? mediaHeightGeometry(node, sampleVps) : {}; const leftClampByVp = !isContents ? centeredInsetClamp(node, cbAncestor, sampleVps, "x") : null; const topClampByVp = !isContents ? centeredInsetClamp(node, cbAncestor, sampleVps, "y") : null; const geometry: GeometryPlan = (mediaGeometry.heightByVp || mediaGeometry.aspectByVp || leftClampByVp || topClampByVp) diff --git a/compiler/src/generate/lottie.ts b/compiler/src/generate/lottie.ts index 233007e..45342da 100644 --- a/compiler/src/generate/lottie.ts +++ b/compiler/src/generate/lottie.ts @@ -154,10 +154,23 @@ export default function DittoLottie({ spec }: { spec: LottieSpec }) { rendererSettings: { preserveAspectRatio: "xMidYMid meet" }, }); // Swap only once the animation is genuinely ready: reveal the live render and remove - // every original child (the placeholder) so the two never stack. + // every original child (the placeholder) so the two never stack. Before discarding the + // placeholder, forward its data-cid onto the runtime-rendered svg/canvas so the media + // node stays addressable by cid after the swap (it would otherwise mount without one). const reveal = () => { mount.style.opacity = "1"; + // The captured placeholder is an svg/canvas (or wraps one) carrying its own data-cid. + let placeholderCid: string | null = null; + for (const child of Array.from(el.childNodes)) { + if (child === mount || placeholderCid !== null || !(child instanceof Element)) continue; + const media = child.matches("svg, canvas") ? child : child.querySelector("svg, canvas"); + placeholderCid = (media ?? child).getAttribute("data-cid"); + } for (const child of Array.from(el.childNodes)) if (child !== mount) el.removeChild(child); + if (placeholderCid) { + const rendered = mount.querySelector("svg, canvas"); + if (rendered) rendered.setAttribute("data-cid", placeholderCid); + } mount.style.position = ""; mount.style.inset = ""; }; diff --git a/compiler/src/generate/tailwind.ts b/compiler/src/generate/tailwind.ts index 8a88904..b6b382e 100644 --- a/compiler/src/generate/tailwind.ts +++ b/compiler/src/generate/tailwind.ts @@ -1077,11 +1077,11 @@ function interactionUtilities( return { byCid, groups }; } -export function buildTailwind(ir: IR, assetMap: Map, colorVar?: (v: string) => string | null, opts?: { interner?: ColorInterner; includeNode?: (id: string) => boolean; interaction?: InteractionCapture; reflow?: boolean }): TailwindOutput { +export function buildTailwind(ir: IR, assetMap: Map, colorVar?: (v: string) => string | null, opts?: { interner?: ColorInterner; includeNode?: (id: string) => boolean; interaction?: InteractionCapture; reflow?: boolean; lottieMounts?: ReadonlySet }): TailwindOutput { // Colors tokenized (var(--…)); typography/geometry kept RAW (text-[16px] reads cleaner // than a token ref). The full tokenResolver is deliberately NOT passed — only colors are // tokenized below, so spacing/type stay as readable arbitrary values. - const rules = collectNodeRules(ir, assetMap, opts?.includeNode, colorVar, undefined, opts?.reflow); + const rules = collectNodeRules(ir, assetMap, opts?.includeNode, colorVar, undefined, opts?.reflow, opts?.lottieMounts); const classOf = new Map(); const styleOf = new Map>(); // cid → inline style (base-only gradients/url) const extraParts: string[] = []; // pseudo rules + url()-bearing decls, keyed by [data-cid] diff --git a/compiler/test/css.test.ts b/compiler/test/css.test.ts index 8d57ddb..ae43449 100644 --- a/compiler/test/css.test.ts +++ b/compiler/test/css.test.ts @@ -2,7 +2,7 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import type { IR, IRNode, IRChild, StyleMap, BBox } from "../src/normalize/ir.js"; import type { RawSizing } from "../src/capture/walker.js"; -import { generateCss } from "../src/generate/css.js"; +import { generateCss, collectNodeRules } from "../src/generate/css.js"; const VPS = [375, 1280]; const CANONICAL = 1280; @@ -484,3 +484,136 @@ describe("generateCss cross-band transform identity", () => { assert.ok(!allRulesX(css, "n1").includes("transform:none"), `an always-identity node needs no explicit transform, got: ${allRulesX(css, "n1")}`); }); }); + +// BUG 1 — heightFlows must consult the sizing probe. A circular parent/child pair of authored-height +// boxes (two nested viewport-height containers whose only children are absolutely positioned) used to +// "explain each other away": the outer read content-driven (its content IS the inner box) and the +// inner read stretched (it equals the outer's content height) — both dropped, collapsing a hero to +// 0px. The probe now proves each height authored-explicit (hAuto:false, hFill:false); heightFlows +// bails so both survive. +describe("generateCss circular authored-height (probe bail)", () => { + const authored = (): RawSizing => ({ wAuto: false, wFill: false, hAuto: false, hFill: false }); + function hero(hByVp: Record) { + // an absolutely-positioned decoration inside the inner box (contributes no in-flow content height) + const abs = xNode("n3", "div", { + 375: { cs: { position: "absolute" }, bbox: { x: 0, y: 0, width: 375, height: hByVp[375]! } }, + 768: { cs: { position: "absolute" }, bbox: { x: 0, y: 0, width: 768, height: hByVp[768]! } }, + 1280: { cs: { position: "absolute" }, bbox: { x: 0, y: 0, width: 1280, height: hByVp[1280]! } }, + }); + const inner = xNode("n2", "div", { + 375: { cs: { display: "flex", height: `${hByVp[375]}px` }, bbox: { x: 0, y: 0, width: 375, height: hByVp[375]! }, sizing: authored() }, + 768: { cs: { display: "flex", height: `${hByVp[768]}px` }, bbox: { x: 0, y: 0, width: 768, height: hByVp[768]! }, sizing: authored() }, + 1280: { cs: { display: "flex", height: `${hByVp[1280]}px` }, bbox: { x: 0, y: 0, width: 1280, height: hByVp[1280]! }, sizing: authored() }, + }, [abs]); + const outer = xNode("n1", "div", { + 375: { cs: { display: "flex", height: `${hByVp[375]}px` }, bbox: { x: 0, y: 0, width: 375, height: hByVp[375]! }, sizing: authored() }, + 768: { cs: { display: "flex", height: `${hByVp[768]}px` }, bbox: { x: 0, y: 0, width: 768, height: hByVp[768]! }, sizing: authored() }, + 1280: { cs: { display: "flex", height: `${hByVp[1280]}px` }, bbox: { x: 0, y: 0, width: 1280, height: hByVp[1280]! }, sizing: authored() }, + }, [inner]); + return xNode("n0", "body", { + 375: { bbox: { x: 0, y: 0, width: 375, height: hByVp[375]! } }, + 768: { bbox: { x: 0, y: 0, width: 768, height: hByVp[768]! } }, + 1280: { bbox: { x: 0, y: 0, width: 1280, height: hByVp[1280]! } }, + }, [outer]); + } + + it("keeps both heights when the probe proves them authored-explicit", () => { + const css = generateCss(xIr(hero({ 375: 771, 768: 900, 1280: 800 })), new Map()); + const outer = allRulesX(css, "n1"); + const inner = allRulesX(css, "n2"); + assert.ok(/height:800px/.test(outer), `outer must keep its canonical authored height, got: ${outer}`); + assert.ok(/height:800px/.test(inner), `inner must keep its canonical authored height, got: ${inner}`); + }); + + it("still flows a genuinely content-sized varying height (probe hAuto:true)", () => { + // Same shape but the probe says auto reproduces the box — the varying height is real content, safe + // to drop. Give it an in-flow text child so it reads content-driven. + const auto = (): RawSizing => ({ wAuto: true, wFill: false, hAuto: true, hFill: false }); + const mk = (h: number, w: number): XPerVp => ({ cs: { display: "block", height: `${h}px` }, bbox: { x: 0, y: 0, width: w, height: h }, sizing: auto() }); + const inner = xNode("n2", "div", { 375: mk(200, 375), 768: mk(260, 768), 1280: mk(320, 1280) }, [{ text: "flowing content" } as IRChild]); + // pad the box so height == content is plausible per contentDriven's text branch + const root = xNode("n0", "body", { 375: { bbox: { x: 0, y: 0, width: 375, height: 200 } }, 768: { bbox: { x: 0, y: 0, width: 768, height: 260 } }, 1280: { bbox: { x: 0, y: 0, width: 1280, height: 320 } } }, [inner]); + const css = generateCss(xIr(root), new Map()); + assert.ok(!/height:320px/.test(allRulesX(css, "n2")), `a probe-auto content height should still flow, got: ${allRulesX(css, "n2")}`); + }); +}); + +// BUG 2 — a space-distributing flex column (justify-content: space-between/around/evenly, or a packed +// center/flex-end) sets the free space its children spread through, so the last child reaching the box +// bottom is NOT content evidence. heightFlows used to read that as content-driven and drop the box's +// authored height, collapsing the distributed gaps. +describe("generateCss space-distributing flex column keeps its height", () => { + // hAuto:true (auto reproduces the box at capture, because distribution places the last child at the + // bottom) — so the sizing probe does NOT forbid the drop. Wrappable text inside makes heightProbeDrops + // abstain, isolating heightFlows' space-distribution disqualification as the sole arbiter. + const auto = (): RawSizing => ({ wAuto: true, wFill: false, hAuto: true, hFill: false }); + const textChild = { text: "a line of distributed content" } as IRChild; + function distributed(justify: string) { + // two content-sized blocks; the last sits at the box bottom because the column distributes space + const top = xNode("n2", "div", { + 375: { cs: { display: "block" }, bbox: { x: 0, y: 0, width: 375, height: 40 } }, + 768: { cs: { display: "block" }, bbox: { x: 0, y: 0, width: 768, height: 40 } }, + 1280: { cs: { display: "block" }, bbox: { x: 0, y: 0, width: 1280, height: 40 } }, + }, [textChild]); + const bottomH = (h: number): XPerVp => ({ cs: { display: "block" }, bbox: { x: 0, y: h - 40, width: 375, height: 40 } }); + const bot = xNode("n3", "div", { 375: bottomH(360), 768: bottomH(500), 1280: bottomH(600) }, [textChild]); + const col = xNode("n1", "div", { + 375: { cs: { display: "flex", flexDirection: "column", justifyContent: justify, height: "360px" }, bbox: { x: 0, y: 0, width: 375, height: 360 }, sizing: auto() }, + 768: { cs: { display: "flex", flexDirection: "column", justifyContent: justify, height: "500px" }, bbox: { x: 0, y: 0, width: 768, height: 500 }, sizing: auto() }, + 1280: { cs: { display: "flex", flexDirection: "column", justifyContent: justify, height: "600px" }, bbox: { x: 0, y: 0, width: 1280, height: 600 }, sizing: auto() }, + }, [top, bot]); + return xNode("n0", "body", { 375: { bbox: { x: 0, y: 0, width: 375, height: 360 } }, 768: { bbox: { x: 0, y: 0, width: 768, height: 500 } }, 1280: { bbox: { x: 0, y: 0, width: 1280, height: 600 } } }, [col]); + } + + for (const j of ["space-between", "space-around", "space-evenly"]) { + it(`keeps the authored height under justify-content:${j}`, () => { + const css = generateCss(xIr(distributed(j)), new Map()); + assert.ok(/height:600px/.test(allRulesX(css, "n1")), `${j} column must keep its distributing height, got: ${allRulesX(css, "n1")}`); + }); + } + + it("still flows a flex-start column whose height truly equals its content", () => { + // justify-content:flex-start (default) — the last child at the bottom IS content-driven, so the + // varying height flows (drops) as before. + const css = generateCss(xIr(distributed("flex-start")), new Map()); + assert.ok(!/height:600px/.test(allRulesX(css, "n1")), `a packed-top column should still flow, got: ${allRulesX(css, "n1")}`); + }); +}); + +// BUG 3 — a lottie mount pins its captured per-viewport height so the runtime player's aspect-sized +// svg fills a definite box instead of inflating past its neighbours. Threaded via collectNodeRules' +// lottieMounts set (populated from the lottie spec item cids). +describe("collectNodeRules lottie mount height pin", () => { + const auto = (): RawSizing => ({ wAuto: true, wFill: false, hAuto: true, hFill: false }); + function mountFixture() { + // the mount is a flex box; its only child is a content-sized placeholder svg — without the pin the + // box height flows/collapses and the runtime svg inflates by aspect. + const svg = xNode("n2", "svg", { + 375: { cs: { display: "block" }, bbox: { x: 0, y: 0, width: 375, height: 94 } }, + 768: { cs: { display: "block" }, bbox: { x: 0, y: 0, width: 768, height: 195 } }, + 1280: { cs: { display: "block" }, bbox: { x: 0, y: 0, width: 1280, height: 227 } }, + }); + const mount = xNode("n1", "div", { + 375: { cs: { display: "flex", height: "94px" }, bbox: { x: 0, y: 0, width: 375, height: 94 }, sizing: auto() }, + 768: { cs: { display: "flex", height: "195px" }, bbox: { x: 0, y: 0, width: 768, height: 195 }, sizing: auto() }, + 1280: { cs: { display: "flex", height: "227px" }, bbox: { x: 0, y: 0, width: 1280, height: 227 }, sizing: auto() }, + }, [svg]); + return xNode("n0", "body", { 375: { bbox: { x: 0, y: 0, width: 375, height: 94 } }, 768: { bbox: { x: 0, y: 0, width: 768, height: 195 } }, 1280: { bbox: { x: 0, y: 0, width: 1280, height: 227 } } }, [mount]); + } + + it("pins the captured height on a mount node", () => { + const rules = collectNodeRules(xIr(mountFixture()), new Map(), undefined, undefined, undefined, false, new Set(["n1"])); + const nr = rules.get("n1"); + assert.ok(nr, "mount rule must exist"); + const all = [nr!.base.get("height"), ...nr!.bands.map((b) => b.decls.get("height"))].filter(Boolean).join(";"); + assert.ok(/227px/.test(all), `canonical mount height must be pinned, got: ${all}`); + assert.ok(/94px/.test(all), `narrow-band mount height must be pinned, got: ${all}`); + }); + + it("leaves a non-mount box unpinned (flows its varying content height)", () => { + const rules = collectNodeRules(xIr(mountFixture()), new Map(), undefined, undefined, undefined, false, new Set()); + const nr = rules.get("n1")!; + const all = [nr.base.get("height"), ...nr.bands.map((b) => b.decls.get("height"))].filter(Boolean).join(";"); + assert.ok(!/227px/.test(all), `without the mount flag the varying height should flow, got: ${all}`); + }); +}); diff --git a/compiler/test/lottiePlaceholder.test.ts b/compiler/test/lottiePlaceholder.test.ts index c220df5..9cb6f2b 100644 --- a/compiler/test/lottiePlaceholder.test.ts +++ b/compiler/test/lottiePlaceholder.test.ts @@ -36,6 +36,20 @@ describe("DittoLottie placeholder retention", () => { it("handles a failed load without leaving a broken mount stacked over the placeholder", () => { assert.match(DITTO_LOTTIE_TSX, /data_failed/); }); + + // The player creates its / at runtime WITHOUT a data-cid, so the DOM/media gate can't + // map it back to the captured node. The reveal must forward the discarded placeholder's data-cid + // onto the runtime-rendered element (validation-agnostic; no gate special-casing). + it("forwards the placeholder's data-cid onto the runtime-rendered svg/canvas before the swap", () => { + // reads the placeholder cid, then reassigns it to the rendered svg/canvas inside the mount + assert.match(DITTO_LOTTIE_TSX, /getAttribute\(\s*["']data-cid["']\s*\)/); + assert.match(DITTO_LOTTIE_TSX, /mount\.querySelector\(\s*["']svg,\s*canvas["']\s*\)/); + assert.match(DITTO_LOTTIE_TSX, /setAttribute\(\s*["']data-cid["']\s*,/); + // the cid capture must precede its removal so the placeholder is still in the DOM when read + const readIdx = DITTO_LOTTIE_TSX.indexOf('getAttribute("data-cid")'); + const removeIdx = DITTO_LOTTIE_TSX.indexOf("removeChild"); + assert.ok(readIdx >= 0 && removeIdx >= 0 && readIdx < removeIdx, "must read the placeholder cid before removing it"); + }); }); describe("identity-transform canonicalization (isIdentityTransform)", () => {