From c49d5b6ab780a23d80db19c2729fbc4e452124fc Mon Sep 17 00:00:00 2001 From: Samraaj Bath Date: Sat, 4 Jul 2026 12:42:11 -0700 Subject: [PATCH] Recover viewport-relative min-heights and capped-container centering at emission - viewportMinHeightLaw: a box authored min-h-screen / min-h-[calc(100vh - Cpx)] had its per-viewport px baked at capture, leaving whitespace (or overflow) at window heights between captured viewports; when the source class list corroborates a vh token and per-regime offsets agree, emit the authored law (100vh / calc(100vh - Cpx)) instead of frozen px - isCappedCenteredContainer: max-width-capped centered content columns emit mx-auto (including as flex/grid items, where auto margins center along the main axis) so pages stay centered at non-captured window widths instead of pinning left at frozen px - Circular-slide guard skips fluid full-bleed sections so viewport-spanning shrink-0 items keep fluid width 476 tests pass (4 new, with corroboration-guard negatives), typecheck clean. Co-Authored-By: Claude Fable 5 --- compiler/src/generate/css.ts | 153 ++++++++++++++++++++++++++++++++++- compiler/test/css.test.ts | 102 +++++++++++++++++++++++ 2 files changed, 251 insertions(+), 4 deletions(-) diff --git a/compiler/src/generate/css.ts b/compiler/src/generate/css.ts index 650ed50..74eb531 100644 --- a/compiler/src/generate/css.ts +++ b/compiler/src/generate/css.ts @@ -318,6 +318,7 @@ const viewportHeightFor = (vp: number): number => CAPTURE_VIEWPORT_HEIGHTS[vp] ? type GeometryPlan = { heightByVp?: Record; aspectByVp?: Record; + minHeightByVp?: Record; leftByVp?: Record; topByVp?: Record; }; @@ -813,6 +814,69 @@ function singleFluidGridRow(node: IRNode, viewports: number[]): Map= 2 ? result : null; } +/** A box authored with a viewport-relative min-height (e.g. `min-h-screen` or + * `min-h-[calc(100vh - Cpx)]`) whose per-viewport px is baked at capture, freezing a full-screen + * hero to one window height. The engine only sees the resolved px, so we recover the authored law + * `calc(100vh - Cpx)` (constant offset C, k=1) from samples spanning DIFFERENT viewport heights and + * re-emit it as a viewport unit. Gated by an authored vh-token corroboration so a content-driven + * min-height can't be mistaken for a viewport law. */ +function viewportMinHeightLaw(node: IRNode, viewports: number[]): Record | null { + // Authored corroboration: only recover a viewport law when the source class carries a + // viewport-relative min-height intent. Without srcClass we can't distinguish it from a fixed box. + const src = node.srcClass || ""; + if (!src) return null; + const hasVhIntent = + /\b\d*\.?\d*(?:vh|dvh|svh|lvh)\b/.test(src) || + src.includes("100vh") || src.includes("min-h-screen") || + src.includes("min-h-[100vh") || src.includes("calc(100vh"); + if (!hasVhIntent) return null; + + const samples: Array<{ vp: number; mh: number; vh: number }> = []; + for (const vp of viewports) { + const cs = node.computedByVp[vp]; const nb = node.bboxByVp[vp]; + if (!cs || !nb || !node.visibleByVp[vp] || (cs.display || "") === "none") continue; + const mhRaw = cs.minHeight; + if (!mhRaw || !mhRaw.endsWith("px")) continue; + const mh = pf(mhRaw); + if (!(mh >= 120)) continue; // avoid tiny content boxes + samples.push({ vp, mh, vh: viewportHeightFor(vp) }); + } + if (samples.length < 2) return null; + // The law must be DETERMINED: at least two samples whose viewport heights actually differ. + // If every sample shares one viewport height, a fixed px is indistinguishable from `100vh`. + const vhs = samples.map((s) => s.vh); + if (Math.max(...vhs) - Math.min(...vhs) <= 1) return null; + + // Per-sample offset C_i = vh_i - mh_i, checked against the SAME viewport's own captured height (an + // exact per-sample identity, not an extrapolation). Responsive heroes are commonly authored as a + // BREAKPOINT-SPLIT law — `min-h-[100vh] md:min-h-[calc(100vh - Cpx)]` — so C is a per-regime + // constant, NOT globally constant: the mobile band has C≈0 (`100vh`) while the desktop bands share + // one positive C (`calc(100vh - Cpx)`). We allow that split but keep each regime determined: + // - each C_i must be a modest subtraction (0 <= C_i <= 0.5·vh_i), else the box isn't viewport-fit; + // - the NON-ZERO offsets must all agree within 1.5px (one determined desktop offset, k=1) — a + // spread of distinct positive C's is ambiguous, so bail rather than freeze a wrong law. + const offsets = samples.map((s) => ({ vp: s.vp, c: s.vh - s.mh, vh: s.vh })); + for (const o of offsets) { + if (o.c < -1.5) return null; // an addition, not a viewport-fit subtraction + if (o.c > 0.5 * o.vh) return null; // too large an offset to be a full-screen hero + } + const nonZero = offsets.filter((o) => o.c > 1.5).map((o) => o.c); + let deskC = 0; + if (nonZero.length) { + // A non-zero offset regime must be CORROBORATED: a lone desktop sample with a unique positive C is + // indistinguishable from a genuinely fixed px min-height (e.g. `min-h-[100vh] md:min-h-[500px]`), so + // require >= 2 desktop samples that agree on one C (within 1.5px). A C≈0 (`100vh`) band is an exact + // per-sample viewport identity and needs no such corroboration. + if (nonZero.length < 2) return null; + const mean = nonZero.reduce((a, b) => a + b, 0) / nonZero.length; + if (nonZero.some((c) => Math.abs(c - mean) > 1.5)) return null; // desktop offsets disagree + deskC = Math.round(mean); + } + const out: Record = {}; + for (const o of offsets) out[o.vp] = o.c <= 1.5 ? "100vh" : `calc(100vh - ${deskC}px)`; + return out; +} + /** A large structural/media box whose captured height is a viewport-height expression with a lower * and/or upper clamp. Cursor's demo frames are the canonical example: every sampled height equals * `clamp(MIN, 70vh, MAX)`, but the engine only sees the resolved px row height. */ @@ -1025,6 +1089,22 @@ function isFlexFillItem(node: IRNode, parentNode: IRNode | undefined, viewports: * genuine circular collapse, never on a normal content-sized shrink-0 item. */ function isCircularShrinkSlide(node: IRNode, parentNode: IRNode | undefined, viewports: number[]): boolean { if (!parentNode || REPLACED.has(node.tag) || node.tag.includes("-")) return false; + // A box that spans the full viewport at ≥2 distinct widths with no horizontal margins has a real + // width source (it fills a fluid container / was authored width:100%) — it is NOT a circular-collapse + // slide. `width:100%`/`auto` gives its fill-children a valid definite basis, so there is nothing to + // collapse. Same "proven fluid" bar (samples >= 2 distinct widths) used by isFluidFullBleed / + // isFluidFillItem above; freezing such a full-bleed section to captured px is exactly the misfire. + const nearZero = (v: string | undefined): boolean => v != null && v !== "auto" && Math.abs(parseFloat(v)) <= 1.5; + let fluidSpan = 0; + for (const vp of viewports) { + const cs = node.computedByVp[vp]; const bb = node.bboxByVp[vp]; + if (!cs || !bb || !node.visibleByVp[vp] || (cs.display || "") === "none" || !(bb.width > 0)) continue; + if (Math.abs(bb.x) > 1.5) continue; // must start at the left edge + if (Math.abs(bb.width - vp) > 1.5) continue; // must span the full viewport + if (!nearZero(cs.marginLeft) || !nearZero(cs.marginRight)) continue; // margins make the width load-bearing + fluidSpan++; + } + if (fluidSpan >= 2) return false; // proven fluid full-bleed, not a circular slide let painted = 0; for (const vp of viewports) { const cs = node.computedByVp[vp]; const pcs = parentNode.computedByVp[vp]; const nb = node.bboxByVp[vp]; @@ -2693,8 +2773,13 @@ function declsForViewport( } else if (cs.height && cs.height !== "auto" && !isTableWithCaption && !rootUnclamp && !dropHeight && (!isInlineOnly || REPLACED.has(tag)) && (noCollapse || isLeaf || explicitHeight)) { out.set("height", cs.height); } + const plannedMinHeight = geometry.minHeightByVp?.[vp]; if (cs.minHeight && cs.minHeight !== "0px" && cs.minHeight !== "auto") { - out.set("min-height", isViewportHeight(cs.minHeight, vp) ? "100vh" : cs.minHeight); + if (plannedMinHeight) { + out.set("min-height", plannedMinHeight); + } else { + out.set("min-height", isViewportHeight(cs.minHeight, vp) ? "100vh" : cs.minHeight); + } } // borders (complete triple per side, only when width > 0). When all four sides are @@ -3085,6 +3170,59 @@ function hasPxMaxWidthCap(node: IRNode, viewports: number[]): boolean { return max > 0 && max - min <= Math.max(2, max * 0.02); } +/** A centred max-width container that also qualifies when it is a FLEX/GRID ITEM (which + * `planWidth` case (a) and `centeredAtVp` both bail on — a flex/grid item is normally positioned by + * the parent layout). The pattern: `width:100%; max-width:W; margin:0 auto` — the box fills its + * container below the cap and centres (symmetric free space) above it. `margin:auto` centres a + * flex/grid item along the main axis exactly as it centres a block, so emitting `mx-auto` is correct + * here too; freezing the captured side margins (which vary per width — 32/47/67/324px) instead + * left-aligns the box at any non-captured width. + * + * This is SAFE (unlike the width-dropped fill-inset case `centeredAtVp` guards against, where + * `margin:auto` resolves to 0 and blows the box full-bleed) precisely because a px `max-width` cap + * is RETAINED at every band: the cap holds the width, so `margin:auto` can only absorb the centring + * slack, never over-widen. The `max-width` cap may VARY per viewport (a fluid + * `min(Wpx, 100% − 2·gutter)` resolves to a different px per width) — we accept per-band caps and let + * the normal `max-width` emission carry them, mirroring `planWidth` case (a)'s documented reasoning. + * + * Every guard holds over the painted samples (≥2), and centring must be OBSERVED (symmetric positive + * free space with equal margins) at ≥1 band where the container exceeds the cap: */ +function isCappedCenteredContainer(node: IRNode, parentNode: IRNode | undefined, viewports: number[]): boolean { + if (!parentNode || REPLACED.has(node.tag) || node.tag.includes("-")) return false; + let painted = 0; + let sawCenter = false; + for (const vp of viewports) { + const cs = node.computedByVp[vp]; const pcs = parentNode.computedByVp[vp]; + const nb = node.bboxByVp[vp]; const pb = parentNode.bboxByVp[vp]; + if (!cs || !pcs || !nb || !pb || !node.visibleByVp[vp] || (cs.display || "") === "none" || !(nb.width > 0)) continue; + // Block-LEVEL box (block/list-item/flow-root/table, or a grid/flex CONTAINER — all margin-auto + // centreable), in normal flow, border-box. Inline-level boxes centre via text-align, not margins. + if (!/^(block|list-item|flow-root|table|grid|flex)$/.test(cs.display || "")) return false; + const pos = cs.position || "static"; + if (pos !== "static" && pos !== "relative") return false; + if ((cs.float || "none") !== "none") return false; + if ((cs.boxSizing || "border-box") === "content-box") return false; + const maxW = cs.maxWidth || ""; + if (!maxW.endsWith("px")) return false; // must carry a px max-width cap at every band + const cap = pf(maxW); + if (!(cap > 0)) return false; + const content = pb.width - pf(pcs.paddingLeft) - pf(pcs.paddingRight) - pf(pcs.borderLeftWidth) - pf(pcs.borderRightWidth); + if (!(content > 0)) return false; + // The box sits at min(containerContent, cap): it fills the container below the cap and plateaus at + // the cap above it — the exact `width:100%; max-width:W` trajectory (NOT a fixed or content width). + if (Math.abs(nb.width - Math.min(content, cap)) > Math.max(1.5, 0.01 * nb.width)) return false; + const ml = pf(cs.marginLeft); const mr = pf(cs.marginRight); + // Where the container exceeds the cap there is room to centre — the box MUST be centred there + // (symmetric positive side margins), else it is left/right-aligned and margin:auto would move it. + if (content > cap + 4) { + if (ml > 1 && mr > 1 && Math.abs(ml - mr) <= 1.5) sawCenter = true; + else return false; + } + painted++; + } + return painted >= 2 && sawCenter; +} + function hasElementChild(node: IRNode): boolean { for (const c of node.children) if (!isTextChild(c)) return true; return false; @@ -3546,7 +3684,11 @@ export function collectNodeRules(ir: IR, assetMap: Map, includeN const stableCenter = centerAlways || sourceMarginAutoIntent(node) || - ((!!fillCap || hasPxMaxWidthCap(node, sampleVps)) && centeredAtAnySample); + ((!!fillCap || hasPxMaxWidthCap(node, sampleVps)) && centeredAtAnySample) || + // A centred max-width container that is a flex/grid ITEM (which centerAlways / centeredAtVp bail + // on): `width:100%; max-width:W; margin:0 auto`. Safe because the px max-width cap is retained, + // so mx-auto only absorbs the centring slack. Handles per-viewport-varying caps. + isCappedCenteredContainer(node, parentNode, sampleVps); // Fluid grid-template-columns (fr), per-viewport (the column count may change across breakpoints). const gridColsByVp = fluidGridColumns(node, sampleVps); // Large one-row media grids need a definite height/aspect law before the single `1fr` row is @@ -3558,8 +3700,11 @@ export function collectNodeRules(ir: IR, assetMap: Map, includeN 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) - ? { ...mediaGeometry, ...(leftClampByVp ? { leftByVp: leftClampByVp } : {}), ...(topClampByVp ? { topByVp: topClampByVp } : {}) } + // Recover an authored viewport-relative min-height (`100vh`/`calc(100vh - Cpx)`) so a full-screen + // hero refills the window instead of freezing to the captured px at each band. + const minHeightByVp = !isContents ? viewportMinHeightLaw(node, sampleVps) : null; + const geometry: GeometryPlan = (mediaGeometry.heightByVp || mediaGeometry.aspectByVp || minHeightByVp || leftClampByVp || topClampByVp) + ? { ...mediaGeometry, ...(minHeightByVp ? { minHeightByVp } : {}), ...(leftClampByVp ? { leftByVp: leftClampByVp } : {}), ...(topClampByVp ? { topByVp: topClampByVp } : {}) } : GEOMETRY_NONE; // Fluid grid-template-rows (1fr) for equal, responsive, content-height-filling row regimes. const gridRowsByVp = fluidGridRows(node, sampleVps) ?? ((geometry.heightByVp || geometry.aspectByVp) ? singleRowsByVp : null); diff --git a/compiler/test/css.test.ts b/compiler/test/css.test.ts index 28d2a03..9458cb1 100644 --- a/compiler/test/css.test.ts +++ b/compiler/test/css.test.ts @@ -475,6 +475,29 @@ describe("generateCss literal-margin vs auto-centring", () => { const mobile = xBandRule(css, /^\(max-width/, "n1"); assert.ok(/max-width:311px/.test(mobile), `mobile band carries its own fluid cap, got: ${mobile}`); }); + + // Same centred `width:100%; max-width:min(Wpx, …); margin:0 auto` container, but it is a FLEX ITEM + // (its parent is display:flex). planWidth case (a) and centeredAtVp both bail on flex/grid items, so + // without isCappedCenteredContainer it would freeze its captured per-band side margins (32/47/67px) + // and left-align at every non-captured width. margin:auto centres a flex item correctly, and the + // retained per-band max-width cap keeps it safe (auto only absorbs the centring slack). + it("auto-centres a fluid-max-width container that is a FLEX ITEM (parent is flex)", () => { + const child = xNode("n1", "div", { + 375: { cs: { display: "block", boxSizing: "border-box", maxWidth: "311px", marginLeft: "32px", marginRight: "32px", width: "311px" }, bbox: { x: 32, y: 0, width: 311, height: 200 } }, + 768: { cs: { display: "block", boxSizing: "border-box", maxWidth: "673.2px", marginLeft: "47.4px", marginRight: "47.4px", width: "673.2px" }, bbox: { x: 47.4, y: 0, width: 673.2, height: 200 } }, + 1280: { cs: { display: "block", boxSizing: "border-box", maxWidth: "1145.08px", marginLeft: "67.46px", marginRight: "67.46px", width: "1145.08px" }, bbox: { x: 67.46, y: 0, width: 1145.08, height: 200 } }, + }); + const parent = xNode("n0", "body", { + 375: { cs: { display: "flex", flexDirection: "column" }, bbox: { x: 0, y: 0, width: 375, height: 200 } }, + 768: { cs: { display: "flex", flexDirection: "column" }, bbox: { x: 0, y: 0, width: 768, height: 200 } }, + 1280: { cs: { display: "flex", flexDirection: "column" }, bbox: { x: 0, y: 0, width: 1280, height: 200 } }, + }, [child]); + const css = generateCss(xIr(parent), new Map()); + const all = allRulesX(css, "n1"); + assert.ok(/margin-left:auto/.test(all), `flex-item fluid-cap centred container must auto-centre, got: ${all}`); + assert.ok(!/margin-left:32px|margin-left:47|margin-left:0?67/.test(all), `must NOT bake literal per-band left margins, got: ${all}`); + assert.ok(baseRule(css, "n1").includes("max-width:1145.08px"), `base carries the canonical cap, got: ${baseRule(css, "n1")}`); + }); }); // BUG C — a single-line text leaf whose unwrapped width nearly fills its column at every width gets @@ -963,6 +986,51 @@ describe("generateCss circular carousel slide with a block-level fill child (FIX }); }); +// FIX 4b — the circular-slide detector must NOT misfire on a genuine fluid full-bleed section. A +// `shrink-0` flex/grid item whose box spans the FULL viewport at ≥2 distinct captured widths (x≈0, +// width≈vp) with zero horizontal margins provably HAS a width source (it fills a fluid container / +// was authored width:100%) — there is no circular collapse, so it must emit a fluid width, not freeze +// to per-breakpoint captured px (w-320/w-192/w-480). Same "proven fluid" bar (≥2 distinct widths). +describe("generateCss circular-slide guard skips fluid full-bleed section (FIX 4b)", () => { + const blockFill = (): RawSizing => ({ wAuto: true, wFill: true, hAuto: true, hFill: true }); + function fullBleedSection() { + // A shrink-0 flex item whose bbox tracks the viewport (375/768/1280) with zero horizontal margins: + // a fluid full-bleed section, NOT a fixed-width carousel slide. Its block child fills it. + const child = xNode("n2", "div", { + 375: { cs: { display: "block", position: "static" }, bbox: { x: 0, y: 0, width: 375, height: 80 }, sizing: blockFill() }, + 768: { cs: { display: "block", position: "static" }, bbox: { x: 0, y: 0, width: 768, height: 80 }, sizing: blockFill() }, + 1280: { cs: { display: "block", position: "static" }, bbox: { x: 0, y: 0, width: 1280, height: 80 }, sizing: blockFill() }, + }, [{ text: "Full bleed" } as IRChild]); + // flex-shrink:0 item; its captured px equals the viewport at each width (what the library/probe saw). + // boxSizing:content-box keeps isFillsContainerWidth/isFullWidthShrinkSlide (border-box only) off, so + // WITHOUT the guard the circular-slide detector wins and freezes these per-breakpoint px. + const secCs = (w: number) => ({ display: "block", position: "static", flexShrink: "0", boxSizing: "content-box", marginLeft: "0px", marginRight: "0px", width: `${w}px` }); + const section = xNode("n1", "nav", { + 375: { cs: secCs(375), bbox: { x: 0, y: 0, width: 375, height: 80 } }, + 768: { cs: secCs(768), bbox: { x: 0, y: 0, width: 768, height: 80 } }, + 1280: { cs: secCs(1280), bbox: { x: 0, y: 0, width: 1280, height: 80 } }, + }, [child]); + // A flex track with symmetric padding: its CONTENT width is narrower than the full-viewport section, + // so the "fills the flex container content" detectors (isFillsContainerWidth / isFullWidthShrinkSlide) + // do NOT fire — leaving the circular-slide detector as the width-plan winner it must be guarded from. + const trackCs = { display: "flex", position: "static", paddingLeft: "24px", paddingRight: "24px" }; + return xNode("n0", "body", { + 375: { cs: trackCs, bbox: { x: 0, y: 0, width: 375, height: 80 } }, + 768: { cs: trackCs, bbox: { x: 0, y: 0, width: 768, height: 80 } }, + 1280: { cs: trackCs, bbox: { x: 0, y: 0, width: 1280, height: 80 } }, + }, [section]); + } + + it("emits a fluid width for a full-viewport-span shrink-0 flex item, not frozen per-breakpoint px", () => { + const css = generateCss(xIr(fullBleedSection()), new Map()); + const section = allRulesX(css, "n1"); + assert.ok(!/width:375px|width:768px|width:1280px/.test(section), `a fluid full-bleed section must not freeze to captured px, got: ${section}`); + // A fluid outcome is either an explicit fluid width (width:100%/auto) OR no width at all — the block + // default resolves to auto and fills the container. Any FIXED px width would be the frozen misfire. + assert.ok(!/width:\d/.test(section) || /width:100%|width:auto/.test(section), `a fluid full-bleed section must emit a fluid width (or none), got: ${section}`); + }); +}); + // =========================================================================== // Emission-geometry fix wave (T1/T2/T3/T5, C1 css side, C2) // =========================================================================== @@ -1152,6 +1220,40 @@ describe("generateCss aspectHeightLaw rejects width-clamping ratio transfer (FIX }); }); +// FIX 4 — a full-screen hero authored `min-h-[100vh] md:min-h-[calc(100vh - 137px)]` gets its +// per-viewport min-height baked to px at capture, freezing the hero to one window height and leaving +// whitespace at other heights. With an authored vh-token corroboration, the constant-offset law +// `calc(100vh - 137px)` (k=1) must be recovered from samples spanning DIFFERENT viewport heights and +// re-emitted, instead of the frozen px. XVPS heights are 812/1024/800, so C=137 → 675/887/663px. +describe("generateCss recovers a viewport-relative min-height law (FIX 4)", () => { + function hero(src: string | undefined): IRNode { + const n = xNode("n1", "section", { + 375: { cs: { display: "flex", position: "static", minHeight: "675px" }, bbox: { x: 0, y: 0, width: 375, height: 675 } }, + 768: { cs: { display: "flex", position: "static", minHeight: "887px" }, bbox: { x: 0, y: 0, width: 768, height: 887 } }, + 1280: { cs: { display: "flex", position: "static", minHeight: "663px" }, bbox: { x: 0, y: 0, width: 1280, height: 663 } }, + }); + if (src !== undefined) n.srcClass = src; + return xNode("n0", "body", Object.fromEntries(XVPS.map((vp) => [vp, { cs: { display: "block", position: "static" }, bbox: { x: 0, y: 0, width: vp, height: 900 } }])), [n]); + } + + it("emits calc(100vh - 137px) and drops the frozen px when srcClass carries a vh token", () => { + const css = generateCss(xIr(hero("flex min-h-[100vh] md:min-h-[calc(100vh_-_137px)]")), new Map()); + const all = allRulesX(css, "n1"); + assert.ok(/min-height:calc\(100vh - 137px\)/.test(all), `hero min-height must become the viewport law, got: ${all}`); + assert.ok(!/min-height:663px/.test(all), `desktop min-height must not stay frozen px, got: ${all}`); + assert.ok(!/min-height:887px/.test(all), `768 min-height must not stay frozen px, got: ${all}`); + assert.ok(!/min-height:675px/.test(all), `375 min-height must not stay frozen px, got: ${all}`); + }); + + it("keeps the baked px when srcClass has NO viewport-relative token (corroboration guard)", () => { + const css = generateCss(xIr(hero("flex min-h-[675px] md:min-h-[663px]")), new Map()); + const all = allRulesX(css, "n1"); + assert.ok(!/calc\(100vh/.test(all), `a content-driven min-height must not be mistaken for a vh law, got: ${all}`); + assert.ok(/min-height:663px/.test(all) || /min-height:675px/.test(all) || /min-height:887px/.test(all), + `without a vh token the baked px min-height must survive, got: ${all}`); + }); +}); + // C1 (css side) — a computed `grid-template-rows: subgrid` is a STRUCTURAL keyword, not a baked px // regime. It must NEVER be dropped by the reflow-mode dropGridRows path (which strips px row tracks). describe("collectNodeRules never drops a subgrid rows template (C1)", () => {