From ec4e4397ccb8457cffbe58fb88dd7b81fd196b7f Mon Sep 17 00:00:00 2001 From: Samraaj Bath Date: Sat, 4 Jul 2026 04:55:07 -0700 Subject: [PATCH] Trust geometry-corroborated authored fixed sizes; guard aspect-law width transfer - Authored fixed h-/w- utilities from the source cascade, when the captured geometry corroborates them, suppress fill/flow re-derivation and emit as captured computed px per band (root-font-size independent) - fixes logo tiles collapsing to h-full and replaced-element sizes dropping to intrinsic - aspectHeightLaw rejects ratios where the CSS aspect-ratio/max-height width transfer would clamp width below the captured box, falling back to per-band explicit dimensions 419 tests pass (4 new, each verified discriminating), typecheck clean, double-regen byte-identical. Co-Authored-By: Claude Fable 5 --- compiler/src/generate/css.ts | 126 +++++++++++++++++++++++++++-- compiler/src/generate/tailwind.ts | 68 +++++++++++++++- compiler/test/bandingFixes.test.ts | 39 +++++++++ compiler/test/css.test.ts | 94 +++++++++++++++++++++ 4 files changed, 318 insertions(+), 9 deletions(-) diff --git a/compiler/src/generate/css.ts b/compiler/src/generate/css.ts index bf9d8ac..f256e44 100644 --- a/compiler/src/generate/css.ts +++ b/compiler/src/generate/css.ts @@ -882,7 +882,15 @@ function aspectHeightLaw(node: IRNode, viewports: number[]): Record Math.max(1.5, 0.01 * s.h)) continue; + // Aspect-ratio + max-height also transfers into a max-WIDTH: per CSS transferred sizing, a box + // with `aspect-ratio:R` and `max-height:M` cannot exceed `R × M` wide (the height cap back-computes + // a width cap). The height-only check above accepts any ratio that makes min(w/R, M) ≈ h at a + // clamped viewport, but a ratio whose transferred max-width (R × M) is narrower than the measured + // box would clamp the clone's width below the source (a 1.66:1 box forced square to its 420px cap). + // Require the measured width to fit under the transferred cap at every clamped sample. + if (Number.isFinite(s.maxH) && s.w > r.ratio * s.maxH + Math.max(1.5, 0.01 * s.w)) continue; + chosen = r; break; } if (!chosen) return null; out[s.vp] = chosen.value; @@ -1169,6 +1177,100 @@ function sourceFixedSizeIntent(node: IRNode): boolean { return /(?:^|\s)(?:[a-z0-9-]+:)*size-(?!fit(?:\s|$))\S+/.test(node.srcClass || ""); } +// An authored, definite length unit (px/rem/em/…) — the same predicate the capture-side probe uses to +// call a dimension "load-bearing". Rejects auto/%/keywords and viewport/container units (those are +// fluid laws the width/height passes already recover); a bare number (`0`) is not definite. Used to +// read the arbitrary-value inner of a `h-[…]` / `w-[…]` source utility. +function isDefiniteAuthoredLength(inner: string): boolean { + const v = inner.trim().toLowerCase(); + if (!v) return false; + if (/^(auto|min-content|max-content|fit-content|inherit|initial|unset|revert)$/.test(v)) return false; + if (/%|vw|vh|vmin|vmax|svw|lvw|dvw|svh|lvh|dvh|\bfr\b/.test(v)) return false; + return /(?:^|[\s(*/+-])[\d.]+(?:px|rem|em|cm|mm|in|pt|pc|ex|ch|q)\b/.test(v); +} + +/** Does the SOURCE class author an explicit, definite fixed height on this axis — `h-[]`, + * `min-h-[]`, or the token forms (`h-`, `h-px`) — at one or more breakpoints, corroborated by + * geometry? This is the emission-side twin of the capture probe's authored-height guard: a banded + * authored fixed height (`h-[4rem] sm:h-[4.5rem] md:h-[6.25rem]`) can still probe hFill/hAuto when the + * box is a grid/flex item whose fill child (or stretched track) reproduces the box height — the + * fill↔content cycle — so the probe alone would drop the baked band to `h-full`/auto. When the source + * authors a definite height AND every painted viewport has a definite computed height matching its + * bbox (the authored value actually resolved to that px), trust the baked per-band px. Geometry + * corroboration keeps this from firing on a fluid/percentage box that merely mentions a fixed h-token + * under an inactive variant. */ +function sourceFixedHeightIntent(node: IRNode, viewports: number[]): boolean { + if (REPLACED.has(node.tag) || node.tag === "canvas" || node.tag.includes("-")) return false; + if (!authorsFixedAxis(node.srcClass, "h")) return false; + return fixedAxisGeometryHolds(node, "h", viewports); +} + +/** The WIDTH twin of sourceFixedHeightIntent: the source authors a definite `w-[]`/`min-w-[]` + * (or token) width, corroborated by a definite computed width == bbox at every painted viewport. A + * banded authored fixed width on a shrink-0 item (a testimonial avatar `h-[2.5rem] w-[2.5rem] shrink-0` + * whose img child fills it) probes wAuto/wFill and would otherwise be dropped, collapsing the box to + * its child's intrinsic size. Trust the authored fixed width. */ +function sourceFixedWidthIntent(node: IRNode, viewports: number[]): boolean { + if (REPLACED.has(node.tag) || node.tag === "canvas" || node.tag.includes("-")) return false; + if (!authorsFixedAxis(node.srcClass, "w")) return false; + return fixedAxisGeometryHolds(node, "w", viewports); +} + +// True when the source class authors a definite fixed length on the given axis at any breakpoint: +// `h-[4rem]` / `min-w-[24px]` (arbitrary), `h-px`, or a numeric token `w-24` (Tailwind's spacing scale +// is a fixed rem length). `full`/`auto`/`screen`/fraction/`min`/`max`/`fit`/`svh`… tokens are fluid and +// excluded. Variant-prefixed forms count (banded authored heights), matching the source-intent parser. +function authorsFixedAxis(srcClass: string | undefined, axis: "h" | "w"): boolean { + if (!srcClass) return false; + for (const raw of srcClass.split(/\s+/).filter(Boolean)) { + const colon = lastTopLevelColon(raw); + const core = colon >= 0 ? raw.slice(colon + 1) : raw; + const m = new RegExp(`^(?:min-)?${axis}-(.+)$`).exec(core); + if (!m) continue; + const val = m[1]!; + if (val.startsWith("[") && val.endsWith("]")) { + if (isDefiniteAuthoredLength(val.slice(1, -1))) return true; + continue; + } + if (val === "px") return true; // 1px + if (/^\d+(?:\.\d+)?$/.test(val)) return true; // spacing-scale token (fixed rem) + } + return false; +} + +// Index of the variant/core separating colon at bracket depth 0 (so `h-[calc(1px:2)]` is not split on +// an interior colon), mirroring parseSourceClass in the tailwind emitter. +function lastTopLevelColon(raw: string): number { + let depth = 0, last = -1; + for (let i = 0; i < raw.length; i++) { + const ch = raw[i]!; + if (ch === "[" || ch === "(") depth++; + else if (ch === "]" || ch === ")") depth = Math.max(0, depth - 1); + else if (ch === ":" && depth === 0) last = i; + } + return last; +} + +// Geometry corroboration for an authored fixed height/width: at EVERY painted viewport the node has a +// definite (px, non-auto) computed value on the axis that equals its measured bbox extent. This proves +// the authored fixed length actually resolved to the captured box (so the baked per-band px is the +// authored value), not a fluid/percentage box that merely carries a fixed token under an inactive +// variant. Requires ≥1 painted sample. +function fixedAxisGeometryHolds(node: IRNode, axis: "h" | "w", viewports: number[]): boolean { + let painted = 0; + 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 val = axis === "h" ? cs.height : cs.width; + const ext = axis === "h" ? nb.height : nb.width; + if (!val || val === "auto" || !/px$/.test(val)) return false; + if (!(ext > 0)) return false; + if (Math.abs(pf(val) - ext) > Math.max(1.5, 0.01 * ext)) return false; + painted++; + } + return painted >= 1; +} + const ABSOLUTE_MEDIA_FILL_TAGS = new Set(["img", "picture", "video"]); /** An absolutely-positioned media layer authored as a full-cover background: `position:absolute; @@ -2352,6 +2454,7 @@ function declsForViewport( dropViewportMaxWidth = false, nowrapText = false, keepIdentityTransform = false, + forceHeight = false, ): Map { const cs = node.computedByVp[vp]; const out = new Map(); @@ -2439,7 +2542,11 @@ function declsForViewport( // absolutely-positioned children). Without emitting it the block collapses to // its content in the clone. Content-filled blocks stay auto so margin // collapsing is preserved. - let explicitHeight = false; + // `forceHeight`: the caller proved this node authors a geometry-corroborated fixed height + // (sourceFixedHeightIntent) but the box's own content extent can't re-derive it (a shrink-0 box whose + // child fills it — the avatar case), so none of the structural explicit-height tests below fire. Trust + // the authored value and emit the baked per-band px. + let explicitHeight = forceHeight; const nb = node.bboxByVp[vp]; // Probe ground truth, emission twin of the heightFlows keep (see the hAuto/hFill guard there): // the sizing probe proved height:auto did NOT reproduce this box (hAuto:false) AND it is not a @@ -3295,9 +3402,15 @@ export function collectNodeRules(ir: IR, assetMap: Map, includeN // parent, varies) OR the sizing probe measured that height:100% reproduces and auto does not // (heightProbeFills — direct ground truth, also catches constant fillers; inert pre-probe captures). const sourceFixedSize = !isContents && sourceFixedSizeIntent(node); + // Authored, geometry-corroborated fixed height/width on this node's own source class (banded or + // not). These break the fill↔content probe cycle that otherwise drops a banded authored height to + // `h-full` (logo tiles) or a shrink-0 box's authored width/height to auto (testimonial avatars): + // trust the baked per-band px the capture measured, exactly as the capture-side authored guard does. + const sourceFixedHeight = !isContents && sourceFixedHeightIntent(node, sampleVps); + const sourceFixedWidth = !isContents && sourceFixedWidthIntent(node, sampleVps); const mediaCover = !isContents && absoluteMediaCoversParent(node, parentNode, sampleVps); const absHeightFill = !isContents && absoluteHeightFill(node, parentNode, sampleVps); - const buttonFixedHeight = !isContents && (fixedHeightButtonLike(node, sampleVps) || sourceFixedSize); + const buttonFixedHeight = !isContents && (fixedHeightButtonLike(node, sampleVps) || sourceFixedSize || sourceFixedHeight); const heightFill = !isContents && !buttonFixedHeight && (isHeightFill(node, parentNode, parentDefiniteHeight, sampleVps) || absHeightFill || mediaCover || heightProbeFills(node, sampleVps)); // This node's own height-drop decision, computed here so it can also feed childDefiniteHeight (a // height we drop becomes `auto` → does not confer a definite containing block). Same OR'd signals @@ -3356,7 +3469,7 @@ export function collectNodeRules(ir: IR, assetMap: Map, includeN const fillCap = !isContents ? fillsToCapWidth(node, parentNode, sampleVps) : null; // A width that leaves interior free space positions its children (auto margins / justify) — it's // load-bearing, so the content-sized branches below must NOT drop it (would kill the spacing). - const buttonFixedWidth = !isContents && (fixedWidthButtonLike(node, sampleVps) || sourceFixedSize); + const buttonFixedWidth = !isContents && (fixedWidthButtonLike(node, sampleVps) || sourceFixedSize || sourceFixedWidth); const lockWidth = !isContents && !fillCap && (hasInteriorFreeSpaceX(node, sampleVps) || buttonFixedWidth); // Ground-truth sizing probe is the primary signal after authored capped fills: // `width:100%; max-width:X` can look "auto" in a clone probe because the retained cap still @@ -3374,6 +3487,7 @@ export function collectNodeRules(ir: IR, assetMap: Map, includeN const mixedFill = (!lockWidth && !isContents && !percentVp) ? mixedFillByVp(node, parentNode, sampleVps) : null; const widthPlan: WidthPlan = fillCap ? { kind: "fillcap", cap: fillCap } + : sourceFixedWidth ? { kind: "fixed" } : sourceFixedSize ? inferred.plan : circularSlide ? { kind: "fixed" } : fullWidthSlide ? { kind: "basisFull" } @@ -3485,7 +3599,7 @@ export function collectNodeRules(ir: IR, assetMap: Map, includeN // gated to a breakpoint owns the transform only where it runs, but freezes a mid-scroll offset // at the OTHER bands — suppress those frozen deltas at every width). const animOwned = animOwnedProps(node, [baseVp, ...bands.map((b) => b.vp)]); - const baseDecls = finalizeDecls(declsForViewport(node, parentNode?.computedByVp[baseVp], baseVp, assetMap, centeredBase, colorVar, ir.doc.perViewport[baseVp]?.scrollHeight, widthPlan, gridColsByVp?.get(baseVp), gridRowsByVp?.get(baseVp), flowH, dropInsets, leftPct, heightFill, geometry, dropGridRows, dropViewportMaxWidth, nowrapText, keepIdentityTransform), tokenResolver); + const baseDecls = finalizeDecls(declsForViewport(node, parentNode?.computedByVp[baseVp], baseVp, assetMap, centeredBase, colorVar, ir.doc.perViewport[baseVp]?.scrollHeight, widthPlan, gridColsByVp?.get(baseVp), gridRowsByVp?.get(baseVp), flowH, dropInsets, leftPct, heightFill, geometry, dropGridRows, dropViewportMaxWidth, nowrapText, keepIdentityTransform, sourceFixedHeight), tokenResolver); const nr: NodeRule = { base: baseDecls, bands: [] }; // Per-band overrides (delta vs base), using the parent's value AT THAT viewport. @@ -3596,7 +3710,7 @@ export function collectNodeRules(ir: IR, assetMap: Map, includeN } } const centeredVp = stableCenter || (layoutParent ? centeredAtVp(node, layoutParent, b.vp) : false); - const vpDecls = finalizeDecls(declsForViewport(node, parentNode?.computedByVp[b.vp], b.vp, assetMap, centeredVp, colorVar, ir.doc.perViewport[b.vp]?.scrollHeight, widthPlan, gridColsByVp?.get(b.vp), gridRowsByVp?.get(b.vp), flowH, dropInsets, leftPct, heightFill, geometry, dropGridRows, dropViewportMaxWidth, nowrapText, keepIdentityTransform), tokenResolver); + const vpDecls = finalizeDecls(declsForViewport(node, parentNode?.computedByVp[b.vp], b.vp, assetMap, centeredVp, colorVar, ir.doc.perViewport[b.vp]?.scrollHeight, widthPlan, gridColsByVp?.get(b.vp), gridRowsByVp?.get(b.vp), flowH, dropInsets, leftPct, heightFill, geometry, dropGridRows, dropViewportMaxWidth, nowrapText, keepIdentityTransform, sourceFixedHeight), tokenResolver); const delta = new Map(); for (const [k, v] of vpDecls) if (baseDecls.get(k) !== v) delta.set(k, v); for (const [k] of baseDecls) if (!vpDecls.has(k)) delta.set(k, resetValue(k)); diff --git a/compiler/src/generate/tailwind.ts b/compiler/src/generate/tailwind.ts index 23381b4..a300bed 100644 --- a/compiler/src/generate/tailwind.ts +++ b/compiler/src/generate/tailwind.ts @@ -762,6 +762,57 @@ function sourceFluidLengthSuffix(suffix: string): boolean { return /(%|vw|vh|svw|lvw|dvw|svh|lvh|dvh|fr|min-content|max-content|fit-content)/.test(inner); } +// A FIXED, definite authored length suffix on a `w-`/`h-` utility: an arbitrary `[]`, the +// `px` token (1px), or a numeric spacing-scale token (`24`, `2.5` → fixed rem). Unlike +// sourceFluidLengthSuffix these do NOT re-derive from the container, so the source-intent pass must +// re-emit them as the CAPTURED computed px (the clone's root font-size may differ from the source's, so +// echoing `h-[4rem]` verbatim would mis-size), and only where geometry corroborates the resolved box. +function sourceFixedLengthSuffix(suffix: string): boolean { + const inner = sourceArbitraryInner(suffix); + if (inner !== null) { + const v = inner.trim().toLowerCase(); + if (/^var\(/.test(v)) return false; // handled by the var-length path + if (/%|vw|vh|vmin|vmax|svw|lvw|dvw|svh|lvh|dvh|\bfr\b|min-content|max-content|fit-content/.test(v)) return false; + return /(?:^|[\s(*/+-])[\d.]+(?:px|rem|em|cm|mm|in|pt|pc|ex|ch|q)\b/.test(v); + } + if (suffix === "px") return true; + return /^\d+(?:\.\d+)?$/.test(suffix); +} + +// A `w-`/`h-` utility carrying a fixed authored length (as recognised by sourceFixedLengthSuffix). +function isFixedLengthUtil(util: string): boolean { + const m = /^([wh])-(.+)$/.exec(util); + return !!m && sourceFixedLengthSuffix(m[2]!); +} + +// The captured computed px on this axis is definite and matches the measured bbox — i.e. the authored +// fixed length resolved to the box the capture recorded. Geometry corroboration for a fixed w/h util. +function fixedLengthResolvesToBox(node: IRNode, axis: "w" | "h", vp: number): boolean { + const cs = node.computedByVp[vp]; const b = node.bboxByVp[vp]; + if (!cs || !b) return false; + const val = axis === "h" ? cs.height : cs.width; + const ext = axis === "h" ? b.height : b.width; + if (!val || val === "auto" || !/px$/.test(val)) return false; + if (!(ext > 0)) return false; + return Math.abs(pf(val) - ext) <= Math.max(1.5, 0.01 * ext); +} + +// Rewrite a fixed-length `w-`/`h-` source utility to the node's CAPTURED computed px at a viewport, +// preserving any variant prefix. `h-[4rem]` → `h-[60px]` (the source resolved 4rem against a 15px root; +// the clone's root may differ, so bake the measured px). Returns the utility unchanged when it is not a +// fixed-length util or the computed px is unavailable. +function fixedLengthUtilAsPx(util: string, node: IRNode, vp: number): string { + const m = VARIANT_PREFIX.exec(util); + const prefix = m ? m[1]! : ""; + const core = m ? m[2]! : util; + const sm = /^([wh])-(.+)$/.exec(core); + if (!sm || !sourceFixedLengthSuffix(sm[2]!)) return util; + const val = sm[1] === "h" ? node.computedByVp[vp]?.height : node.computedByVp[vp]?.width; + if (!val || !/px$/.test(val)) return util; + const px = Math.round(pf(val) * 100) / 100; + return `${prefix}${sm[1]}-[${px}px]`; +} + function sourceAspectUtility(core: string): string | null { let m = /^aspect-(\d+)\/(\d+)-box$/.exec(core); if (m) return m[1] === m[2] ? "aspect-square" : `aspect-[${m[1]}/${m[2]}]`; @@ -793,7 +844,7 @@ function sourceAxisForCore(core0: string): { axis: SourceAxis; utility: string } const maxH = /^max-h-(.+)$/.exec(core); if (maxH && sourceFluidLengthSuffix(maxH[1]!)) return { axis: "max-h", utility: core }; const size = /^([wh])-(.+)$/.exec(core); - if (size && sourceFluidLengthSuffix(size[2]!)) return { axis: size[1] as SourceAxis, utility: core }; + if (size && (sourceFluidLengthSuffix(size[2]!) || sourceFixedLengthSuffix(size[2]!))) return { axis: size[1] as SourceAxis, utility: core }; return null; } @@ -957,6 +1008,12 @@ function sourceAxisCompatible(node: IRNode, parent: IRNode | undefined, axis: So if (axis === "h" && v === "h-full") return !!s?.hFill; if (axis === "h" && v === "h-auto") return !!s?.hAuto; if (axis === "h" && v && sourceVarName(v)) return /px$/.test(node.computedByVp[vp]?.height || ""); + // A FIXED authored length (`h-[4rem]`, `w-24`, `h-px`) — the source-intent pass will re-emit it as + // the captured computed px. Compatible when that px is definite and equals the measured bbox extent + // at this viewport (the authored length actually resolved to the captured box). This recovers a + // banded authored fixed height/width the sizing probe dropped through the fill↔content cycle + // (h-full) or the content/fill drop (auto), without depending on the clone's root font-size. + if (v && isFixedLengthUtil(v)) return fixedLengthResolvesToBox(node, axis, vp); return false; }); } @@ -1023,13 +1080,18 @@ function sourceIntentUtilities(node: IRNode, parent: IRNode | undefined, viewpor continue; } if (!sourceAxisCompatible(node, parent, axis, byVp, viewports)) continue; - const base = byVp.get(canonical)!; + // A fixed-length w/h axis re-emits the CAPTURED computed px per band (root-font-size independent), + // not the source rem token; every other axis keeps its authored utility verbatim. + const asEmit = (vp: number): string => + (axis === "w" || axis === "h") && isFixedLengthUtil(byVp.get(vp)!) + ? fixedLengthUtilAsPx(byVp.get(vp)!, node, vp) : byVp.get(vp)!; + const base = asEmit(canonical); axes.add(axis); if (axis === "aspect") axes.add("grid-rows"); utilities.push(base); for (const b of bands) { if (!b.media) continue; - const v = byVp.get(b.vp)!; + const v = asEmit(b.vp); if (v !== base) utilities.push(prefixFor(b.media) + v); } css.push(...sourceIntentVarCss(node, axis, byVp, viewports, canonical)); diff --git a/compiler/test/bandingFixes.test.ts b/compiler/test/bandingFixes.test.ts index 1578ab8..8689db5 100644 --- a/compiler/test/bandingFixes.test.ts +++ b/compiler/test/bandingFixes.test.ts @@ -97,3 +97,42 @@ describe("buildTailwind partial-coverage subgrid intent (C1)", () => { assert.ok(/grid-rows-subgrid/.test(cls), `subgrid must survive the partial-intent path, got class: "${cls}"`); }); }); + +// FIX 1 (source-intent side) — an authored banded FIXED height (`h-[4rem] md:h-[6.25rem]`) must be +// recoverable through source intent even though sourceFluidLengthSuffix rejects fixed rem/px lengths. +// The recovered value is re-emitted as the CAPTURED computed px (root-font-size independent), and the +// generated `h-full`/`h-auto` on that axis is dropped in its favour. +describe("buildTailwind recovers authored banded fixed height as computed px (FIX 1)", () => { + // A per-vp node with distinct computed height + matching bbox on each axis, so geometry corroborates. + function fixedHNode(id: string, tag: string, hByVp: Record, srcClass: string): IRNode { + const computedByVp: Record = {}; + const bboxByVp: Record = {}; + const visibleByVp: Record = {}; + for (const vp of VPS) { + computedByVp[vp] = computed({ display: "flex", height: `${hByVp[vp]}px` }); + bboxByVp[vp] = { x: 0, y: 0, width: vp, height: hByVp[vp]! }; + visibleByVp[vp] = true; + } + const n: IRNode = { id, tag, attrs: {}, visibleByVp, bboxByVp, computedByVp, children: [] }; + n.srcClass = srcClass; + // Probe reads the fill↔content cycle (a grid/flex item whose child fills it): hAuto:false but + // hFill:true, so the generator would otherwise emit h-full. Source intent must still recover. + n.sizingByVp = Object.fromEntries(VPS.map((vp) => [vp, { wAuto: false, wFill: true, hAuto: false, hFill: true }])); + return n; + } + + it("emits banded computed px (h-[60px]/h-[100px]) and no h-full", () => { + // Source authors `h-[4rem]` (base) and `md:h-[6.25rem]`; the source root is 15px so 4rem→60px @375 + // and 6.25rem→100px @1280. Emitted as px so the clone's root font-size can't mis-size it. + const el = fixedHNode("n1", "div", { 375: 60, 1280: 100 }, "flex h-[4rem] w-full md:h-[6.25rem]"); + const root = pvNode("n0", "body", { 375: {}, 1280: {} }, [el]); + const tw = buildTailwind(irWith(root), new Map()); + const cls = tw.classOf.get("n1") || ""; + assert.ok(!/\bh-full\b/.test(cls), `authored fixed height must not stay h-full, got class: "${cls}"`); + // The captured px must appear (as an arbitrary px value or a clean rem token equivalent). Accept the + // px form or its 16px-root rem equivalent (100px→6.25rem, 60px→3.75rem) since the pretty-printer may + // fold clean px back to rem — both resolve to the captured px at the clone's 16px root. + assert.ok(/h-\[100px\]|h-\[6\.25rem\]/.test(cls), `desktop height (100px/6.25rem@16root) must appear, got class: "${cls}"`); + assert.ok(/h-\[60px\]|h-\[3\.75rem\]/.test(cls), `mobile height (60px/3.75rem@16root) must appear, got class: "${cls}"`); + }); +}); diff --git a/compiler/test/css.test.ts b/compiler/test/css.test.ts index 294c97c..115fd86 100644 --- a/compiler/test/css.test.ts +++ b/compiler/test/css.test.ts @@ -985,6 +985,100 @@ describe("generateCss own-probe height trust never bakes a page-height wrapper ( }); }); +// FIX 1 — a banded AUTHORED fixed height (`h-[4rem] sm:h-[4.5rem] md:h-[6.25rem]`) on a grid/flex item +// whose fill child reproduces the box height (the fill↔content cycle: hFill:true) must NOT collapse to +// `height:100%`. The source authors a definite per-band height, geometry corroborates it (computed px == +// bbox at every vp), so the baked per-band px must survive instead of the probe's circular fill verdict. +describe("generateCss authored banded fixed height survives the fill cycle (FIX 1)", () => { + const cycle = (): RawSizing => ({ wAuto: false, wFill: true, hAuto: false, hFill: true }); + function tile(): IRNode { + // Inner filler: height:100% child (the fill side of the cycle). + const inner = xNode("n2", "span", Object.fromEntries(XVPS.map((vp) => [vp, { + cs: { display: "block", position: "static", height: "100%" }, + bbox: { x: 0, y: 0, width: 100, height: vp === 375 ? 60 : vp === 768 ? 93.75 : 100 }, + }]))); + const t = xNode("n1", "div", { + 375: { cs: { display: "flex", position: "static", height: "60px" }, bbox: { x: 0, y: 0, width: 100, height: 60 }, sizing: cycle() }, + 768: { cs: { display: "flex", position: "static", height: "93.75px" }, bbox: { x: 0, y: 0, width: 100, height: 93.75 }, sizing: cycle() }, + 1280: { cs: { display: "flex", position: "static", height: "100px" }, bbox: { x: 0, y: 0, width: 100, height: 100 }, sizing: cycle() }, + }, [inner]); + t.srcClass = "px-2 flex h-[4rem] w-full items-center justify-center sm:h-[4.5rem] md:h-[6.25rem]"; + // Grid parent that confers a definite height at every band, so isHeightFill/heightProbeFills would + // otherwise drop the tile to h-full. + return xNode("n0", "div", { + 375: { cs: { display: "grid", position: "static", height: "60px" }, bbox: { x: 0, y: 0, width: 375, height: 60 } }, + 768: { cs: { display: "grid", position: "static", height: "93.75px" }, bbox: { x: 0, y: 0, width: 768, height: 93.75 } }, + 1280: { cs: { display: "grid", position: "static", height: "100px" }, bbox: { x: 0, y: 0, width: 1280, height: 100 } }, + }, [t]); + } + + it("keeps the baked per-band px height and never emits height:100%", () => { + const css = generateCss(xIr(tile()), new Map()); + const all = allRulesX(css, "n1"); + assert.ok(!/height:100%/.test(all), `authored fixed-height tile must not collapse to h-full, got: ${all}`); + assert.ok(/height:60px/.test(all), `375 band height (60px) must survive, got: ${all}`); + assert.ok(/height:93\.75px/.test(all), `768 band height (93.75px) must survive, got: ${all}`); + assert.ok(/height:100px/.test(all), `desktop band height (100px) must survive, got: ${all}`); + }); +}); + +// FIX 3 — a testimonial-avatar wrapper authors a fixed square (`h-[2.5rem] w-[2.5rem] shrink-0`) whose +// img child fills it, so the sizing probe reads hAuto/wAuto:true and would drop BOTH dimensions, letting +// the box collapse to the img's intrinsic size. With geometry corroboration (computed px == bbox at +// every vp), the authored fixed width AND height must be emitted. +describe("generateCss authored fixed avatar size survives the content drop (FIX 3)", () => { + const drop = (): RawSizing => ({ wAuto: true, wFill: false, hAuto: true, hFill: false }); + function avatar(): IRNode { + const img = xNode("n2", "img", Object.fromEntries(XVPS.map((vp) => [vp, { + cs: { display: "block", position: "static", height: "100%", width: "100%" }, + bbox: { x: 0, y: 0, width: vp === 1280 ? 40 : 37.5, height: vp === 1280 ? 40 : 37.5 }, + }]))); + const a = xNode("n1", "div", { + 375: { cs: { display: "block", position: "static", height: "37.5px", width: "37.5px" }, bbox: { x: 0, y: 0, width: 37.5, height: 37.5 }, sizing: drop() }, + 768: { cs: { display: "block", position: "static", height: "37.5px", width: "37.5px" }, bbox: { x: 0, y: 0, width: 37.5, height: 37.5 }, sizing: drop() }, + 1280: { cs: { display: "block", position: "static", height: "40px", width: "40px" }, bbox: { x: 0, y: 0, width: 40, height: 40 }, sizing: drop() }, + }, [img]); + a.srcClass = "avatar-border-container h-[2.5rem] w-[2.5rem] shrink-0"; + return xNode("n0", "div", Object.fromEntries(XVPS.map((vp) => [vp, { cs: { display: "flex", position: "static" }, bbox: { x: 0, y: 0, width: vp, height: 40 } }])), [a]); + } + + it("emits both the authored fixed width and height per band", () => { + const css = generateCss(xIr(avatar()), new Map()); + const all = allRulesX(css, "n1"); + assert.ok(/width:37\.5px/.test(all), `narrow-band width (37.5px) must survive, got: ${all}`); + assert.ok(/height:37\.5px/.test(all), `narrow-band height (37.5px) must survive, got: ${all}`); + assert.ok(/width:40px/.test(all), `desktop width (40px) must survive, got: ${all}`); + assert.ok(/height:40px/.test(all), `desktop height (40px) must survive, got: ${all}`); + }); +}); + +// FIX 2 — aspectHeightLaw must account for the aspect-ratio↔max-height WIDTH transfer. At a +// max-height-clamped viewport, choosing a ratio also caps the width at ratio×maxH; a square ratio for a +// 1.66:1 box (w=698, h=maxH=420) satisfies the height-only check but would clamp the clone's width to +// 420. The law must reject that ratio (and fall back to explicit per-band dimensions) rather than emit a +// wrong aspect-square. +describe("generateCss aspectHeightLaw rejects width-clamping ratio transfer (FIX 2)", () => { + function photo(): IRNode { + // 375: genuinely square, unclamped (304.69²). 768: 1.66:1 but height clamped to max-h 420 (w=698). + // 1280: genuinely square, unclamped (372²). The 768 clamp must not license aspect-square. + // Single-row grid (grid-template-rows == content height) so singleFluidGridRow fires and the + // media/aspect pass runs. + const container = xNode("n1", "div", { + 375: { cs: { display: "grid", position: "static", maxHeight: "420px", aspectRatio: "auto", gridTemplateRows: "304.69px" }, bbox: { x: 0, y: 0, width: 304.69, height: 304.69 } }, + 768: { cs: { display: "grid", position: "static", maxHeight: "420px", aspectRatio: "auto", gridTemplateRows: "420px" }, bbox: { x: 0, y: 0, width: 697.69, height: 420 } }, + 1280: { cs: { display: "grid", position: "static", maxHeight: "none", aspectRatio: "auto", gridTemplateRows: "371.66px" }, bbox: { x: 0, y: 0, width: 371.66, height: 371.66 } }, + }); + return xNode("n0", "div", Object.fromEntries(XVPS.map((vp) => [vp, { cs: { display: "block", position: "static" }, bbox: { x: 0, y: 0, width: vp, height: 500 } }])), [container]); + } + + it("does not emit aspect-square for a box whose max-height clamp would shrink its width", () => { + const css = generateCss(xIr(photo()), new Map()); + const all = allRulesX(css, "n1"); + assert.ok(!/aspect-ratio:\s*1\s*\/\s*1/.test(all) && !/aspect-ratio:1\/1/.test(all), + `must not force aspect-square where the max-height transfer would clamp width, 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)", () => {