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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
994d2bc21e
commit
ec4e4397cc
@@ -882,7 +882,15 @@ function aspectHeightLaw(node: IRNode, viewports: number[]): Record<number, stri
|
|||||||
let chosen: { value: string; ratio: number } | undefined;
|
let chosen: { value: string; ratio: number } | undefined;
|
||||||
for (const r of ordered) {
|
for (const r of ordered) {
|
||||||
const predicted = Math.min(s.w / r.ratio, s.maxH);
|
const predicted = Math.min(s.w / r.ratio, s.maxH);
|
||||||
if (Math.abs(predicted - s.h) <= Math.max(1.5, 0.01 * s.h)) { chosen = r; break; }
|
if (Math.abs(predicted - s.h) > 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;
|
if (!chosen) return null;
|
||||||
out[s.vp] = chosen.value;
|
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 || "");
|
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-[<len>]`,
|
||||||
|
* `min-h-[<len>]`, or the token forms (`h-<n>`, `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-[<len>]`/`min-w-[<len>]`
|
||||||
|
* (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"]);
|
const ABSOLUTE_MEDIA_FILL_TAGS = new Set(["img", "picture", "video"]);
|
||||||
|
|
||||||
/** An absolutely-positioned media layer authored as a full-cover background: `position:absolute;
|
/** An absolutely-positioned media layer authored as a full-cover background: `position:absolute;
|
||||||
@@ -2352,6 +2454,7 @@ function declsForViewport(
|
|||||||
dropViewportMaxWidth = false,
|
dropViewportMaxWidth = false,
|
||||||
nowrapText = false,
|
nowrapText = false,
|
||||||
keepIdentityTransform = false,
|
keepIdentityTransform = false,
|
||||||
|
forceHeight = false,
|
||||||
): Map<string, string> {
|
): Map<string, string> {
|
||||||
const cs = node.computedByVp[vp];
|
const cs = node.computedByVp[vp];
|
||||||
const out = new Map<string, string>();
|
const out = new Map<string, string>();
|
||||||
@@ -2439,7 +2542,11 @@ function declsForViewport(
|
|||||||
// absolutely-positioned children). Without emitting it the block collapses to
|
// absolutely-positioned children). Without emitting it the block collapses to
|
||||||
// its content in the clone. Content-filled blocks stay auto so margin
|
// its content in the clone. Content-filled blocks stay auto so margin
|
||||||
// collapsing is preserved.
|
// 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];
|
const nb = node.bboxByVp[vp];
|
||||||
// Probe ground truth, emission twin of the heightFlows keep (see the hAuto/hFill guard there):
|
// 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
|
// 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<string, string>, includeN
|
|||||||
// parent, varies) OR the sizing probe measured that height:100% reproduces and auto does not
|
// 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).
|
// (heightProbeFills — direct ground truth, also catches constant fillers; inert pre-probe captures).
|
||||||
const sourceFixedSize = !isContents && sourceFixedSizeIntent(node);
|
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 mediaCover = !isContents && absoluteMediaCoversParent(node, parentNode, sampleVps);
|
||||||
const absHeightFill = !isContents && absoluteHeightFill(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));
|
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
|
// 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
|
// 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<string, string>, includeN
|
|||||||
const fillCap = !isContents ? fillsToCapWidth(node, parentNode, sampleVps) : null;
|
const fillCap = !isContents ? fillsToCapWidth(node, parentNode, sampleVps) : null;
|
||||||
// A width that leaves interior free space positions its children (auto margins / justify) — it's
|
// 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).
|
// 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);
|
const lockWidth = !isContents && !fillCap && (hasInteriorFreeSpaceX(node, sampleVps) || buttonFixedWidth);
|
||||||
// Ground-truth sizing probe is the primary signal after authored capped fills:
|
// 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
|
// `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<string, string>, includeN
|
|||||||
const mixedFill = (!lockWidth && !isContents && !percentVp) ? mixedFillByVp(node, parentNode, sampleVps) : null;
|
const mixedFill = (!lockWidth && !isContents && !percentVp) ? mixedFillByVp(node, parentNode, sampleVps) : null;
|
||||||
const widthPlan: WidthPlan =
|
const widthPlan: WidthPlan =
|
||||||
fillCap ? { kind: "fillcap", cap: fillCap }
|
fillCap ? { kind: "fillcap", cap: fillCap }
|
||||||
|
: sourceFixedWidth ? { kind: "fixed" }
|
||||||
: sourceFixedSize ? inferred.plan
|
: sourceFixedSize ? inferred.plan
|
||||||
: circularSlide ? { kind: "fixed" }
|
: circularSlide ? { kind: "fixed" }
|
||||||
: fullWidthSlide ? { kind: "basisFull" }
|
: fullWidthSlide ? { kind: "basisFull" }
|
||||||
@@ -3485,7 +3599,7 @@ export function collectNodeRules(ir: IR, assetMap: Map<string, string>, includeN
|
|||||||
// gated to a breakpoint owns the transform only where it runs, but freezes a mid-scroll offset
|
// 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).
|
// at the OTHER bands — suppress those frozen deltas at every width).
|
||||||
const animOwned = animOwnedProps(node, [baseVp, ...bands.map((b) => b.vp)]);
|
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: [] };
|
const nr: NodeRule = { base: baseDecls, bands: [] };
|
||||||
|
|
||||||
// Per-band overrides (delta vs base), using the parent's value AT THAT viewport.
|
// 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<string, string>, includeN
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const centeredVp = stableCenter || (layoutParent ? centeredAtVp(node, layoutParent, b.vp) : false);
|
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<string, string>();
|
const delta = new Map<string, string>();
|
||||||
for (const [k, v] of vpDecls) if (baseDecls.get(k) !== v) delta.set(k, v);
|
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));
|
for (const [k] of baseDecls) if (!vpDecls.has(k)) delta.set(k, resetValue(k));
|
||||||
|
|||||||
@@ -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);
|
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 `[<px|rem|em|…>]`, 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 {
|
function sourceAspectUtility(core: string): string | null {
|
||||||
let m = /^aspect-(\d+)\/(\d+)-box$/.exec(core);
|
let m = /^aspect-(\d+)\/(\d+)-box$/.exec(core);
|
||||||
if (m) return m[1] === m[2] ? "aspect-square" : `aspect-[${m[1]}/${m[2]}]`;
|
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);
|
const maxH = /^max-h-(.+)$/.exec(core);
|
||||||
if (maxH && sourceFluidLengthSuffix(maxH[1]!)) return { axis: "max-h", utility: core };
|
if (maxH && sourceFluidLengthSuffix(maxH[1]!)) return { axis: "max-h", utility: core };
|
||||||
const size = /^([wh])-(.+)$/.exec(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;
|
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-full") return !!s?.hFill;
|
||||||
if (axis === "h" && v === "h-auto") return !!s?.hAuto;
|
if (axis === "h" && v === "h-auto") return !!s?.hAuto;
|
||||||
if (axis === "h" && v && sourceVarName(v)) return /px$/.test(node.computedByVp[vp]?.height || "");
|
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;
|
return false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1023,13 +1080,18 @@ function sourceIntentUtilities(node: IRNode, parent: IRNode | undefined, viewpor
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!sourceAxisCompatible(node, parent, axis, byVp, viewports)) 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);
|
axes.add(axis);
|
||||||
if (axis === "aspect") axes.add("grid-rows");
|
if (axis === "aspect") axes.add("grid-rows");
|
||||||
utilities.push(base);
|
utilities.push(base);
|
||||||
for (const b of bands) {
|
for (const b of bands) {
|
||||||
if (!b.media) continue;
|
if (!b.media) continue;
|
||||||
const v = byVp.get(b.vp)!;
|
const v = asEmit(b.vp);
|
||||||
if (v !== base) utilities.push(prefixFor(b.media) + v);
|
if (v !== base) utilities.push(prefixFor(b.media) + v);
|
||||||
}
|
}
|
||||||
css.push(...sourceIntentVarCss(node, axis, byVp, viewports, canonical));
|
css.push(...sourceIntentVarCss(node, axis, byVp, viewports, canonical));
|
||||||
|
|||||||
@@ -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}"`);
|
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<number, number>, srcClass: string): IRNode {
|
||||||
|
const computedByVp: Record<number, StyleMap> = {};
|
||||||
|
const bboxByVp: Record<number, BBox> = {};
|
||||||
|
const visibleByVp: Record<number, boolean> = {};
|
||||||
|
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}"`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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
|
// 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).
|
// 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)", () => {
|
describe("collectNodeRules never drops a subgrid rows template (C1)", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user