Files
ditto.site/compiler/src/generate/css.ts
T
Samraaj BathandClaude Fable 5 d5a713d31f Honor sizing-probe verdicts in height emission; pin lottie mount heights
- heightFlows() bails when the capture probe proved a height authored-
  explicit (hAuto=false, hFill=false), so circular parent/child flow
  reasoning can no longer drop 100vh heroes to 0px; the probe verdict now
  vetoes the drop through both OR'd paths at the call site
- Space-distributing flex columns (space-between/around/evenly, center,
  flex-end) disqualify the content-driven read - the box height is load-
  bearing for the distribution
- Lottie mounts pin their captured per-viewport height like other replaced
  content, so aspect-fit runtime svgs can't inflate past the captured box
- DittoLottie forwards the placeholder's data-cid onto the runtime svg/
  canvas so the media stays addressable after the swap

240 tests pass (9 new), typecheck clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 23:43:36 -07:00

3497 lines
227 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { IR, IRNode, StyleMap } from "../normalize/ir.js";
import { isTextChild } from "../normalize/ir.js";
import type { TokenResolver } from "../infer/tokens.js";
/** Finalize a decls map: reference design tokens (var(--…)) where the value is tokenized
* — fidelity-neutral (tokens hold the literal value). */
function finalizeDecls(m: Map<string, string>, resolver?: TokenResolver): Map<string, string> {
if (resolver) for (const [k, v] of m) { const t = resolver(k, v); if (t) m.set(k, t); }
return m;
}
/**
* The fidelity engine. Emits per-node CSS that replays the captured computed
* styles, with per-viewport overrides under media-query bands. Combined with the
* UA reset (see RESET_CSS) so that "skipping a default value" reliably falls back
* to the CSS initial value rather than a browser UA default.
*
* Geometry is exact because computed width/height are reported per the element's
* own box-sizing (verified empirically), so replaying box-sizing + width + height
* + padding + border reconstructs the same border-box.
*/
// Reset that neutralizes UA defaults to CSS initial values, so default-skipping
// in the generator is correct. box-sizing defaults to border-box (matches the
// overwhelming majority of modern sites; content-box is emitted per-node).
export const RESET_CSS = `*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; border: 0 solid currentColor; }
html { -webkit-text-size-adjust: 100%; line-height: normal; }
body { margin: 0; }
ul, ol, menu { list-style: none; }
a { color: inherit; text-decoration: none; }
button, input, select, textarea, optgroup { font: inherit; color: inherit; background: transparent; border: 0; padding: 0; margin: 0; text-align: inherit; letter-spacing: inherit; }
button { cursor: pointer; }
table { border-collapse: separate; border-spacing: 0; }
img, picture, video, canvas, svg { display: inline-block; }
h1, h2, h3, h4, h5, h6, p, figure, blockquote, dl, dd { margin: 0; }
/* Neutralize UA text defaults to inherit so the generator's default-skipping is
correct: a property equal to its inherited value is simply not emitted. */
h1, h2, h3, h4, h5, h6 { font-size: inherit; font-weight: inherit; }
b, strong, th { font-weight: inherit; }
em, i, cite, var, dfn, address, q { font-style: inherit; }
small, big, sub, sup { font-size: inherit; }
sub, sup { vertical-align: inherit; }
code, kbd, samp, pre, tt, var { font-family: inherit; font-size: inherit; }
mark { background-color: transparent; color: inherit; }
abbr[title] { text-decoration: none; }
fieldset, legend { min-width: 0; }
`;
const CAPTURE_VIEWPORT_HEIGHTS: Record<number, number> = {
375: 812,
480: 854,
640: 960,
768: 1024,
1024: 768,
1280: 800,
1536: 864,
1920: 1080,
2560: 1440,
};
function isViewportHeight(value: string, width: number): boolean {
if (!value.endsWith("px")) return false;
const expected = CAPTURE_VIEWPORT_HEIGHTS[width] ?? Math.round(width * 0.66);
return Math.abs(pf(value) - expected) <= 1;
}
// Inherited properties — skipped when they equal the parent's value (inheritance
// reproduces them), only emitted on the node that introduces a change.
const INHERITED = new Set([
"color", "fontFamily", "fontSize", "fontWeight", "fontStyle", "lineHeight",
"letterSpacing", "wordSpacing", "textAlign", "textTransform", "whiteSpace",
"wordBreak", "overflowWrap", "textIndent", "fontVariantCaps", "fontFeatureSettings",
"listStyleType", "listStylePosition", "writingMode", "direction", "cursor",
"textShadow", "visibility", "textDecorationColor", "webkitTextStroke",
"webkitTextFillColor",
]);
// Properties handled in dedicated blocks (not the generic loop).
const SPECIAL = new Set([
"width", "height", "minHeight", "boxSizing",
"borderTopWidth", "borderRightWidth", "borderBottomWidth", "borderLeftWidth",
"borderTopStyle", "borderRightStyle", "borderBottomStyle", "borderLeftStyle",
"borderTopColor", "borderRightColor", "borderBottomColor", "borderLeftColor",
]);
// Generic properties to emit, with the value treated as a removable default.
const GENERIC: Array<{ prop: string; def: string | string[] }> = [
{ prop: "display", def: "__never__" }, // always emit display
{ prop: "position", def: "static" },
{ prop: "top", def: "auto" }, { prop: "right", def: "auto" },
{ prop: "bottom", def: "auto" }, { prop: "left", def: "auto" },
{ prop: "float", def: "none" }, { prop: "clear", def: "none" },
{ prop: "zIndex", def: "auto" },
// visibility is INHERITED, so the parent-equality skip keeps descendants of a
// hidden subtree clean; without this entry a node hidden at the canonical
// viewport could never emit `visibility:hidden` at all (the only other path is
// per-band and gated on being shown at base).
{ prop: "visibility", def: "visible" },
{ prop: "opacity", def: "1" }, { prop: "isolation", def: "auto" },
{ prop: "mixBlendMode", def: "normal" },
{ prop: "minWidth", def: ["0px", "auto"] }, { prop: "maxWidth", def: "none" },
{ prop: "maxHeight", def: "none" },
{ prop: "marginTop", def: "0px" }, { prop: "marginRight", def: ["0px", "auto"] },
{ prop: "marginBottom", def: "0px" }, { prop: "marginLeft", def: ["0px", "auto"] },
{ prop: "paddingTop", def: "0px" }, { prop: "paddingRight", def: "0px" },
{ prop: "paddingBottom", def: "0px" }, { prop: "paddingLeft", def: "0px" },
{ prop: "borderTopLeftRadius", def: "0px" }, { prop: "borderTopRightRadius", def: "0px" },
{ prop: "borderBottomRightRadius", def: "0px" }, { prop: "borderBottomLeftRadius", def: "0px" },
{ prop: "flexDirection", def: "row" }, { prop: "flexWrap", def: "nowrap" },
{ prop: "justifyContent", def: "normal" }, { prop: "alignItems", def: "normal" },
{ prop: "alignContent", def: "normal" }, { prop: "alignSelf", def: "auto" },
{ prop: "flexGrow", def: "0" }, { prop: "flexShrink", def: "1" },
{ prop: "flexBasis", def: "auto" }, { prop: "order", def: "0" },
{ prop: "gap", def: ["normal", "0px"] }, { prop: "rowGap", def: ["normal", "0px"] },
{ prop: "columnGap", def: ["normal", "0px"] },
{ prop: "gridTemplateColumns", def: "none" }, { prop: "gridTemplateRows", def: "none" },
{ prop: "gridTemplateAreas", def: "none" },
{ prop: "gridAutoFlow", def: "row" }, { prop: "gridAutoRows", def: "auto" },
{ prop: "gridAutoColumns", def: "auto" }, { prop: "justifyItems", def: ["normal", "legacy"] },
{ prop: "gridColumnStart", def: "auto" }, { prop: "gridColumnEnd", def: "auto" },
{ prop: "gridRowStart", def: "auto" }, { prop: "gridRowEnd", def: "auto" },
{ prop: "overflowX", def: "visible" }, { prop: "overflowY", def: "visible" },
{ prop: "objectFit", def: "fill" }, { prop: "objectPosition", def: "50% 50%" },
{ prop: "aspectRatio", def: "auto" }, { prop: "verticalAlign", def: "baseline" },
// Inherited properties: rely solely on the parent-equality skip (never an
// absolute default), otherwise e.g. text-align:left looks like a "default" and
// gets dropped, letting the node wrongly inherit a parent's center.
{ prop: "color", def: "__never__" },
{ prop: "fontFamily", def: "__never__" }, { prop: "fontSize", def: "__never__" },
{ prop: "fontWeight", def: "__never__" }, { prop: "fontStyle", def: "__never__" },
{ prop: "lineHeight", def: "__never__" }, { prop: "letterSpacing", def: "__never__" },
{ prop: "wordSpacing", def: "__never__" }, { prop: "textAlign", def: "__never__" },
{ prop: "textTransform", def: "__never__" }, { prop: "textDecorationLine", def: "none" },
{ prop: "textDecorationStyle", def: "solid" },
{ prop: "whiteSpace", def: "__never__" }, { prop: "wordBreak", def: "__never__" },
{ prop: "overflowWrap", def: "__never__" }, { prop: "textIndent", def: "__never__" },
{ prop: "textShadow", def: "__never__" }, { prop: "fontVariantCaps", def: "__never__" },
{ prop: "fontFeatureSettings", def: "__never__" }, { prop: "listStyleType", def: "__never_list__" },
{ prop: "listStylePosition", def: "__never__" }, { prop: "writingMode", def: "__never__" },
{ prop: "direction", def: "__never__" },
{ prop: "backgroundColor", def: ["rgba(0, 0, 0, 0)", "transparent"] },
{ prop: "backgroundImage", def: "none" }, { prop: "backgroundSize", def: "auto" },
{ prop: "backgroundPosition", def: ["0% 0%", "0px 0px"] }, { prop: "backgroundRepeat", def: "repeat" },
{ prop: "backgroundClip", def: "border-box" }, { prop: "backgroundOrigin", def: "padding-box" },
{ prop: "backgroundAttachment", def: "scroll" }, { prop: "backgroundBlendMode", def: "normal" },
{ prop: "boxShadow", def: "none" }, { prop: "filter", def: "none" },
{ prop: "backdropFilter", def: "none" }, { prop: "transform", def: "none" },
{ prop: "translate", def: "none" }, { prop: "rotate", def: "none" }, { prop: "scale", def: "none" },
{ prop: "transformOrigin", def: "__skip_if_no_transform__" }, { prop: "clipPath", def: "none" },
{ prop: "maskImage", def: "none" }, { prop: "webkitBackgroundClip", def: ["border-box", "initial"] },
{ prop: "webkitTextFillColor", def: "__skip__" }, { prop: "webkitTextStroke", def: "__stroke__" },
{ prop: "animationName", def: "none" }, { prop: "animationDuration", def: "0s" },
{ prop: "animationTimingFunction", def: "ease" }, { prop: "animationDelay", def: "0s" },
{ prop: "animationIterationCount", def: "1" }, { prop: "animationDirection", def: "normal" },
{ prop: "animationFillMode", def: "none" },
{ prop: "cursor", def: "auto" }, { prop: "pointerEvents", def: "auto" },
{ prop: "tableLayout", def: "auto" }, { prop: "borderCollapse", def: "separate" },
{ prop: "borderSpacing", def: ["0px", "0px 0px"] },
];
const REPLACED = new Set(["img", "svg", "video", "canvas", "picture", "iframe", "input", "textarea", "select", "hr", "object", "embed"]);
const ANIMATION_PROPS = new Set(["animationName", "animationDuration", "animationTimingFunction", "animationDelay", "animationIterationCount", "animationDirection", "animationFillMode"]);
function kebab(prop: string): string {
if (prop.startsWith("webkit")) return "-webkit-" + kebab(prop[6]!.toLowerCase() + prop.slice(7));
return prop.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase());
}
function isDefault(def: string | string[], value: string): boolean {
return Array.isArray(def) ? def.includes(value) : def === value;
}
export type Band = { vp: number; media: string | null };
export function computeBands(viewports: number[], canonical: number): Band[] {
const sorted = [...viewports].sort((a, b) => a - b);
const mids: number[] = [];
for (let i = 0; i < sorted.length - 1; i++) mids.push(Math.floor((sorted[i]! + sorted[i + 1]!) / 2));
return sorted.map((vp, i) => {
if (vp === canonical) return { vp, media: null };
const left = i === 0 ? 0 : mids[i - 1]! + 1;
const right = i === sorted.length - 1 ? Infinity : mids[i]!;
let media: string;
if (left <= 0) media = `@media (max-width: ${right}px)`;
else if (right === Infinity) media = `@media (min-width: ${left}px)`;
else media = `@media (min-width: ${left}px) and (max-width: ${right}px)`;
return { vp, media };
});
}
/** Rewrite url(...) tokens in a CSS value to local asset paths; unknown remote
* urls become a transparent placeholder so the generated app never 404s or
* points back to the origin (rubric Gate 2). */
const TRANSPARENT_GIF = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
function rewriteUrls(value: string, assetMap: Map<string, string>): string {
return value.replace(/url\(\s*(['"]?)([^'")]+)\1\s*\)/gi, (_full, _q, u: string) => {
if (u.startsWith("data:")) return `url(${u})`;
const local = assetMap.get(u);
if (local) return `url("${local}")`;
return `url("${TRANSPARENT_GIF}")`;
});
}
/** A fluid full-bleed element: its rendered box spans the full viewport width at EVERY
* captured viewport (a fixed-px element can't equal 375 AND 1280 AND 1920) and sits at
* the left edge. Such elements were authored fluid (width:100%/auto, or an absolute bar
* pinned `left:0;right:0`); the capture only ever saw the resolved px, so emitting that
* px locks the clone to the captured widths and it stops filling a wider window (the
* "page isn't full-width" bug — body, full-bleed sections, sticky nav bars, full-screen
* background layers). Dropping the px (→ width:auto, or insets-determined for the pinned
* absolute case) restores the fluidity while still resolving to the identical px at every
* captured viewport, so the layout gate (measured at those exact widths) is unmoved.
* Three guards keep this safe — only TRUE full-bleed converts:
* - in-flow block-level box (block/flow-root/list-item/flex/grid), not a flex/grid item;
* - out-of-flow (absolute/fixed) box pinned to BOTH horizontal edges (left≈0 && right≈0);
* - NO horizontal margins / positional offsets: a negative-margin "breakout" row
* (width:1280;margin-left:-640;left:320 — a full-bleed scroller inside a padded
* container) spans the viewport via offsets calibrated to its explicit width, so that
* width is load-bearing and must be kept. */
function isFluidFullBleed(node: IRNode, parentNode: IRNode | undefined, viewports: number[]): boolean {
// Replaced elements (img/svg/video/canvas/iframe…) and opaque custom elements size to
// their INTRINSIC dimensions under width:auto, not to the container — a full-bleed <svg>
// would render at its viewBox width at every viewport. Their captured px is load-bearing.
if (REPLACED.has(node.tag) || node.tag.includes("-")) return false;
const nearZero = (v: string | undefined): boolean => v != null && v !== "auto" && Math.abs(parseFloat(v)) <= 1.5;
const zeroOrAuto = (v: string | undefined): boolean => v == null || v === "auto" || Math.abs(parseFloat(v)) <= 1.5;
let samples = 0;
for (const vp of viewports) {
const cs = node.computedByVp[vp];
const bb = node.bboxByVp[vp];
if (!cs || !bb) continue;
const disp = cs.display || "";
const blockish = disp === "block" || disp === "flow-root" || disp === "list-item" || disp === "flex" || disp === "grid";
if (!blockish) return false;
// Horizontal margins make the width load-bearing (negative-margin breakout) — never drop it.
if (!nearZero(cs.marginLeft) || !nearZero(cs.marginRight)) return false;
const pos = cs.position || "static";
if (pos === "absolute" || pos === "fixed") {
if (!(nearZero(cs.left) && nearZero(cs.right))) return false; // width is real unless both insets pin it
} else if (pos === "sticky") {
return false;
} else {
if (!(zeroOrAuto(cs.left) && zeroOrAuto(cs.right))) return false; // positioned breakout: width is real
const pdisp = parentNode?.computedByVp[vp]?.display || "";
if (/flex|grid/.test(pdisp)) return false; // flex/grid item: auto width ≠ fill
}
if (Math.abs(bb.x) > 1.5) return false; // starts at the left edge
if (Math.abs(bb.width - vp) > 1.5) return false; // spans the full viewport
samples++;
}
return samples >= 2; // proven fluid only if it tracked ≥2 distinct viewport widths
}
/** A full-bleed flex/grid ITEM. Its box spans the full viewport at every captured width and
* sits at the left edge, but its containing block is a column flex / grid that does NOT
* stretch it on the cross axis (e.g. a `flex-direction:column; align-items:center` <main>).
* There, `width:auto` shrinks the item to its content instead of filling — so isFluidFullBleed
* (which would drop the width) correctly refuses it. Such an item was authored `width:100%`
* (or align-self:stretch); replaying the resolved px locks it to the capture width and leaves
* a dead gutter on wider windows (framer's full-bleed sections under a centered column <main>).
* Emitting `width:100%` restores the fluidity and still resolves to the identical px at every
* captured viewport, so the layout gate is unmoved. Row flex is excluded (a 100% item would
* overflow its siblings); only a box that already fills the viewport at ≥2 widths, starts at
* x≈0, and has no horizontal margins qualifies. */
function isFluidFillItem(node: IRNode, layoutParent: IRNode | undefined, viewports: number[]): boolean {
if (REPLACED.has(node.tag) || node.tag.includes("-")) return false;
if (!layoutParent) return false;
const nearZero = (v: string | undefined): boolean => v != null && v !== "auto" && Math.abs(parseFloat(v)) <= 1.5;
let samples = 0;
for (const vp of viewports) {
const cs = node.computedByVp[vp];
const bb = node.bboxByVp[vp];
const pcs = layoutParent.computedByVp[vp];
if (!cs || !bb || !pcs) continue;
const pdisp = pcs.display || "";
const columnFlex = /flex/.test(pdisp) && /column/.test(pcs.flexDirection || "row");
const isGrid = /grid/.test(pdisp);
if (!columnFlex && !isGrid) return false; // only non-stretching cross-axis contexts
if (!nearZero(cs.marginLeft) || !nearZero(cs.marginRight)) return false;
const pos = cs.position || "static";
if (pos === "absolute" || pos === "fixed") return false; // pinned boxes are isFluidFullBleed's job
if (Math.abs(bb.x) > 1.5) return false; // starts at the left edge
if (Math.abs(bb.width - vp) > 1.5) return false; // spans the full viewport
samples++;
}
return samples >= 2;
}
/** How a node's width is emitted. The capture only ever saw a node's *resolved px* at the
* four sampled widths, so the default ("fixed") replays that px under each media band — which
* is exact AT those widths but frozen between/beyond them (the "stairstep" + off-centre-on-a-
* wide-monitor bugs). When the per-viewport samples prove the width was authored *relative*
* to its container, emit the relative form instead: it resolves to the IDENTICAL px at every
* captured viewport (so gates 06, measured only at those widths, are unmoved) yet scales
* fluidly everywhere else — the same generalisation `isFluidFullBleed` already applies to
* full-viewport elements, extended to "fills/centres within its container". */
export type WidthPlan = { kind: "fixed" } | { kind: "auto" } | { kind: "percent"; pct: string } | { kind: "percentVp"; pctByVp: Record<number, string> } | { kind: "flexfill" } | { kind: "fill" } | { kind: "fillcap"; cap: string } | { kind: "basis"; px: string } | { kind: "basisFull" };
const PLAN_FIXED: WidthPlan = { kind: "fixed" };
const pf = (v: string | undefined): number => { const n = parseFloat(v ?? ""); return Number.isFinite(n) ? n : 0; };
// Snap sub-pixel geometry: integer when within 0.1px (measurement jitter), else at most 1 decimal
// — matching the Tailwind arbitrary-length rounding (tailwind.ts:snapLen) so CSS and utility output
// agree and frozen sub-pixel noise (204.797px) never ships. Transforms/border widths format elsewhere.
const fmtPx = (n: number): string => `${(Math.abs(n - Math.round(n)) < 0.1 ? Math.round(n) : Math.round(n * 10) / 10)}px`;
const viewportHeightFor = (vp: number): number => CAPTURE_VIEWPORT_HEIGHTS[vp] ?? Math.round(vp * 0.66);
type GeometryPlan = {
heightByVp?: Record<number, string>;
aspectByVp?: Record<number, string>;
leftByVp?: Record<number, string>;
topByVp?: Record<number, string>;
};
const GEOMETRY_NONE: GeometryPlan = {};
function cleanPct(n: number): number {
return Math.round(n * 2) / 2;
}
function snapAspectRatio(ratio: number): { value: string; ratio: number } | null {
const candidates: Array<[number, number]> = [
[1, 1], [4, 3], [3, 2], [8, 5], [16, 9], [5, 4], [2, 1],
[3, 4], [2, 3], [9, 16],
];
for (const [w, h] of candidates) {
const r = w / h;
if (Math.abs(ratio - r) <= 0.025) return { value: `${w} / ${h}`, ratio: r };
}
const rounded = Math.round(ratio * 1000) / 1000;
if (!(rounded > 0.1 && rounded < 10)) return null;
return { value: String(rounded), ratio: rounded };
}
/** Content-box width of `node`'s containing block at `vp`: the parent's content box for an
* in-flow box, or the viewport for the root (`<body>`, whose containing block is `<html>`/
* the initial containing block). Returns null when not measurable. */
function containingWidthAt(node: IRNode, parentNode: IRNode | undefined, vp: number): number | null {
if (!parentNode) return vp; // root: % / auto resolve against the viewport width
const pcs = parentNode.computedByVp[vp]; const pb = parentNode.bboxByVp[vp];
if (!pcs || !pb) return null;
const w = pb.width - pf(pcs.paddingLeft) - pf(pcs.paddingRight) - pf(pcs.borderLeftWidth) - pf(pcs.borderRightWidth);
return w > 0 ? w : null;
}
/** Decide a node's width treatment from its per-viewport samples. Three relative forms, each
* proven against ALL sampled viewports so it reproduces the captured px exactly:
* (a) centred max-width container (`max-width:W; margin:0 auto`) → width:auto + auto margins
* at every width, so it stays centred on any monitor instead of freezing at the widest
* captured band's margins;
* (b) a block that fills its container's content width at every width → width:auto (the
* block default) so it tracks a fluid ancestor instead of a baked px;
* (c) a stable fractional width (`width:N%`) → emit the percentage.
* Conservative by construction: only fires for in-flow, block-level, non-(flex/grid-item)
* boxes, and (b)/(c) require the px to actually VARY across viewports (an authored-fixed px
* cannot track a varying container), so a genuinely fixed width is never converted. */
function planWidth(node: IRNode, parentNode: IRNode | undefined, viewports: number[]): { plan: WidthPlan; centerAlways: boolean } {
const none = { plan: PLAN_FIXED, centerAlways: false };
// Replaced/custom elements size to intrinsic dimensions under auto/%, not the container.
if (REPLACED.has(node.tag) || node.tag.includes("-")) return none;
type Sample = { w: number; bw: number; cw: number; ml: number; mr: number; maxW: number | null; gapL: number; gapR: number; borderBox: boolean };
const samples: Sample[] = [];
for (const vp of viewports) {
const cs = node.computedByVp[vp]; const nb = node.bboxByVp[vp];
if (!cs || !nb) continue;
// Block-LEVEL, in-flow only: margin-auto centring and auto/100% fill apply to these; an
// inline / inline-block / flex-item box is positioned differently, so bail conservatively.
if (!/^(block|flow-root|list-item|flex|grid)$/.test(cs.display || "")) return none;
const pos = cs.position || "static";
if (pos !== "static" && pos !== "relative") return none;
if ((cs.float || "none") !== "none") return none; // float + width:auto shrink-wraps, not fills
const pdisp = parentNode?.computedByVp[vp]?.display || "";
if (/(?:^|-)(?:flex|grid)$/.test(pdisp)) return none; // flex/grid item — parent layout owns sizing
const w = pf(cs.width); const cw = containingWidthAt(node, parentNode, vp);
if (!(w > 0) || cw == null) return none;
let cl: number, cr: number;
if (parentNode) {
const pcs = parentNode.computedByVp[vp]!; const pb = parentNode.bboxByVp[vp]!;
cl = pb.x + pf(pcs.paddingLeft) + pf(pcs.borderLeftWidth);
cr = pb.x + pb.width - pf(pcs.paddingRight) - pf(pcs.borderRightWidth);
} else { cl = 0; cr = vp; }
const maxW = cs.maxWidth && cs.maxWidth.endsWith("px") ? pf(cs.maxWidth) : null;
samples.push({
w, bw: nb.width, cw, ml: pf(cs.marginLeft), mr: pf(cs.marginRight), maxW,
gapL: nb.x - cl, gapR: cr - (nb.x + nb.width), borderBox: (cs.boxSizing || "border-box") !== "content-box",
});
}
if (samples.length < 2) return none;
const close = (a: number, b: number, abs: number, pct: number): boolean => Math.abs(a - b) <= Math.max(abs, pct * Math.max(Math.abs(a), Math.abs(b)));
const span = (xs: number[]): number => Math.max(...xs) - Math.min(...xs);
const zeroMargins = samples.every((s) => Math.abs(s.ml) <= 1.5 && Math.abs(s.mr) <= 1.5);
// (a) Centred max-width container: border-box, a single px max-width cap, and every sample's
// border box equals min(containerWidth, cap) — filling when narrower than the cap, centred
// (symmetric positive gaps) when wider. Reproduces `max-width:W; margin:0 auto` exactly.
if (samples.every((s) => s.maxW != null && s.borderBox)) {
const caps = samples.map((s) => s.maxW!);
let ok = span(caps) <= Math.max(2, 0.01 * Math.max(...caps));
let sawCenter = false;
if (ok) for (const s of samples) {
if (!close(s.bw, Math.min(s.cw, s.maxW!), 1.5, 0.01)) { ok = false; break; }
if (s.cw > s.maxW! + 4) { // room to centre — must actually be centred, not left-aligned
if (s.gapL > 1 && s.gapR > 1 && Math.abs(s.gapL - s.gapR) <= 1.5) sawCenter = true;
else { ok = false; break; }
}
}
if (ok && sawCenter) return { plan: { kind: "auto" }, centerAlways: true };
}
// (b) Fills its container: border box == container content width at every (varying) width and
// no horizontal margins → it was authored width:auto/100%. Emit auto so it tracks the container.
if (zeroMargins && span(samples.map((s) => s.bw)) > 8 && samples.every((s) => close(s.bw, s.cw, 1.5, 0.01))) {
return { plan: { kind: "auto" }, centerAlways: false };
}
// (c) Stable fraction of the container (width:N%): own width / container width ~constant in
// (0,1) across viewports whose px actually varies, no horizontal margins.
if (zeroMargins && span(samples.map((s) => s.w)) > 8) {
const ratios = samples.map((s) => s.w / s.cw);
const mean = ratios.reduce((a, b) => a + b, 0) / ratios.length;
if (mean > 0.04 && mean < 0.985 && span(ratios) <= 0.006) {
return { plan: { kind: "percent", pct: `${Math.round(mean * 1e5) / 1e3}%` }, centerAlways: false };
}
}
return none;
}
/** Parse a computed grid track list ("284.5px 284.5px 284.5px", possibly with `[line-name]`
* groups) into px numbers; null if any token is not a px length (getComputedStyle resolves
* fr/auto/min-content/minmax to used px, so a non-px token means something we can't model). */
function parseTracks(v: string | undefined): number[] | null {
if (!v || v === "none") return null;
const toks = v.replace(/\[[^\]]*\]/g, " ").trim().split(/\s+/).filter(Boolean);
if (!toks.length) return null;
const out: number[] = [];
for (const t of toks) { if (!/^-?\d+(?:\.\d+)?px$/.test(t)) return null; out.push(parseFloat(t)); }
return out;
}
/** Re-express a grid container's computed (px-resolved) `grid-template-columns` as a fluid
* template — `fr` for tracks that scale with the container, `px` for genuinely fixed tracks —
* so the grid (and the items its tracks size) tracks the window instead of freezing at baked
* px. getComputedStyle reports `repeat(3,1fr)` as `"284px 284px 284px"`, which the engine
* bakes; unlike flex, grid items have no shrink fallback, so they pin to those px.
*
* Returns a per-viewport map of the fluid template (or null if nothing is recoverable). The
* column count may CHANGE across widths (a responsive `grid-cols-3 → grid-cols-6` breakpoint):
* we group the sampled widths by track count and solve the `fr` template independently within
* each regime, so each regime collapses its baked-px tracks to one fluid template (the per-width
* px variation inside a regime stops spawning bands) while the real column-count change stays a
* single honest band. A regime is recovered only when its `fr` model PROVABLY reproduces every
* captured track in it; otherwise those widths keep their baked px. */
function frTemplate(samples: { tracks: number[]; gap: number; content: number }[], n: number): string | null {
if (samples.length < 2 || n < 1) return null;
const sum = (xs: number[]) => xs.reduce((a, b) => a + b, 0);
const span = (xs: number[]) => Math.max(...xs) - Math.min(...xs);
// The fr model only holds when the tracks + gaps fill the container.
for (const s of samples) if (Math.abs(sum(s.tracks) + (n - 1) * s.gap - s.content) > Math.max(2, 0.01 * s.content)) return null;
const fixed: boolean[] = [];
for (let i = 0; i < n; i++) { const col = samples.map((s) => s.tracks[i]!); fixed[i] = span(col) <= Math.max(1.5, 0.01 * Math.max(...col)); }
if (fixed.every(Boolean)) return null; // intentionally fixed grid — keep baked (correctly fixed)
// Each fluid track must hold a CONSTANT share of the free space left after fixed tracks + gaps
// (the defining property of an fr track). Otherwise it isn't a clean fr and we bail.
const free = samples.map((s) => s.content - (n - 1) * s.gap - sum(s.tracks.filter((_, i) => fixed[i])));
if (free.some((f) => f <= 0)) return null;
const share: number[] = new Array(n).fill(0);
for (let i = 0; i < n; i++) {
if (fixed[i]) continue;
const shares = samples.map((s, k) => s.tracks[i]! / free[k]!);
if (span(shares) > 0.01) return null;
share[i] = shares.reduce((a, b) => a + b, 0) / shares.length;
}
const sumShare = sum(share);
const minFluid = Math.min(...share.filter((_, i) => !fixed[i]));
// Verify the fr distribution reproduces every captured track within tolerance before committing.
for (let k = 0; k < samples.length; k++) {
for (let i = 0; i < n; i++) {
if (fixed[i]) continue;
const predicted = free[k]! * (share[i]! / sumShare);
if (Math.abs(predicted - samples[k]!.tracks[i]!) > Math.max(1.5, 0.01 * samples[k]!.tracks[i]!)) return null;
}
}
const last = samples[samples.length - 1]!;
// A "fixed" track that resolves to ~0px is an EMPTY content (`auto`) track, not an authored 0px
// column — the source's `grid-cols-[1fr_auto]` where the second track currently holds nothing.
// Emit `auto` (renders identically to 0px here, but reads as the authored rule and grows if content
// ever appears) instead of the bug-looking `0px`. A genuinely fixed non-zero px track stays px.
return share.map((sh, i) => fixed[i] ? (Math.abs(last.tracks[i]!) < 0.5 ? "auto" : `${Math.round(last.tracks[i]! * 100) / 100}px`) : `${Math.round((sh / minFluid) * 1000) / 1000}fr`).join(" ");
}
/** Per-viewport fluid `grid-template-columns`. A responsive grid usually keeps its tracks fluid
* (`repeat(N, 1fr)`) and changes the COLUMN COUNT at breakpoints (4 → 3 → 2) — between breakpoints
* the columns shrink with the window, then one rolls over. The engine bakes each width's resolved
* px (`grid-cols-[300px_300px_300px_300px]`), which freezes the columns so they JUMP at breakpoints
* instead of shrinking. We group the sampled widths by column count and solve each regime
* independently: an equal-track regime becomes `repeat(N, minmax(0,1fr))` (Tailwind `grid-cols-N`),
* a mixed fixed+fluid regime keeps its solved `fr` template. Returns a vp→template map so each
* breakpoint band emits its own count (the per-width px collapse to one fluid rule per regime, and
* 1fr reproduces the captured px exactly — gate-neutral — while restoring the smooth shrink). */
/** Which columns of a grid are CONTENT-sized (`auto`), provable from the sizing probe. For each track
* we find the in-flow grid cell that starts at that track's x-offset and compare the track width to
* the cell's max-content (`wMax`): if they match at EVERY given viewport, the track sizes to its
* content → `auto` (the source's authored value), not a baked px. An auto track renders identically
* to its captured px (content == wMax) but reads as the rule and drops the decimal. Returns a per-
* track boolean mask; all-false (so a no-op) when there's no probe data, a track has no matching
* single-column cell, or the cell content doesn't fill the track (a genuinely fixed px column). */
function gridAutoTrackMask(node: IRNode, vps: number[], count: number): boolean[] {
const mask = new Array<boolean>(count).fill(true);
let any = false;
for (const vp of vps) {
const cs = node.computedByVp[vp]; const nb = node.bboxByVp[vp];
const tracks = cs ? parseTracks(cs.gridTemplateColumns) : null;
if (!cs || !nb || !tracks || tracks.length !== count) return new Array<boolean>(count).fill(false);
const gap = pf(cs.columnGap && cs.columnGap !== "normal" ? cs.columnGap : cs.gap);
let x = nb.x + pf(cs.paddingLeft) + pf(cs.borderLeftWidth);
const starts = tracks.map((t) => { const s = x; x += t + gap; return s; });
for (let i = 0; i < count; i++) {
if (!mask[i]) continue;
let cellWMax: number | undefined;
for (const c of node.children) {
if (isTextChild(c)) continue;
const ccs = c.computedByVp[vp]; const cb = c.bboxByVp[vp];
if (!ccs || !cb || !c.visibleByVp[vp] || (ccs.display || "") === "none") continue;
if (ccs.position === "absolute" || ccs.position === "fixed") continue; // out of flow
if (Math.abs(cb.x - starts[i]!) <= 2) { cellWMax = c.sizingByVp?.[vp]?.wMax; break; }
}
if (cellWMax === undefined || Math.abs(tracks[i]! - cellWMax) > Math.max(1.5, 0.02 * tracks[i]!)) mask[i] = false;
else any = true;
}
}
return any ? mask : new Array<boolean>(count).fill(false);
}
function fluidGridColumns(node: IRNode, viewports: number[]): Map<number, string> | null {
// A SINGLE fixed-px grid track that FILLS its container at every sampled viewport is a full-bleed
// fill column baked as `grid-cols-[Npx]` (a full-viewport hero carousel `uwp-carousel` with one
// track = the viewport width, re-baked per breakpoint). It freezes at the nearest band's px, so the
// grid — and everything it lays out — over-widens at any unsampled width (the splide02 hero measuring
// 768px inside a 572px window: the whole hero subtree inherits the frozen track). Re-express the lone
// track as `minmax(0,1fr)` (fills, reproduces the captured px exactly — gate-neutral). This is proven
// purely from the box geometry (track == content), so it is safe even on a REPLACED/custom-element
// grid, which the general solver below (it walks children) correctly still skips.
{
const filled: { vp: number; content: number }[] = [];
for (const vp of viewports) {
const cs = node.computedByVp[vp]; const nb = node.bboxByVp[vp];
if (!cs || !nb) continue;
if (!/^(grid|inline-grid)$/.test(cs.display || "")) continue;
const tracks = parseTracks(cs.gridTemplateColumns);
if (!tracks || tracks.length !== 1) { filled.length = 0; break; } // must be single-track at EVERY grid vp
const gap = pf(cs.columnGap && cs.columnGap !== "normal" ? cs.columnGap : cs.gap);
if (gap !== 0) { filled.length = 0; break; }
const content = nb.width - pf(cs.paddingLeft) - pf(cs.paddingRight) - pf(cs.borderLeftWidth) - pf(cs.borderRightWidth);
if (content <= 0) { filled.length = 0; break; }
if (Math.abs(tracks[0]! - content) > Math.max(1.5, 0.01 * content)) { filled.length = 0; break; } // track must FILL
filled.push({ vp, content });
}
if (filled.length >= 2) {
const m = new Map<number, string>();
for (const f of filled) m.set(f.vp, "minmax(0, 1fr)");
return m;
}
}
if (REPLACED.has(node.tag) || node.tag.includes("-")) return null;
type S = { vp: number; tracks: number[]; gap: number; content: number };
// Replace a solved template's fixed `Npx` tracks with `auto` where the column provably sizes to its
// cell's content (gridAutoTrackMask) — matches the source's `auto` and drops the decimal px.
const withAuto = (tmpl: string, grp: { vp: number; tracks: number[] }[]): string => {
const toks = tmpl.split(/\s+/);
if (toks.length !== grp[0]!.tracks.length) return tmpl; // repeat()/auto-fit forms — leave alone
const mask = gridAutoTrackMask(node, grp.map((s) => s.vp), toks.length);
if (!mask.some(Boolean)) return tmpl;
return toks.map((t, i) => (mask[i] && /px$/.test(t)) ? "auto" : t).join(" ");
};
const perVp: S[] = [];
for (const vp of viewports) {
const cs = node.computedByVp[vp]; const nb = node.bboxByVp[vp];
if (!cs || !nb) continue;
if (!/^(grid|inline-grid)$/.test(cs.display || "")) continue; // skip non-grid widths, don't bail
const tracks = parseTracks(cs.gridTemplateColumns);
if (!tracks || tracks.length < 1) continue;
const gapRaw = cs.columnGap && cs.columnGap !== "normal" ? cs.columnGap : cs.gap;
const content = nb.width - pf(cs.paddingLeft) - pf(cs.paddingRight) - pf(cs.borderLeftWidth) - pf(cs.borderRightWidth);
if (content <= 0) continue;
perVp.push({ vp, tracks, gap: pf(gapRaw), content });
}
if (perVp.length < 2) return null;
// FIRST try the idiomatic auto-fit grid: a SINGLE `repeat(auto-fit, minmax(MIN, 1fr))` that
// reproduces the column COUNT at every captured width — columns shrink (1fr) then roll over when
// they'd fall below MIN. Valid only when every sample has equal tracks and one MIN satisfies
// floor((content+gap)/(MIN+gap)) = count everywhere; the per-sample MIN windows must intersect.
// A genuinely breakpoint-driven grid (counts that no single MIN explains) fails this and falls
// through to the per-regime grid-cols-N below, so auto-fit never mis-fires.
const everyEqual = perVp.every((s) => Math.max(...s.tracks) - Math.min(...s.tracks) <= Math.max(1.5, 0.02 * Math.max(...s.tracks)));
const countsSeen = new Set(perVp.map((s) => s.tracks.length));
if (everyEqual && countsSeen.size >= 2) { // count must actually CHANGE — else it's a fixed grid-cols-N, not a rollover
let lo = 0, hi = Infinity;
for (const s of perVp) {
const N = s.tracks.length, C = s.content, g = s.gap;
lo = Math.max(lo, (C + g) / (N + 1) - g); // need < N+1 cols ⇒ MIN strictly above this
hi = Math.min(hi, (C + g) / N - g); // need ≥ N cols ⇒ MIN at or below this
}
if (lo + 0.5 < hi) {
const minCol = Math.min(...perVp.flatMap((s) => s.tracks)); // the column width just before rollover ≈ authored MIN
// Leave a little room below the theoretical upper bound. Some real source grids sit exactly
// at the rollover threshold at a captured viewport; emitting that exact min can wrap a card at
// intermediate/browser-rounded widths even though the source stayed in the larger column count.
const MIN = Math.max(1, Math.floor(Math.min(Math.max(minCol, lo + 0.5), hi - 0.5)));
const tmpl = `repeat(auto-fit, minmax(${MIN}px, 1fr))`;
const result = new Map<number, string>();
for (const s of perVp) result.set(s.vp, tmpl);
return result;
}
}
const byCount = new Map<number, S[]>();
for (const s of perVp) { const c = s.tracks.length; const g = byCount.get(c); if (g) g.push(s); else byCount.set(c, [s]); }
const result = new Map<number, string>();
for (const [count, samples] of byCount) {
// Every sample in the regime has ~equal tracks ⇒ a candidate `repeat(N, 1fr)`. (1fr divides the
// container content evenly, reproducing the equal baked px at each captured width.)
const allEqual = samples.every((s) => Math.max(...s.tracks) - Math.min(...s.tracks) <= Math.max(1.5, 0.02 * Math.max(...s.tracks)));
// `repeat(N,1fr)` divides the CONTAINER content among the tracks — valid only when the tracks +
// gaps actually FILL the container (the same fill law frTemplate enforces before solving an fr
// model). A fixed-track scrolling list (`overflow-x:auto` with `grid-cols-[173.5px…]` × 50 summing
// to 8675px inside a 1066px container) has equal tracks too, but rewriting it as `repeat(50,1fr)`
// shrinks every slide to viewport/50 and kills the horizontal scroll. When the equal tracks
// OVERFLOW the container, keep the baked fixed-px template (fall through, leaving these vps unset).
const fillsContainer = samples.every((s) => Math.abs(s.tracks.reduce((a, b) => a + b, 0) + (count - 1) * s.gap - s.content) <= Math.max(2, 0.01 * s.content));
const tmpl = (allEqual && fillsContainer) ? `repeat(${count}, minmax(0, 1fr))` : (samples.length >= 2 ? frTemplate(samples, count) : null);
if (tmpl) { const t2 = withAuto(tmpl, samples); for (const s of samples) result.set(s.vp, t2); continue; }
// Rescue a MIXED-track regime where frTemplate bailed only because the column PROPORTIONS
// change at a breakpoint — the ridge footer signup grid is 70/30 (`2.333fr 1fr`) at ≥768 but
// 60/40 at 375, so one fr model can't span all four widths. Partition the regime by its
// proportional signature and solve each sub-regime that has ≥2 samples on its own; frTemplate
// re-verifies reproduction within each, so a wrong split can NEVER emit (it just bails to baked).
// Single-sample sub-regimes (an isolated mobile width) stay baked — one width can't prove an fr.
if (allEqual || samples.length < 2) continue;
const byShare = new Map<string, S[]>();
for (const s of samples) {
const total = s.tracks.reduce((a, b) => a + b, 0);
const sig = total > 0 ? s.tracks.map((t) => Math.round((t / total) * 20) / 20).join("_") : "x";
const g = byShare.get(sig); if (g) g.push(s); else byShare.set(sig, [s]);
}
for (const grp of byShare.values()) {
if (grp.length < 2) continue;
const t = frTemplate(grp, count);
if (t) { const t2 = withAuto(t, grp); for (const s of grp) result.set(s.vp, t2); }
}
// Content-classification rescue: label each column `auto` (provably content-sized — its cell's
// max-content fills the track) or fluid, and group widths by that signature. This handles a grid
// whose FLUID column moves position across a breakpoint: `1fr auto auto` below lg
// (logo column is the spacer) and `auto 1fr auto` at/above it (center column is the spacer) —
// including the mobile regime the px-only frTemplate can't fit (two columns vary because an auto
// column's content reflows). For a signature with exactly ONE non-auto column that VARIES with the
// container while the grid fills its container, emit `auto`/`1fr` directly: the auto columns
// reproduce their content, the single fluid column absorbs the rest. Verifiable, matches source.
{
const byAuto = new Map<string, S[]>();
for (const s of samples) {
if (result.has(s.vp)) continue;
const sig = gridAutoTrackMask(node, [s.vp], count).map((b) => b ? "a" : "f").join("");
const g = byAuto.get(sig); if (g) g.push(s); else byAuto.set(sig, [s]);
}
for (const [sig, grp] of byAuto) {
if (grp.length < 2) continue;
const frIdx: number[] = []; for (let i = 0; i < count; i++) if (sig[i] === "f") frIdx.push(i);
if (frIdx.length !== 1) continue; // exactly one fluid (spacer) column
const fi = frIdx[0]!; const frVals = grp.map((s) => s.tracks[fi]!);
if (Math.max(...frVals) - Math.min(...frVals) <= Math.max(2, 0.02 * Math.max(...frVals))) continue; // a constant non-auto column is fixed px, not fr
if (grp.some((s) => Math.abs(s.tracks.reduce((a, b) => a + b, 0) + (count - 1) * s.gap - s.content) > Math.max(2, 0.01 * s.content))) continue; // grid must fill its container
const tmpl = Array.from({ length: count }, (_, i) => i === fi ? "1fr" : "auto").join(" ");
for (const s of grp) result.set(s.vp, tmpl);
}
}
// Final rescue: the FLUID track changes POSITION across a breakpoint: `1fr auto auto`
// below lg (the spacer is track 0) and `auto 1fr auto` at/above it (the spacer
// moves to track 1). The proportional partition above can't cluster these (the fill track's share
// of the container varies continuously), so partition instead by WHICH track is the LARGEST — the
// fill track absorbs the free space, so it is ≈ the widest — and solve each group with frTemplate,
// which re-verifies reproduction at every width (a wrong grouping just bails to baked). Skip widths
// a prior rescue already solved. This collapses the per-breakpoint baked-px pile to one `…1fr…`
// template per regime, so the nav flows on resize instead of jumping at each band.
const byFill = new Map<number, S[]>();
for (const s of samples) {
if (result.has(s.vp)) continue;
let mi = 0; for (let i = 1; i < s.tracks.length; i++) if (s.tracks[i]! > s.tracks[mi]!) mi = i;
const g = byFill.get(mi); if (g) g.push(s); else byFill.set(mi, [s]);
}
if (byFill.size < 1) continue;
for (const grp of byFill.values()) {
if (grp.length < 2) continue;
const t = frTemplate(grp, count);
if (t) { const t2 = withAuto(t, grp); for (const s of grp) result.set(s.vp, t2); }
}
}
return result.size ? result : null;
}
/** Per-viewport fluid `grid-template-rows`. Deliberately NARROWER than `fluidGridColumns`: grid
* COLUMNS fill the container width (always definite), so a baked column track is almost always a
* fluid `fr`; grid ROWS are content-sized by default and the container's height is usually
* indefinite, so a baked `grid-rows-[Npx]` is most often a frozen *content* height that belongs to
* the height path, not an `fr`. We re-express ONLY the safe case: an EQUAL multi-track regime whose
* tracks fill the container's content HEIGHT under normal/stretch packing, on a grid that is
* actually responsive (its row count OR row size changes across viewports — the `fr` signature, the
* same "must vary" bar `frTemplate` applies to columns). Equal tracks summing to the content height
* are already an even split, so `repeat(N, minmax(0,1fr))` reproduces them EXACTLY at each captured
* height WITHOUT changing the container's height (the rows still total the same) — gate-neutral, no
* vertical cascade. Unequal tracks (differing content rows), single-track rows (a frozen content
* height), and fully-constant equal rows (an ambiguous authored-fixed grid) are left baked. The row
* count may change across breakpoints (a card grid rewrapping 3→4→6 rows); we group by count and
* emit one `grid-rows-N` per regime as a band. */
function fluidGridRows(node: IRNode, viewports: number[]): Map<number, string> | null {
if (REPLACED.has(node.tag) || node.tag.includes("-")) return null;
// A subgrid's row tracks are INHERITED from the parent grid, not independent sizes —
// getComputedStyle resolves `subgrid` to the parent's px, so a viewport where this node is subgrid
// looks like ordinary (often coincidentally-equal) px tracks. Re-expressing those as `1fr` severs
// the inheritance and forces an even split the parent never had. If the node is subgrid at ANY
// sampled width, it's a subgrid element everywhere — leave its rows alone.
if (viewports.some((vp) => /subgrid/.test(node.computedByVp[vp]?.gridTemplateRows || ""))) return null;
type S = { vp: number; tracks: number[] };
const ok: S[] = [];
let singleRowRegime = false;
for (const vp of viewports) {
const cs = node.computedByVp[vp]; const nb = node.bboxByVp[vp];
if (!cs || !nb) continue;
if (!/^(grid|inline-grid)$/.test(cs.display || "")) continue; // skip non-grid widths, don't bail
const tracks = parseTracks(cs.gridTemplateRows);
if (!tracks) continue; // subgrid/auto/fr/non-px — not a baked px regime
// A baked SINGLE row is often carrying an authored/media height. Replacing it with `1fr` is only
// safe when some other declaration already confers the same definite height; collectNodeRules
// does not currently thread that fact into this helper, so leave single-row grids baked.
if (tracks.length < 2) { singleRowRegime = true; continue; }
// `align-content` must not inject free space between the tracks (space-*/center/start/end leave
// gaps that `1fr` would absorb); only normal/stretch packs the tracks so they fill the box.
if (!/^(normal|stretch)$/.test(cs.alignContent || "normal")) continue;
const rowGap = cs.rowGap && cs.rowGap !== "normal" ? cs.rowGap : cs.gap;
const contentH = nb.height - pf(cs.paddingTop) - pf(cs.paddingBottom) - pf(cs.borderTopWidth) - pf(cs.borderBottomWidth);
if (contentH <= 0) continue;
const sum = tracks.reduce((a, b) => a + b, 0) + (tracks.length - 1) * pf(rowGap);
if (Math.abs(sum - contentH) > Math.max(2, 0.01 * contentH)) continue; // tracks must fill the content box
// Equal tracks only — an even split is exactly what `1fr` reproduces. UNEQUAL rows at ANY width
// prove the rows are content-driven (cards of differing height), so an equal-rows regime at a
// OTHER width is just coincidental content, not an `fr` structure: bail entirely and keep the
// whole grid's rows baked.
if (Math.max(...tracks) - Math.min(...tracks) > Math.max(1.5, 0.02 * Math.max(...tracks))) return null;
ok.push({ vp, tracks });
}
if (singleRowRegime || ok.length < 1) return null; // wrapping/collapsing grid — not a stable tile grid
// The `fr` signature: a genuinely responsive grid changes its row COUNT or row SIZE across the
// captured widths. A grid whose equal rows are identical at every width is an ambiguous
// authored-fixed candidate (in an indefinite-height container `1fr` would collapse to content) —
// leave it baked.
const counts = new Set(ok.map((s) => s.tracks.length));
const sizes = new Set(ok.map((s) => Math.round(s.tracks[0]!)));
if (counts.size < 2 && sizes.size < 2) return null;
const result = new Map<number, string>();
for (const s of ok) result.set(s.vp, `repeat(${s.tracks.length}, minmax(0, 1fr))`);
return result;
}
/** A single-row grid whose height comes from a recovered height/aspect law can use the authored
* `1fr` row again. Without the definite-height law this is unsafe: an indefinite one-row grid
* collapses to content and loses the media frame height. */
function singleFluidGridRow(node: IRNode, viewports: number[]): Map<number, string> | null {
if (REPLACED.has(node.tag) || node.tag.includes("-")) return null;
const result = new Map<number, string>();
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;
if (!/^(grid|inline-grid)$/.test(cs.display || "")) return null;
const tracks = parseTracks(cs.gridTemplateRows);
if (!tracks || tracks.length !== 1) return null;
const rowGap = cs.rowGap && cs.rowGap !== "normal" ? cs.rowGap : cs.gap;
const contentH = nb.height - pf(cs.paddingTop) - pf(cs.paddingBottom) - pf(cs.borderTopWidth) - pf(cs.borderBottomWidth);
if (contentH <= 0 || pf(rowGap) !== 0) return null;
if (Math.abs(tracks[0]! - contentH) > Math.max(1.5, 0.01 * contentH)) return null;
result.set(vp, "repeat(1, minmax(0, 1fr))");
painted++;
}
return painted >= 2 ? result : null;
}
/** 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. */
function viewportHeightLaw(node: IRNode, viewports: number[]): Record<number, string> | null {
if (REPLACED.has(node.tag) || node.tag === "canvas" || node.tag.includes("-")) return null;
const samples: Array<{ vp: number; h: 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 pos = cs.position || "static";
if (pos !== "static" && pos !== "relative") return null;
const h = nb.height;
if (!(h >= 120)) return null;
samples.push({ vp, h, vh: viewportHeightFor(vp) });
}
if (samples.length < 2) return null;
const hs = samples.map((s) => s.h);
if (Math.max(...hs) - Math.min(...hs) <= 8) return null;
// Keep this on structural/media boxes. Text-heavy boxes are too likely to be content-height.
if (node.children.some((c) => isTextChild(c) && c.text.trim())) return null;
const minH = Math.min(...hs);
const maxH = Math.max(...hs);
const pctCandidates = new Set<number>();
for (const s of samples) {
if (s.h > minH + 1) pctCandidates.add(cleanPct((s.h / s.vh) * 100));
}
for (const pct of pctCandidates) {
if (!(pct >= 20 && pct <= 100)) continue;
const css = `clamp(${fmtPx(minH)}, ${pct}vh, ${fmtPx(maxH)})`;
const ok = samples.every((s) => {
const predicted = Math.max(minH, Math.min(maxH, s.vh * pct / 100));
return Math.abs(predicted - s.h) <= Math.max(1.5, 0.01 * s.h);
});
if (!ok) continue;
const out: Record<number, string> = {};
for (const s of samples) out[s.vp] = css;
return out;
}
return null;
}
/** A media/card frame whose height is derived from its width by aspect-ratio, optionally capped by
* max-height. Emits an aspect-ratio law and drops the baked height/grid-row px. Per-viewport ratios
* are allowed because responsive designs often switch aspect by breakpoint. */
function aspectHeightLaw(node: IRNode, viewports: number[]): Record<number, string> | null {
if (REPLACED.has(node.tag) || node.tag === "canvas" || node.tag.includes("-")) return null;
type S = { vp: number; w: number; h: number; maxH: number; ratio?: { value: string; ratio: number } };
const samples: S[] = [];
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 pos = cs.position || "static";
if (pos !== "static" && pos !== "relative") return null;
if (!(nb.width > 0 && nb.height > 0)) return null;
if (node.children.some((c) => isTextChild(c) && c.text.trim())) return null;
const maxH = cs.maxHeight && cs.maxHeight !== "none" ? pf(cs.maxHeight) : Infinity;
const unclamped = !Number.isFinite(maxH) || nb.height < maxH - 1.5;
samples.push({ vp, w: nb.width, h: nb.height, maxH, ratio: unclamped ? snapAspectRatio(nb.width / nb.height) ?? undefined : undefined });
}
if (samples.length < 2) return null;
if (!samples.some((s) => s.ratio)) return null;
const ratios = [...new Map(samples.filter((s) => s.ratio).map((s) => [s.ratio!.value, s.ratio!])).values()];
const out: Record<number, string> = {};
for (let i = 0; i < samples.length; i++) {
const s = samples[i]!;
const ordered = [
s.ratio,
...ratios.sort((a, b) => Math.abs((samples.findIndex((x) => x.ratio?.value === a.value) - i)) - Math.abs((samples.findIndex((x) => x.ratio?.value === b.value) - i))),
].filter((x): x is { value: string; ratio: number } => !!x);
let chosen: { value: string; ratio: number } | undefined;
for (const r of ordered) {
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 (!chosen) return null;
out[s.vp] = chosen.value;
}
// Must remove a real baked dimension/band: either height varies, or the aspect ratio changes.
const hs = samples.map((s) => s.h);
if (Math.max(...hs) - Math.min(...hs) <= 8 && new Set(Object.values(out)).size < 2) return null;
return out;
}
/** A lottie mount box: the container the runtime player re-mounts its svg/canvas into. The player
* sizes that svg by the animation's aspect at the container's width, so without a pinned height the
* mount inflates to the aspect height (overlapping neighbours). Treat it as replaced-like — pin the
* captured per-viewport border-box height (the source-constrained size) so the aspect-fit svg fills
* a definite box. Per viewport, like img/video height emission. */
function lottieMountHeight(node: IRNode, viewports: number[]): Record<number, string> | null {
const out: Record<number, string> = {};
let any = false;
for (const vp of viewports) {
const nb = node.bboxByVp[vp];
if (!nb || !node.visibleByVp[vp] || (node.computedByVp[vp]?.display || "") === "none") continue;
if (!(nb.height > 0)) continue;
out[vp] = fmtPx(nb.height);
any = true;
}
return any ? out : null;
}
function mediaHeightGeometry(node: IRNode, viewports: number[]): Pick<GeometryPlan, "heightByVp" | "aspectByVp"> {
const heightByVp = viewportHeightLaw(node, viewports);
if (heightByVp) return { heightByVp };
const aspectByVp = aspectHeightLaw(node, viewports);
if (aspectByVp) return { aspectByVp };
return {};
}
/** A grid item whose width is determined by its (now-fluid) track span, so the baked px is
* redundant and freezes it. True when the parent is a grid at every width, the item is in-flow
* and stretches to fill its track (the default), and its width actually VARIES across widths
* (proving it's track-driven, not a fixed/min-content item). Dropping the width then lets the
* fluid tracks size it; at the captured widths it resolves to the same px (gate-neutral). */
function isGridItemFill(node: IRNode, parentNode: IRNode | undefined, viewports: number[]): boolean {
if (!parentNode || REPLACED.has(node.tag) || node.tag.includes("-")) return false;
const widths: number[] = [];
for (const vp of viewports) {
const cs = node.computedByVp[vp]; const pcs = parentNode.computedByVp[vp];
if (!cs || !pcs) continue;
if (!node.visibleByVp[vp] || (cs.display || "") === "none") continue; // not painted here → no width to judge
if (!/^(grid|inline-grid)$/.test(pcs.display || "")) return false;
const pos = cs.position || "static";
if (pos !== "static" && pos !== "relative") return false;
if ((cs.float || "none") !== "none") return false;
// Item must stretch to fill its track (default). justify-self:auto defers to the grid's
// justify-items; a center/start/end on either shrink-wraps the item to content instead.
const js = cs.justifySelf || "auto";
const effective = js === "auto" || js === "normal" ? (pcs.justifyItems || "normal") : js;
if (!/^(normal|stretch|legacy|auto)$/.test(effective)) return false;
const w = pf(cs.width); if (!(w > 0)) return false;
widths.push(w);
}
// The justify-items:stretch check above guarantees the item fills its track, so its width is
// track-driven and `width:auto` reproduces it — whether or not the track px varies. (A grid that
// is max-width-capped, or only painted at one breakpoint, leaves a CONSTANT cell width that the
// old "must vary" gate wrongly kept as baked px — the "Trusted by" logo cells.)
return widths.length >= 1;
}
/** A flex item whose width comes from an explicit width/`flex-basis:auto` and that, with its
* siblings, equally FILLS a single-line flex row. The engine bakes the resolved px as the
* basis; unlike a `flex:1` item (basis 0, which re-distributes), this freezes whenever the
* baked basis no longer overflows the live container (no shrink fires) — the `flex gap w-1/3`
* card-row freeze. Re-expressing it as `flex:1 1 0` reproduces the equal fill at every captured
* width (gate-neutral) and scales fluidly. Gated hard: row-direction, single-line, all in-flow
* siblings equal-width AND collectively filling the row, item width varies across widths. */
function isFlexFillItem(node: IRNode, parentNode: IRNode | undefined, viewports: number[]): boolean {
if (!parentNode || REPLACED.has(node.tag) || node.tag.includes("-")) return false;
const widths: number[] = [];
for (const vp of viewports) {
const cs = node.computedByVp[vp]; const pcs = parentNode.computedByVp[vp]; const pb = parentNode.bboxByVp[vp];
if (!cs || !pcs || !pb) continue;
if (!/^(flex|inline-flex)$/.test(pcs.display || "")) return false;
const dir = pcs.flexDirection || "row";
if (dir !== "row" && dir !== "row-reverse") return false; // width is the main axis
if ((pcs.flexWrap || "nowrap") !== "nowrap") return false; // single line (multi-line ≠ one row)
const pos = cs.position || "static";
if (pos !== "static" && pos !== "relative") return false;
if ((cs.float || "none") !== "none") return false;
if ((cs.flexBasis || "auto") !== "auto") return false; // basis!=auto already self-distributes
if (pf(cs.flexGrow) > 0) return false; // already grows (fluid) — leave it
// All in-flow, visible flex siblings must share ~equal width AND fill the row.
const sw: number[] = [];
for (const c of parentNode.children) {
if (isTextChild(c)) continue;
const scs = c.computedByVp[vp]; const sb = c.bboxByVp[vp];
if (!scs || !sb || !c.visibleByVp[vp]) continue;
const sp = scs.position || "static";
if (sp === "absolute" || sp === "fixed") continue;
sw.push(sb.width);
}
if (sw.length < 2) return false;
if (Math.max(...sw) - Math.min(...sw) > Math.max(2, 0.02 * Math.max(...sw))) return false; // not equal
const gap = pf(pcs.columnGap && pcs.columnGap !== "normal" ? pcs.columnGap : pcs.gap);
const content = pb.width - pf(pcs.paddingLeft) - pf(pcs.paddingRight) - pf(pcs.borderLeftWidth) - pf(pcs.borderRightWidth);
const total = sw.reduce((a, b) => a + b, 0) + (sw.length - 1) * gap;
if (Math.abs(total - content) > Math.max(2, 0.02 * content)) return false; // doesn't fill the row
widths.push(pf(cs.width));
}
return widths.length >= 2 && Math.max(...widths) - Math.min(...widths) > 8;
}
/** A CIRCULAR-DEPENDENCY flex/grid item whose width has NO in-flow source and would collapse to 0.
*
* A Splide (and similar JS carousel) slide is a `shrink-0` flex item whose width is set only by the
* library's INJECTED inline `width:Npx`. The sizing probe therefore reads it as content/fill-sized
* (`wAuto`/`wFill` both true), so generation drops the width. But the slide's only in-flow children
* FILL it (`w-full h-full`, often `aspect-square`) — they take their width FROM the slide. With the
* slide's width dropped and every child filling, nothing establishes a definite width: the slide, the
* fill children, and the track all resolve to 0×0 (the "Explore Our Oven Range" carousel collapsing
* to a 640px hole).
*
* Detect exactly that circular case and pin the captured px so the fill children have a definite basis:
* - the node is an IN-FLOW flex or grid ITEM with `flex-shrink:0` (a carousel slide, never shrinks),
* - it has a definite positive captured width at every painted viewport,
* - it has NO in-flow width source: every in-flow ELEMENT child either FILLS it
* (probe `wFill && !wAuto` → width derives from the slide) or is absolutely/fixed positioned,
* and at least one such fill child exists (the thing that would collapse).
* Requires probe data (older captures return false → inert). Scoped hard so it fires only on the
* 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;
let painted = 0;
for (const vp of viewports) {
const cs = node.computedByVp[vp]; const pcs = parentNode.computedByVp[vp]; const nb = node.bboxByVp[vp];
if (!cs || !pcs || !nb) continue;
if (!node.visibleByVp[vp] || (cs.display || "") === "none") continue; // judge only where painted
if (!/^(flex|inline-flex|grid|inline-grid)$/.test(pcs.display || "")) return false; // parent must lay it out as a flex/grid item
const pos = cs.position || "static";
if (pos !== "static" && pos !== "relative") return false; // in-flow item only
if ((cs.float || "none") !== "none") return false;
if (pf(cs.flexShrink) !== 0) return false; // a slide is shrink-0 (never collapses to content)
if (!(nb.width > 0) || !(pf(cs.width) > 0)) return false; // need a definite captured px to pin
// No in-flow width source: every in-flow element child fills the slide (its width derives from the
// slide) or is out of flow; at least one fill child must exist (else nothing collapses).
let fillChildren = 0;
for (const c of node.children) {
if (isTextChild(c)) continue;
const ccs = c.computedByVp[vp]; const cb = c.bboxByVp[vp];
if (!ccs || !cb || !c.visibleByVp[vp] || (ccs.display || "") === "none") continue;
const cpos = ccs.position || "static";
if (cpos === "absolute" || cpos === "fixed") continue; // out of flow → no width contribution
const csz = c.sizingByVp?.[vp];
if (csz && csz.wFill === true && csz.wAuto === false) { fillChildren++; continue; } // fills the slide → derives width from it
return false; // a genuine in-flow width source → not circular
}
if (fillChildren === 0) return false;
painted++;
}
return painted >= 1;
}
/** A FULL-WIDTH carousel slide: a `shrink-0` flex-ROW item whose border box FILLS its flex
* container's content width at every painted viewport (a full-viewport hero slide in a Splide list;
* the slides sit side by side and the track clips via overflow). The naive emission `width:100%` on
* a `shrink-0` flex item does NOT stay at 100%: flex sizes the item from its MAX-CONTENT (the
* shrink-0 item can't drop below it), and a full-bleed slide's max-content (its absolutely-positioned
* aspect-ratio media) freezes the whole list wider than the container at any unsampled width (the
* splide02 hero list measuring 768px inside a 572px window). Emitting `flex-basis:100%` gives the
* slide a DEFINITE main size = the container content width, so the flex row stays exactly one
* container wide at every width (gate-neutral at samples, correct in between). Distinct from
* isCircularShrinkSlide (a FIXED-width sub-container slide that pins its captured px). */
function isFullWidthShrinkSlide(node: IRNode, parentNode: IRNode | undefined, viewports: number[]): boolean {
if (!parentNode || REPLACED.has(node.tag) || node.tag.includes("-")) return false;
let painted = 0;
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) continue;
if (!node.visibleByVp[vp] || (cs.display || "") === "none") continue;
if (!/^(flex|inline-flex)$/.test(pcs.display || "")) return false; // flex parent
const dir = pcs.flexDirection || "row";
if (dir !== "row" && dir !== "row-reverse") return false; // width is the main axis
const pos = cs.position || "static";
if (pos !== "static" && pos !== "relative") return false;
if ((cs.float || "none") !== "none") return false;
if (pf(cs.flexShrink) !== 0) return false; // a slide is shrink-0
if (pf(cs.marginLeft) !== 0 || pf(cs.marginRight) !== 0) return false; // margins make the width load-bearing
if ((cs.boxSizing || "border-box") === "content-box") return false; // basis:100% would overflow by padding
const content = pb.width - pf(pcs.paddingLeft) - pf(pcs.paddingRight) - pf(pcs.borderLeftWidth) - pf(pcs.borderRightWidth);
if (Math.abs(nb.width - content) > 1.5) return false; // must actually FILL the flex container
painted++;
}
return painted >= 1;
}
/** A flex/grid ITEM whose border box fills its container's content width at every viewport —
* cross-axis stretch in a column, a sole/spanning item in a row, a full-span grid cell. We were
* baking the resolved fill width per viewport, which freezes it AND (the px differs per width)
* spawns a wall of breakpoint variants. Emitting `width:100%` restores the fill and resolves to
* the identical px at every sampled width (gate-neutral — width:100% IS the container content
* width). Gated: in-flow, border-box, no horizontal margins (those make the width load-bearing),
* the box ACTUALLY fills (border box == container content, within 1.5px) at every width, and the
* width varies (proving it tracks a fluid container, not a fixed box). */
function isFillsContainerWidth(node: IRNode, parentNode: IRNode | undefined, viewports: number[]): boolean {
if (!parentNode || REPLACED.has(node.tag) || node.tag.includes("-")) return false;
const widths: number[] = [];
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) continue;
if (!node.visibleByVp[vp] || (cs.display || "") === "none") continue; // not painted here (width 0) → judge only where it renders
if (!/^(flex|inline-flex|grid|inline-grid)$/.test(pcs.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; // width:100% would overflow by padding
if (pf(cs.marginLeft) !== 0 || pf(cs.marginRight) !== 0) return false; // margins make the width load-bearing
const content = pb.width - pf(pcs.paddingLeft) - pf(pcs.paddingRight) - pf(pcs.borderLeftWidth) - pf(pcs.borderRightWidth);
if (Math.abs(nb.width - content) > 1.5) return false; // must actually fill the container
widths.push(nb.width);
}
return widths.length >= 2 && Math.max(...widths) - Math.min(...widths) > 8;
}
/** A REPLACED element (img/video/svg/canvas) that fills its containing block at every painted width —
* width == container content width across viewports, guarded by object-fit cover/contain/fill or
* max-width:100% so `w-full` can't overflow. The sizing probe skips replaced elements, so a fill
* thumbnail (a card image, a hero photo) otherwise bakes per-viewport px (`w-[18.75rem]` +
* `max-md:w-[10.25rem]`) that won't track a fluid cell — leaving the image narrower than its
* cell/caption at widths the capture didn't sample. → `w-full`, which fills at any width. For an
* absolutely-positioned image the parent must be the containing block (positioned). */
function replacedFillsContainer(node: IRNode, parentNode: IRNode | undefined, viewports: number[]): boolean {
if (!parentNode || !REPLACED.has(node.tag) || node.tag.includes("-")) return false;
let painted = 0;
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) continue;
if (!node.visibleByVp[vp] || (cs.display || "") === "none") continue; // judge only where it renders
// Form controls (textarea/input/select) have no intrinsic ASPECT to distort — a `w-full` chat
// input that fills its container is just that, so skip the image-aspect guards below for them.
const isFormControl = node.tag === "textarea" || node.tag === "input" || node.tag === "select";
// `object-fit:fill` is the DEFAULT, so it's NOT a fill signal. A fixed-PX-height replaced element
// with no object-fit is an intrinsic-aspect icon/logo (a 32px nav SVG, a square brand logo) —
// forcing width:100% distorts it. Genuine fills are object-fit cover/contain media, or a
// height:auto responsive image (which keeps aspect via auto height).
const hasObjectFit = /cover|contain/.test(cs.objectFit || "");
if (!isFormControl && !hasObjectFit && /px$/.test(cs.height || "")) return false;
if (!isFormControl && !hasObjectFit && (cs.maxWidth || "") !== "100%") return false;
if (pf(cs.marginLeft) !== 0 || pf(cs.marginRight) !== 0) return false; // margins make width load-bearing
const pos = cs.position || "static";
if (pos === "absolute" || pos === "fixed") {
if (!/^(relative|absolute|fixed|sticky)$/.test(pcs.position || "")) return false; // parent isn't the CB
} else if (pos !== "static" && pos !== "relative") return false;
const content = pb.width - pf(pcs.paddingLeft) - pf(pcs.paddingRight) - pf(pcs.borderLeftWidth) - pf(pcs.borderRightWidth);
if (Math.abs(nb.width - content) > 2) return false; // must actually fill the container
painted++;
}
return painted >= 1;
}
function sourceWidthFillIntent(node: IRNode): boolean {
if (!REPLACED.has(node.tag) || node.tag.includes("-")) return false;
return /\bw-full\b/.test(node.srcClass || "");
}
function sourceMarginAutoIntent(node: IRNode): boolean {
return /(?:^|\s)mx-auto(?:\s|$)/.test(node.srcClass || "");
}
function sourceFixedSizeIntent(node: IRNode): boolean {
// Tailwind's `size-*` is an authored square sizing contract. If the sizing probe later drops it
// as "content-sized", circular icon wrappers collapse to their inner img and lose their apparent
// padding (Astro integration tabs: `size-12 lg:size-16`). `size-fit` is deliberately excluded:
// that one is content-derived and should stay auto.
return /(?:^|\s)(?:[a-z0-9-]+:)*size-(?!fit(?:\s|$))\S+/.test(node.srcClass || "");
}
const ABSOLUTE_MEDIA_FILL_TAGS = new Set(["img", "picture", "video"]);
/** An absolutely-positioned media layer authored as a full-cover background: `position:absolute;
* inset:0; width:100%; height:100%` or the equivalent. The source capture reports the resolved
* pixel width/height at each sampled viewport, so a `<picture>` wrapper can get frozen at the
* widest captured card size and leave the underlying card background exposed on wider screens.
* This is deliberately narrower than `replacedFillsContainer`: only media tags, all four zero
* insets, no margins, positioned parent, and measured coverage of the parent's padding box. */
function absoluteMediaCoversParent(node: IRNode, parentNode: IRNode | undefined, viewports: number[]): boolean {
if (!parentNode || !ABSOLUTE_MEDIA_FILL_TAGS.has(node.tag) || node.tag.includes("-")) return false;
let painted = 0;
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) continue;
if (!node.visibleByVp[vp] || (cs.display || "") === "none") continue;
const pos = cs.position || "static";
if (pos !== "absolute" && pos !== "fixed") return false;
if (pos === "absolute" && !/^(relative|absolute|fixed|sticky)$/.test(pcs.position || "")) return false;
const sideVals = [cs.top, cs.right, cs.bottom, cs.left];
if (sideVals.some((v) => v == null || v === "" || v === "auto" || Math.abs(pf(v)) > 1)) return false;
if (pf(cs.marginTop) !== 0 || pf(cs.marginRight) !== 0 || pf(cs.marginBottom) !== 0 || pf(cs.marginLeft) !== 0) return false;
const cbX = pos === "fixed" ? 0 : pb.x + pf(pcs.borderLeftWidth);
const cbY = pos === "fixed" ? 0 : pb.y + pf(pcs.borderTopWidth);
const cbW = pos === "fixed" ? vp : pb.width - pf(pcs.borderLeftWidth) - pf(pcs.borderRightWidth);
const cbH = pos === "fixed"
? (CAPTURE_VIEWPORT_HEIGHTS[vp] ?? Math.round(vp * 0.66))
: pb.height - pf(pcs.borderTopWidth) - pf(pcs.borderBottomWidth);
if (!(cbW > 0) || !(cbH > 0)) return false;
if (Math.abs(nb.x - cbX) > 2 || Math.abs(nb.y - cbY) > 2) return false;
if (Math.abs(nb.width - cbW) > Math.max(2, 0.01 * cbW)) return false;
if (Math.abs(nb.height - cbH) > Math.max(2, 0.01 * cbH)) return false;
painted++;
}
return painted >= 1;
}
/** An inline <svg> sized by a fixed HEIGHT with `width:auto` follows its viewBox aspect ratio — the
* source's `h-[2rem] w-auto` logo. Across breakpoints the height steps up (`h-[2rem] sm:h-[2.25rem]
* md:h-[2.5rem]`) so the resolved width scales too, and the engine bakes the per-vp px — pinning the
* logo. Here the intrinsic aspect is KNOWN (the viewBox is in the captured rawHTML), so we can prove
* the law: when the captured width equals height × (viewBox w/h) at EVERY painted viewport, the width
* IS purely the aspect-scaled height (not a constrained/flex-shrunk box) → drop it to `auto` so it
* tracks the height fluidly. Unlike the reverted width-only image fill, this KEEPS the height as the
* driver and lets width follow it, so the aspect is preserved by construction (no distortion). Any vp
* whose width is constrained below the aspect (a flex-shrunk mobile logo) fails the check → stays baked. */
function svgAspectWidthAuto(node: IRNode, viewports: number[]): boolean {
if (node.tag !== "svg" || !node.rawHTML) return false;
const m = node.rawHTML.match(/viewBox\s*=\s*["']([^"']+)["']/);
if (!m) return false;
const p = m[1]!.trim().split(/[\s,]+/).map(Number);
if (p.length !== 4 || !(p[2]! > 0) || !(p[3]! > 0)) return false;
const aspect = p[2]! / p[3]!;
let painted = 0;
for (const vp of viewports) {
const nb = node.bboxByVp[vp]; const cs = node.computedByVp[vp];
if (!nb || !cs || !node.visibleByVp[vp] || (cs.display || "") === "none") continue;
if (!(nb.height > 0) || !(nb.width > 0)) return false;
if (Math.abs(nb.width - nb.height * aspect) > Math.max(1.5, 0.02 * nb.width)) return false;
painted++;
}
return painted >= 2;
}
function maxWidthTracksViewport(node: IRNode, viewports: number[]): boolean {
if (REPLACED.has(node.tag) || node.tag.includes("-")) return false;
let samples = 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") continue;
if (!cs.maxWidth || cs.maxWidth === "none") return false;
if (Math.abs(pf(cs.maxWidth) - vp) > 1.5) return false;
if (Math.abs(bb.x) > 1.5 || Math.abs(bb.width - vp) > 1.5) return false;
samples++;
}
return samples >= 2;
}
// NOTE: a GENERAL width-only responsive-image fill (`w-[Npx]` → `w-full`, height KEPT) was tried and
// REVERTED — keeping the baked px height while fluidising width distorts a SQUARE image's aspect
// (square images can become wide rectangles). A correct aspect-preserving fill needs
// `height:auto`, unprovable without an intrinsic-aspect probe. `fullBleedImageFill` below is the
// NARROW, safe subset.
/** A FULL-BLEED banner image: a replaced element whose width ≈ the VIEWPORT at every painted width
* (it spans the window, starting at the left edge), object-fit cover/fill or max-width:100%, and
* whose width VARIES. The engine bakes the per-vp px (`w-320` 1280 + `w-480` 1920), so beyond the
* widest captured band it FREEZES — the ridge hero photo stops at 1920 and leaves a gutter on a 2400
* monitor. Emit `width:100%` so it keeps filling. Height stays baked (object-fit cover/fill absorbs
* the aspect — no distortion, just fills; a gutter is the worse failure). Narrow on purpose: the
* "width ≈ viewport" bar excludes small card images (a 372px avatar fills its CARD, not the window),
* which is exactly what the general law got wrong. */
function fullBleedImageFill(node: IRNode, parentNode: IRNode | undefined, viewports: number[]): boolean {
if (!REPLACED.has(node.tag) || node.tag.includes("-")) return false;
const ws: 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" || nb.width <= 0) continue;
const pos = cs.position || "static";
if (pos !== "static" && pos !== "relative") return false; // a positioned banner's coords are load-bearing
if (pf(cs.marginLeft) !== 0 || pf(cs.marginRight) !== 0) return false;
const objFit = cs.objectFit || "fill";
if (!/cover|contain|fill/.test(objFit) && (cs.maxWidth || "") !== "100%") return false; // can absorb a wider box
if (Math.abs(nb.x) > Math.max(2, 0.01 * vp)) return false; // starts at the left edge
if (Math.abs(nb.width - vp) > Math.max(3, 0.02 * vp)) return false; // spans the viewport (FULL-BLEED, not a card image)
ws.push(nb.width);
}
return ws.length >= 2 && Math.max(...ws) - Math.min(...ws) > 8; // grows with the viewport
}
/** A block-level child filling its BLOCK container — the single commonest fluid case, and the one
* `isFillsContainerWidth` (flex/grid parents only) misses. A block-level box with `width:auto`
* always resolves to its containing block's content width; so when the measured width equals the
* parent's content width at every sampled viewport (and varies, so there's a band to remove), the
* baked per-viewport px is just the default block fill frozen into a value + a band wall. Dropping
* width (→ `width:auto`, emit nothing) re-derives the identical box at every width — and is exactly
* what a human writes on a full-width div. Provably the captured width because no fixed px could
* equal the parent across a 375→1920 (5×) span; only auto/% can. Verified by the layout + perceptual
* gates. Block-level = the displays whose `width:auto` FILLS (block/flow-root/flex/grid/list-item) —
* NOT inline/inline-block/table, which shrink-to-fit. */
function fillsBlockContainer(node: IRNode, parentNode: IRNode | undefined, viewports: number[]): boolean {
if (!parentNode || REPLACED.has(node.tag) || node.tag.includes("-")) return false;
const widths: number[] = [];
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]) continue;
if ((cs.display || "") === "none") continue;
// Parent must establish a block formatting context where a child's width:auto fills it.
if (!/^(block|flow-root|list-item)$/.test(pcs.display || "")) return false;
// Node must be block-LEVEL — only then does width:auto fill (inline/table shrink-to-fit).
if (!/^(block|flow-root|flex|grid|list-item)$/.test(cs.display || "")) return false;
const pos = cs.position || "static";
// `sticky` sizes exactly like an in-flow block — its offset is vertical-only and `width:auto`
// still fills the block container — so a sticky full-bleed bar baked to a fixed `width:1280px`
// is fluidisable. Out-of-flow `absolute`/`fixed` auto shrink-wraps to content, so it stays excluded.
if (pos !== "static" && pos !== "relative" && pos !== "sticky") return false;
if ((cs.float || "none") !== "none") return false; // floated auto = shrink-to-fit
if (pf(cs.marginLeft) !== 0 || pf(cs.marginRight) !== 0) return false; // a margin makes the width load-bearing
const content = pb.width - pf(pcs.paddingLeft) - pf(pcs.paddingRight) - pf(pcs.borderLeftWidth) - pf(pcs.borderRightWidth);
if (Math.abs(nb.width - content) > 1.5) return false; // must actually fill the container
widths.push(nb.width);
}
return widths.length >= 2 && Math.max(...widths) - Math.min(...widths) > 8;
}
/** An absolutely/fixed-positioned element spanned by BOTH insets — `left` and `right` are set, so
* with `width:auto` the box stretches between them and its used width is `containing-block left
* right margins`. The engine bakes the resolved px, freezing that stretch and spawning a band as
* the containing block changes width. Dropping width (→ `width:auto`, the absolute default when
* both insets are set) re-derives the identical box at every width — the idiomatic `absolute
* inset-x-0` full-bleed bar. Provable because the width VARIES (a fixed px couldn't), and we guard
* the over-constrained `width:N%` case (where the browser ignores `right`) by checking the box
* actually reaches the `right` inset: its right edge sits `right` px from the parent's right edge.
* Replaced elements keep their intrinsic width. Verified by the layout + perceptual gates. */
function insetSpannedAbsolute(node: IRNode, parentNode: IRNode | undefined, viewports: number[]): boolean {
if (REPLACED.has(node.tag) || node.tag.includes("-")) return false;
const widths: 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 pos = cs.position || "static";
if (pos !== "absolute" && pos !== "fixed") return false;
if (cs.left === "auto" || cs.left == null || cs.right === "auto" || cs.right == null) return false; // both insets must pin the box
// Guard the over-constrained width:% case: only `width:auto` actually reaches the `right` inset.
// If the parent is the containing block, the box's right edge sits `right` px inside it.
const pb = parentNode?.bboxByVp[vp]; const pcs = parentNode?.computedByVp[vp];
if (pb && pcs && /^(relative|absolute|fixed|sticky)$/.test(pcs.position || "")) {
const cbRight = pb.x + pb.width - pf(pcs.borderRightWidth) - pf(pcs.paddingRight);
const expectedRight = cbRight - pf(cs.right) - pf(cs.marginRight);
if (Math.abs((nb.x + nb.width) - expectedRight) > 1.5) return false; // width didn't reach `right` ⇒ over-constrained, keep it
}
widths.push(nb.width);
}
return widths.length >= 2 && Math.max(...widths) - Math.min(...widths) > 8;
}
/** An item capped by an explicit max-width: it FILLS its container below the cap and sits AT the
* cap above it (width = min(container content, cap)). We bake the resolved px per viewport, which
* both freezes it and spawns bands; emitting `width:100%` (the max-width is emitted separately)
* restores `min(100%, cap)` and reproduces every sample. Needs the DENSE/WIDE samples to see the
* cap plateau — at just 4 widths a capped element looks like an ordinary shrinking box. Gated:
* in-flow, border-box, a real px max-width that is actually reached, fills below it, width varies. */
function isMaxWidthFill(node: IRNode, parentNode: IRNode | undefined, viewports: number[]): boolean {
if (!parentNode || REPLACED.has(node.tag) || node.tag.includes("-")) return false;
const cap = pf(node.computedByVp[viewports[0]!]?.maxWidth);
if (!(cap > 0)) return false; // no px max-width → not this pattern
let reachedCap = false; let fellBelow = false; const widths: number[] = [];
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) continue;
if (!node.visibleByVp[vp] || (cs.display || "") === "none") continue; // not painted here → judge only where it renders
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;
if (pf(cs.marginLeft) !== 0 || pf(cs.marginRight) !== 0) return false;
if (Math.abs(pf(cs.maxWidth) - cap) > 1) return false; // a STABLE cap (not itself banded)
const content = pb.width - pf(pcs.paddingLeft) - pf(pcs.paddingRight) - pf(pcs.borderLeftWidth) - pf(pcs.borderRightWidth);
const expected = Math.min(content, cap); // min(100%, max-width)
if (Math.abs(nb.width - expected) > 1.5) return false;
if (Math.abs(nb.width - cap) <= 1.5) reachedCap = true;
if (nb.width < cap - 1.5) fellBelow = true;
widths.push(nb.width);
}
return reachedCap && fellBelow && widths.length >= 2 && Math.max(...widths) - Math.min(...widths) > 8;
}
/** A flex/block item that FILLS its container up to a CAP: border-box width = min(parentContent,
* CAP) at every width — it tracks the parent below the cap and plateaus at CAP above it. The cap
* may come from `max-width` (the nav bar: width:100%; max-width:1200px) OR from `width` (an FAQ
* list: width:700px; max-width:100%); either source renders identically and the faithful, fluid
* emission is the same: `width:100%; max-width:CAP`. This is the generalisation of isMaxWidthFill
* (which needs a px `max-width`) and it MUST win over the content-sized detectors — a capped fill
* looks "content-sized" to them (flex:0 1 auto, never overflows), so they wrongly drop its width
* to `auto`, collapsing the box to its true content (nav links bunch up; the hero input shrinks).
* Distinguished from a PURE fill (no cap → isFillsContainerWidth) by plateauing BELOW the widest
* parent, and from a fixed width by actually filling the parent at the narrow widths. Centred
* caps (margin auto) are left to planWidth case (a). Returns the cap as a px string, or null. */
function fillsToCapWidth(node: IRNode, parentNode: IRNode | undefined, viewports: number[]): string | null {
if (!parentNode || REPLACED.has(node.tag) || node.tag.includes("-")) return null;
const samples: { w: number; content: number }[] = [];
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") continue;
const pos = cs.position || "static";
if (pos !== "static" && pos !== "relative") return null;
if ((cs.float || "none") !== "none") return null;
if ((cs.boxSizing || "border-box") === "content-box") return null;
if (pf(cs.marginLeft) !== 0 || pf(cs.marginRight) !== 0) return null; // margin → load-bearing / centred
const content = pb.width - pf(pcs.paddingLeft) - pf(pcs.paddingRight) - pf(pcs.borderLeftWidth) - pf(pcs.borderRightWidth);
if (content <= 0) return null;
samples.push({ w: nb.width, content });
}
if (samples.length < 2) return null;
const cap = Math.max(...samples.map((s) => s.w));
if (!(cap > 0)) return null;
// The cap must be AUTHORED, not content-driven. A column that merely shrink-wraps to its widest
// child also plateaus (at that child's CONTENT width — an odd sub-pixel value like 764.17), and
// emitting `max-w-[764.17px]` would just relocate the odd-decimal baked px. The reliable signal
// that the cap is a real design constraint is an explicit px `max-width` equal to it; without one
// the box is content-sized and belongs on the `w-auto` path. (A `width:Npx; max-width:100%` box —
// which has no px max-width but IS authored — is handled by keeping its width, not here.)
const mw = node.computedByVp[viewports[0]!]?.maxWidth;
const maxWpx = mw && mw.endsWith("px") ? pf(mw) : 0;
// Authored cap signals: (a) a px max-width equal to the cap (width:100%; max-width:1200px), or
// (b) max-width:100% with the cap landing on a whole pixel — an authored `width:700px` clamped to
// the parent. A content column (no max-width) plateaus at its widest child's fractional content
// width (764.17) and is excluded here so it falls through to `w-auto`.
const authored = (maxWpx > 0 && Math.abs(maxWpx - cap) <= 1.5) || (mw === "100%" && Math.abs(cap - Math.round(cap)) < 0.1);
if (!authored) return null;
let reached = false, filledBelow = false;
for (const s of samples) {
if (Math.abs(s.w - Math.min(s.content, cap)) > 1.5) return null; // width = min(parentContent, cap) everywhere
if (Math.abs(s.w - cap) <= 1.5) reached = true;
if (s.w < cap - 1.5 && Math.abs(s.w - s.content) <= 1.5) filledBelow = true; // actually fills the narrower parent
}
// At the WIDEST parent the box must sit below it (capped); if it still fills there, it's a pure
// fill (no cap) and isFillsContainerWidth should own it instead.
const widest = samples.reduce((a, b) => (b.content > a.content ? b : a));
if (Math.abs(widest.w - widest.content) <= 1.5) return null;
if (!reached || !filledBelow) return null;
return `${Math.round(cap * 100) / 100}px`;
}
/** A width that is a STABLE FRACTION of its container WITHIN each breakpoint regime — and possibly a
* DIFFERENT fraction per regime. Ridge's promo panels are `width:100%` stacked on mobile and
* `width:50%` side-by-side on desktop; the engine bakes `w-[375px] md:w-160 2xl:w-240`, which
* freezes each regime so the panels neither grow on a wide window (stranding a gutter) nor shrink
* between breakpoints. Generalises `planWidth` case (c): (a) works for flex/grid ITEMS too — their
* `%` resolves against the flex/grid container, the case planWidth bails on — and (b) lets the ratio
* change across regimes (emitting one `width:N%` per regime, e.g. `w-1/2 max-md:w-full`). Returns a
* per-viewport pct map (the band machinery collapses equal neighbours) or null.
*
* Proven the frTemplate way: every sample's `ratio×container` must reproduce its captured px, the
* ratio must be a clean fraction in (0.04, 1.02], the width must VARY (else it's fixed → keep baked),
* and — for a flex item — the row must have non-negative slack at every width (the items + gaps don't
* overflow, so flex-shrink never fires and `width:N%` is the stable used width). No horizontal
* margins (those make the width load-bearing). */
function fluidPercentByVp(node: IRNode, parentNode: IRNode | undefined, viewports: number[]): Record<number, string> | null {
if (!parentNode || REPLACED.has(node.tag) || node.tag.includes("-")) return null;
type S = { vp: number; ratio: number; container: number; w: number; flexRow: boolean };
const samples: S[] = [];
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) continue;
if (!node.visibleByVp[vp] || (cs.display || "") === "none" || nb.width <= 0) continue;
const pos = cs.position || "static";
if (pos !== "static" && pos !== "relative") return null; // out-of-flow → % is a different beast
if ((cs.float || "none") !== "none") return null;
if (pf(cs.marginLeft) !== 0 || pf(cs.marginRight) !== 0) return null; // a margin makes the width load-bearing
// The node must be a level where `width:%` actually sizes it: a block-level box, or a flex/grid item.
const pdisp = pcs.display || "";
const flexGridItem = /(?:^|-)(?:flex|grid)$/.test(pdisp);
const blockLevel = /^(block|flow-root|list-item|flex|grid|inline-block)$/.test(cs.display || "");
if (!flexGridItem && !blockLevel) return null;
const container = pb.width - pf(pcs.paddingLeft) - pf(pcs.paddingRight) - pf(pcs.borderLeftWidth) - pf(pcs.borderRightWidth);
if (container <= 0) return null;
const ratio = nb.width / container;
if (!(ratio > 0.04 && ratio < 1.02)) return null; // not a clean fraction of the container
const flexRow = /(?:^|-)flex$/.test(pdisp) && /^(row|row-reverse)$/.test(pcs.flexDirection || "row");
samples.push({ vp, ratio, container, w: nb.width, flexRow });
}
if (samples.length < 2) return null;
const ws = samples.map((s) => s.w);
if (Math.max(...ws) - Math.min(...ws) <= 8) return null; // width must vary (else fixed → keep baked)
// A flex-ROW item only holds `width:N%` if shrink never fires — i.e. the row's in-flow items + gaps
// don't overflow the container at any sampled width (slack ≥ 0). Otherwise the used width is a
// shrink result, not the authored %, and `width:N%` would diverge between the captured widths.
for (const s of samples) {
if (!s.flexRow) continue;
const pcs = parentNode.computedByVp[s.vp]!; const pb = parentNode.bboxByVp[s.vp]!;
const gap = pf(pcs.columnGap && pcs.columnGap !== "normal" ? pcs.columnGap : pcs.gap);
let sum = 0, vis = 0;
for (const c of parentNode.children) {
if (isTextChild(c)) continue;
const ccs = c.computedByVp[s.vp]; const cb = c.bboxByVp[s.vp];
if (!ccs || !cb || !c.visibleByVp[s.vp] || (ccs.display || "") === "none") continue;
const cp = ccs.position || "static";
if (cp === "absolute" || cp === "fixed") continue;
sum += cb.width + pf(ccs.marginLeft) + pf(ccs.marginRight); vis++;
}
if (vis > 0 && sum + gap * (vis - 1) > s.container + 1.5) return null; // row overflows ⇒ shrink fired ⇒ keep baked
}
// Each sample is a clean fraction that reproduces its captured px. Snap to 0.5% and verify.
const out: Record<number, string> = {};
for (const s of samples) {
const pct = Math.round(s.ratio * 200) / 2; // nearest 0.5%
if (Math.abs((pct / 100) * s.container - s.w) > Math.max(1.5, 0.01 * s.w)) return null;
out[s.vp] = `${pct}%`;
}
// Must actually express a fluid law, not just `width:100%` everywhere (that's the fill path's job).
if (Object.values(out).every((p) => p === "100%")) return null;
return out;
}
/** A box that FILLS its container in some viewports but is a fixed-width OVERFLOW track in others — a
* responsive card row that spans its centred max-width wrapper on desktop yet becomes a horizontal
* SCROLLER on mobile (width > container, the parent `overflow:auto`). The plain fill detectors need
* every viewport to fill, so they bail on the mixed regime and bake every band (`w-310` + `2xl:w-325`),
* freezing the desktop width so it gutters / snaps on a wide window instead of growing. Emit a per-vp
* width: `100%` where it fills the container, the baked px where it overflows as a scroll track.
* Proven per vp (100% reproduces a filling box; the px reproduces the overflow track). Requires the
* regime to be genuinely MIXED (≥1 fill vp AND ≥1 overflow vp) so the pure fill / fixed paths own
* the uniform cases. Returns a percentVp-style map, or null. */
function mixedFillByVp(node: IRNode, parentNode: IRNode | undefined, viewports: number[]): Record<number, string> | null {
if (!parentNode || REPLACED.has(node.tag) || node.tag.includes("-")) return null;
const out: Record<number, string> = {};
let fills = 0; let overflow = 0; const ws: number[] = [];
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;
const pos = cs.position || "static";
if (pos !== "static" && pos !== "relative") return null;
if ((cs.float || "none") !== "none") return null;
if (pf(cs.marginLeft) !== 0 || pf(cs.marginRight) !== 0) return null; // a margin makes the width load-bearing
if (!/^(block|flow-root|list-item|flex|grid|inline-block)$/.test(cs.display || "")) return null;
const container = pb.width - pf(pcs.paddingLeft) - pf(pcs.paddingRight) - pf(pcs.borderLeftWidth) - pf(pcs.borderRightWidth);
if (container <= 0) return null;
ws.push(nb.width);
if (Math.abs(nb.width - container) <= Math.max(1.5, 0.01 * container)) { out[vp] = "100%"; fills++; }
else if (nb.width > container * 1.02 && cs.width && /px$/.test(cs.width)) { out[vp] = cs.width; overflow++; } // scroll track
else return null; // a partial fraction → not this law
}
if (fills < 1 || overflow < 1) return null; // must be genuinely mixed
if (ws.length < 2 || Math.max(...ws) - Math.min(...ws) <= 8) return null;
return out;
}
/** A CONTENT-SIZED flex row: a `flex: 0 1 auto` row whose items size to their own content (the
* default), so their width varies across viewports because the CONTENT reflows — not because of
* any authored per-viewport width. We bake the resolved px, freezing them and spawning a band
* wall. The fix is `width:auto` — let content drive each item again. It's provably the captured
* width when, at EVERY sampled width, the visible items + gaps + margins do NOT overflow the
* container (slack ≥ 0 ⇒ no flex-shrink fired ⇒ each item is already at its content size). And it
* must be all-or-nothing per line: dropping ONE item's width changes the line's free space and
* shifts its siblings (via justify-content); dropping the WHOLE line leaves free space unchanged,
* so nothing moves. Returns the set of item ids to set `width:auto`, or null. */
function contentSizedFlexRow(container: IRNode, viewports: number[]): Set<string> | null {
if (viewports.length < 3) return null;
const ccs = container.computedByVp[viewports[0]!];
if (!ccs || !/^(flex|inline-flex)$/.test(ccs.display || "")) return null;
const dir = ccs.flexDirection || "row";
if (dir !== "row" && dir !== "row-reverse") return null; // width is the main axis only for rows
if ((ccs.flexWrap || "nowrap") !== "nowrap") return null; // multi-line ≠ one row
const items: IRNode[] = [];
for (const c of container.children) {
if (isTextChild(c)) continue;
const cs = c.computedByVp[viewports[0]!]; if (!cs) continue;
const pos = cs.position || "static";
if (pos === "absolute" || pos === "fixed") continue; // out-of-flow → not in the flex line
if ((cs.float || "none") !== "none") continue;
if (cs.marginLeft === "auto" || cs.marginRight === "auto") return null; // auto margins absorb free space — different case
if (pf(cs.flexGrow) > 0) return null; // a grower fills the slack, so items aren't content-sized
items.push(c);
}
if (items.length < 1) return null;
// grow must stay 0 across viewports (else slack is consumed by growth, not left as content slack).
for (const vp of viewports) for (const it of items) {
const cs = it.computedByVp[vp]; if (cs && pf(cs.flexGrow) > 0) return null;
}
// At every width: the visible items + gaps + margins must not overflow the container (slack ≥ 0).
for (const vp of viewports) {
const pcs = container.computedByVp[vp]; const pb = container.bboxByVp[vp];
if (!pcs || !pb) return null;
const content = pb.width - pf(pcs.paddingLeft) - pf(pcs.paddingRight) - pf(pcs.borderLeftWidth) - pf(pcs.borderRightWidth);
const gap = pf(pcs.columnGap && pcs.columnGap !== "normal" ? pcs.columnGap : pcs.gap);
let sum = 0; let vis = 0;
for (const it of items) {
const cs = it.computedByVp[vp]; const b = it.bboxByVp[vp];
if (!cs || !b || !it.visibleByVp[vp] || (cs.display || "") === "none") continue;
sum += b.width + pf(cs.marginLeft) + pf(cs.marginRight); vis++;
}
if (!vis) continue;
if (sum + gap * (vis - 1) > content + 1.5) return null; // overflow ⇒ shrink fired ⇒ not pure content size
}
// Worthwhile only if some item's visible width actually varies (else there were no bands).
if (!items.some((it) => { const ws = viewports.map((vp) => it.visibleByVp[vp] && it.computedByVp[vp]?.display !== "none" ? it.bboxByVp[vp]?.width : undefined).filter((x): x is number => x !== undefined); return ws.length >= 2 && Math.max(...ws) - Math.min(...ws) > 8; })) return null;
return new Set(items.map((it) => it.id));
}
/** True when the node's in-flow ELEMENT children leave horizontal free space inside its content box
* at every sampled width — i.e. the children do NOT fill it. Such a width is LOAD-BEARING: it
* positions the children via auto margins or justify-content, so dropping it to `auto` (content
* shrink-wrap) would collapse the box onto its content and destroy that spacing. The marquee
* testimonial cards are centred by `mx-auto` inside a wrapper 16px wider than the card — dropping
* the wrapper's width removed the inter-card gap. (getComputedStyle resolves `margin:auto` to px,
* so the only reliable signal is geometric: child extent < content-box width.) Nodes that carry
* their own text are excluded — text fills the box, so it is genuinely content-sized. */
function hasInteriorFreeSpaceX(node: IRNode, viewports: number[]): boolean {
if (node.children.some((c) => isTextChild(c) && c.text.trim() !== "")) return false;
let sampled = 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 contentW = nb.width - pf(cs.paddingLeft) - pf(cs.paddingRight) - pf(cs.borderLeftWidth) - pf(cs.borderRightWidth);
if (contentW <= 0) return false;
let minL = Infinity, maxR = -Infinity, kids = 0;
for (const c of node.children) {
if (isTextChild(c)) continue;
const ccs = c.computedByVp[vp]; const cb = c.bboxByVp[vp];
if (!ccs || !cb || !c.visibleByVp[vp] || (ccs.display || "") === "none") continue;
if (ccs.position === "absolute" || ccs.position === "fixed") continue;
kids++; minL = Math.min(minL, cb.x); maxR = Math.max(maxR, cb.x + cb.width);
}
if (kids === 0) return false; // no element children → nothing to leave free space
if (maxR - minL >= contentW - 2) return false; // children fill it → genuinely content-sized
sampled++;
}
return sampled >= 2;
}
/** The sizing probe's verdict for an element's WIDTH, aggregated over the
* viewports where it's painted: "auto" if dropping the width re-derives the box at EVERY such width
* (content / block-fill), "fill" if width:100% does (a fill), else null (load-bearing, or no probe
* data — fall through to the detector cascade). Unanimity is required: a width that's content at one
* width but authored at another is a genuine responsive width and is kept. */
function sizingVerdict(node: IRNode, viewports: number[]): "auto" | "fill" | null {
let any = false, allAuto = true, allFill = true;
for (const vp of viewports) {
// Decide from the viewports the PROBE actually read, not the source's visibleByVp. The flags come
// from the clone render; when source and clone disagree on visibility at a breakpoint (a node the
// clone hides via display:none has no reading), keying off source-visible-but-no-flag bailed to
// null and kept a baked decimal width that was provably content (wAuto at every width we measured).
// A flag's presence means the clone painted+probed it; absence means hidden/animated/replaced there,
// where the width is moot anyway. Unanimity among present flags still guards genuine responsive widths.
const s = node.sizingByVp?.[vp];
if (!s) continue;
any = true;
if (!s.wAuto) allAuto = false;
if (!s.wFill) allFill = false;
}
if (!any) return null;
return allAuto ? "auto" : allFill ? "fill" : null; // content wins over fill when both hold
}
/** Does this subtree contain wrappable text? Text that can wrap to a different number of lines is the
* one thing whose rendered height our clone can't reproduce to the pixel — sub-pixel font-metric
* differences flip a line at narrow widths, and a dropped height then lets the box grow and cascade
* every sibling below it (the 72px vp375 drift). A box whose height is propped only by NON-text
* content (images, fixed-size children, spacers) reproduces exactly, so its height is safe to drop.
* Short runs (≤ a dozen chars: a button label, a single word) can't wrap and don't count. */
function hasWrappableText(node: IRNode): boolean {
for (const c of node.children) {
if (isTextChild(c)) { if (c.text.trim().length > 12) return true; }
else if (hasWrappableText(c)) return true;
}
return false;
}
/** The inset-anchor probe's verdict: the set of sides (top/right/bottom/left)
* that setting to `auto` left the box exactly in place at EVERY painted viewport — i.e. filled-in
* USED values (`bottom` = page height, `right` = viewport width) that should not be baked. A side
* that moves the box at any painted width is the authored anchor and is kept. Unanimity guards
* against a side that's redundant at one width but load-bearing at another. */
const INSET_SIDES = ["top", "right", "bottom", "left"] as const;
function insetDropSides(node: IRNode, viewports: number[]): Set<string> {
const drop = new Set<string>(INSET_SIDES);
let any = false;
for (const vp of viewports) {
if (!node.visibleByVp[vp]) continue;
const id = node.sizingByVp?.[vp]?.insetDrop;
if (!id) return new Set(); // a painted viewport with no inset reading ⇒ decide nothing
any = true;
for (const s of INSET_SIDES) if (!id[s]) drop.delete(s);
}
return any ? drop : new Set();
}
/** A horizontally-centred absolute/fixed OVERLAY: it carries a `translate` whose X component is -50%
* (the `left:50%; translate:-50%` centring idiom) and its `left` is a CONSTANT FRACTION of its
* containing block across viewports. The engine bakes the resolved px (`left-155` + `2xl:left-162.5`),
* which freezes the centre so the box drifts off-centre BETWEEN breakpoints even though the translate
* is correct at sampled widths but can drift between breakpoints. Emit `left:N%` so it tracks the
* centre at every width. `cbAncestor` is the nearest positioned ancestor (its padding box is the
* containing block); the viewport for `fixed`. Returns the `%` string, proven against every sample. */
function centerLeftPct(node: IRNode, cbAncestor: IRNode | undefined, viewports: number[]): string | null {
if (node.tag.includes("-")) return null;
const samples: { left: number; cbW: 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" || nb.width <= 0) continue;
const pos = cs.position || "static";
if (pos !== "absolute" && pos !== "fixed") return null;
if (!hasCenteringTransform(node, vp, "x")) return null;
const left = cs.left;
if (!left || left === "auto" || !/px$/.test(left)) return null; // need a baked px left to replace
let cbW: number;
if (pos === "fixed" || !cbAncestor) cbW = vp; // fixed → viewport
else {
const pcs = cbAncestor.computedByVp[vp]; const pb = cbAncestor.bboxByVp[vp];
if (!pcs || !pb) return null;
cbW = pb.width - pf(pcs.borderLeftWidth) - pf(pcs.borderRightWidth); // containing block = padding box
}
if (!(cbW > 0)) return null;
samples.push({ left: pf(left), cbW });
}
if (samples.length < 2) return null;
const lefts = samples.map((s) => s.left);
if (Math.max(...lefts) - Math.min(...lefts) <= 4) return null; // left must vary (else genuinely fixed)
const ratios = samples.map((s) => s.left / s.cbW);
const mean = ratios.reduce((a, b) => a + b, 0) / ratios.length;
if (!(mean > 0.02 && mean < 0.98)) return null;
if (Math.max(...ratios) - Math.min(...ratios) > 0.01) return null; // a CONSTANT fraction
const pct = Math.round(mean * 1000) / 10;
for (const s of samples) if (Math.abs((pct / 100) * s.cbW - s.left) > Math.max(1.5, 0.01 * s.left)) return null; // reproduces
return `${pct}%`;
}
function fixedWidthButtonLike(node: IRNode, viewports: number[]): boolean {
if (node.tag !== "a" && node.tag !== "button") return false;
const widths: number[] = [];
let sampled = 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") continue;
sampled++;
if (!/^(flex|inline-flex|block|inline-block)$/.test(cs.display || "")) return false;
if (!/pointer/.test(cs.cursor || "")) return false;
if (pf(cs.borderTopLeftRadius) < 12 && pf(cs.borderTopRightRadius) < 12) return false;
if (bb.width < 120 || bb.height < 28) return false;
widths.push(bb.width);
}
return sampled >= 2 && widths.length >= 2 && Math.max(...widths) - Math.min(...widths) <= 2;
}
function fixedHeightButtonLike(node: IRNode, viewports: number[]): boolean {
if (node.tag !== "a" && node.tag !== "button") return false;
const heights: number[] = [];
let sampled = 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") continue;
sampled++;
if (!/^(flex|inline-flex|block|inline-block)$/.test(cs.display || "")) return false;
if (!/pointer/.test(cs.cursor || "")) return false;
if (!cs.height || !/px$/.test(cs.height)) return false;
if (bb.height < 24) return false;
heights.push(bb.height);
}
return sampled >= 2 && heights.length >= 2 && Math.max(...heights) - Math.min(...heights) <= 1;
}
function transformTranslatePx(value: string | undefined): { x: number; y: number } | null {
if (!value || value === "none") return null;
let m = /^matrix\(\s*1\s*,\s*0\s*,\s*0\s*,\s*1\s*,\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*\)$/.exec(value);
if (m) return { x: +m[1]!, y: +m[2]! };
m = /^translate(?:3d)?\(\s*(-?[\d.]+)px(?:\s*,\s*(-?[\d.]+)px)?/.exec(value);
if (m) return { x: +m[1]!, y: +(m[2] ?? 0) };
return null;
}
function hasCenteringTransform(node: IRNode, vp: number, axis: "x" | "y"): boolean {
const cs = node.computedByVp[vp]; const nb = node.bboxByVp[vp];
if (!cs || !nb) return false;
const translate = (cs.translate || "none").trim();
if (axis === "x" && /^-50%(\s|$)/.test(translate)) return true;
if (axis === "y" && /^\S+\s+-50%(\s|$)/.test(translate)) return true;
const tr = transformTranslatePx(cs.transform);
if (!tr) return false;
return axis === "x"
? Math.abs(tr.x + nb.width / 2) <= Math.max(1.5, nb.width * 0.01)
: Math.abs(tr.y + nb.height / 2) <= Math.max(1.5, nb.height * 0.01);
}
/** A centered absolute/fixed element with edge guards: `left/top: clamp(MIN, 50%, 100% - MIN)`.
* This is the source-like law for large floating mockup windows that stay centered in roomy frames
* but pin to a minimum inset inside narrow frames. The transform provides the actual centering. */
function centeredInsetClamp(node: IRNode, cbAncestor: IRNode | undefined, viewports: number[], axis: "x" | "y"): Record<number, string> | null {
if (REPLACED.has(node.tag) || node.tag.includes("-")) return null;
type S = { vp: number; inset: number; span: number };
const samples: S[] = [];
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 pos = cs.position || "static";
if (pos !== "absolute" && pos !== "fixed") return null;
if (!hasCenteringTransform(node, vp, axis)) return null;
const raw = axis === "x" ? cs.left : cs.top;
if (!raw || raw === "auto" || !/px$/.test(raw)) return null;
let span: number;
if (pos === "fixed" || !cbAncestor) span = axis === "x" ? vp : viewportHeightFor(vp);
else {
const pcs = cbAncestor.computedByVp[vp]; const pb = cbAncestor.bboxByVp[vp];
if (!pcs || !pb) return null;
span = axis === "x"
? pb.width - pf(pcs.borderLeftWidth) - pf(pcs.borderRightWidth)
: pb.height - pf(pcs.borderTopWidth) - pf(pcs.borderBottomWidth);
}
if (!(span > 0)) return null;
samples.push({ vp, inset: pf(raw), span });
}
if (samples.length < 2) return null;
const insets = samples.map((s) => s.inset);
if (Math.max(...insets) - Math.min(...insets) <= 4) return null;
const minInset = Math.min(...insets);
const pctCandidates = new Set<number>();
for (const s of samples) {
const pct = cleanPct((s.inset / s.span) * 100);
if (pct > 2 && pct < 98) pctCandidates.add(pct);
}
for (const pct of pctCandidates) {
const ok = samples.every((s) => {
const preferred = s.span * pct / 100;
const maxInset = s.span - minInset;
const predicted = Math.max(minInset, Math.min(preferred, maxInset));
return Math.abs(predicted - s.inset) <= Math.max(1.5, 0.01 * Math.max(s.inset, 1));
});
if (!ok) continue;
const css = `clamp(${fmtPx(minInset)}, ${pct}%, calc(100% - ${fmtPx(minInset)}))`;
const out: Record<number, string> = {};
for (const s of samples) out[s.vp] = css;
return out;
}
return null;
}
/** The sizing probe's verdict for HEIGHT: true when height:auto re-derives the box at every painted
* viewport — an authored-taller control (a button) reads false and keeps its height. Restricted to
* boxes with no wrappable text (see hasWrappableText): the probe's per-element 0.5px reproduction is
* reliable for structural/media boxes, but a text box can reflow differently in our clone and a
* dropped height then cascades. Text boxes keep their height here; heightFlows still drops the
* varying ones it can structurally prove, and the text-leaf path drops clean line-multiple leaves. */
function heightProbeDrops(node: IRNode, viewports: number[], reflow = false): boolean {
// Default (gate-safe): keep heights on wrappable-text boxes — our clone can wrap to a different line
// count and a dropped height then cascades. In reflow mode we flow them too, accepting the position
// drift the perceptual gate proves is invisible.
if (!reflow && hasWrappableText(node)) return false;
let any = false;
for (const vp of viewports) {
if (!node.visibleByVp[vp]) continue;
const s = node.sizingByVp?.[vp];
if (!s || !s.hAuto) return false;
any = true;
}
return any;
}
/** A pure TEXT leaf that is a flex-COLUMN item aligned to its content (not stretched) — its width IS
* its text's content width, so `width:auto` re-derives it (and re-flows when the copy changes). The
* flex-ROW path is contentSizedFlexItemAuto; the column cross-axis isn't covered there, and
* contentSizedColumnItem only handles element children (it measures their extent), so a text-only
* column item — footer links like "Overview"/"Product" — kept its baked content px. Gated: parent
* is a flex column, the item is not stretched (else it'd fill → w-full) and does NOT fill the
* parent, and has no auto margins. */
function isContentSizedColumnTextLeaf(node: IRNode, parentNode: IRNode | undefined, viewports: number[]): boolean {
if (!parentNode || REPLACED.has(node.tag) || node.tag.includes("-")) return false;
if (node.children.some((c) => !isTextChild(c))) return false; // pure text leaf only
if (!node.children.some((c) => isTextChild(c) && c.text.trim() !== "")) return false;
let sampled = 0;
for (const vp of viewports) {
const cs = node.computedByVp[vp]; const nb = node.bboxByVp[vp];
const pcs = parentNode.computedByVp[vp]; const pb = parentNode.bboxByVp[vp];
if (!cs || !nb || !pcs || !pb || !node.visibleByVp[vp] || (cs.display || "") === "none") continue;
if (!/^(flex|inline-flex)$/.test(pcs.display || "")) return false;
if (!/^column/.test(pcs.flexDirection || "row")) return false; // column ⇒ width is the cross axis
const pos = cs.position || "static";
if (pos !== "static" && pos !== "relative") return false;
if (cs.marginLeft === "auto" || cs.marginRight === "auto") return false;
const as = cs.alignSelf && cs.alignSelf !== "auto" ? cs.alignSelf : (pcs.alignItems || "stretch");
if (/stretch/.test(as)) return false; // stretched ⇒ fills (w-full), not content
const pcontent = pb.width - pf(pcs.paddingLeft) - pf(pcs.paddingRight) - pf(pcs.borderLeftWidth) - pf(pcs.borderRightWidth);
if (nb.width >= pcontent - 2) return false; // fills parent ⇒ not content-sized
sampled++;
}
return sampled >= 1;
}
/** An inline-level box (inline-flex / inline-block / inline-grid) shrink-wraps to its content by CSS
* definition. A baked `width` just freezes that — and worse, clamps the content so the box's own
* padding gets eaten (the "Start for Free" button rendered with its px-3 squashed because the frozen
* 116px content-box was narrower than the clone's text + padding needs). Dropping the width lets it
* size to content again, padding intact. The cascade's `lockWidth` still protects an inline box that
* has genuine interior free space; replaced/custom elements keep their intrinsic size. */
function isInlineContentBox(node: IRNode, viewports: number[]): boolean {
if (REPLACED.has(node.tag) || node.tag.includes("-")) return false;
let sampled = 0;
for (const vp of viewports) {
const cs = node.computedByVp[vp];
if (!cs || !node.visibleByVp[vp] || (cs.display || "") === "none") continue;
if (!/^inline-(flex|block|grid)$/.test(cs.display || "")) return false;
const pos = cs.position || "static";
if (pos !== "static" && pos !== "relative") return false;
if (cs.marginLeft === "auto" || cs.marginRight === "auto") return false;
sampled++;
}
return sampled >= 2;
}
/** A content-sized item on its CROSS axis: a flex-COLUMN child (cross axis = width) whose width is
* driven by its own content, so `width:auto` re-derives it (content reflow, not a fixed px). Unlike
* a flex ROW, column items don't share main-axis free space, so this is safe per item (no sibling
* shift). It may even *fill* the container at the narrow widths (a content box that happens to be
* as wide as its parent) and fall short at the wide ones — `width:auto` reproduces both. We require
* its SHARE of the container to vary: a constant share is a percentage width (auto wouldn't
* reproduce), and a constant 1.0 is a true stretch (isFillsContainerWidth's job → width:100%).
* Verified visually by the layout + perceptual gates. */
function contentSizedColumnItem(node: IRNode, parentNode: IRNode | undefined, viewports: number[]): boolean {
if (!parentNode || REPLACED.has(node.tag) || node.tag.includes("-")) return false;
const widths: number[] = []; const ratios: number[] = [];
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") continue;
if (!/^(flex|inline-flex)$/.test(pcs.display || "")) return false;
const dir = pcs.flexDirection || "row";
if (dir !== "column" && dir !== "column-reverse") return false; // cross axis must be width
const pos = cs.position || "static";
if (pos !== "static" && pos !== "relative") return false;
if ((cs.float || "none") !== "none") return false;
if (cs.marginLeft === "auto" || cs.marginRight === "auto") return false;
if ((cs.boxSizing || "border-box") === "content-box") return false;
const content = pb.width - pf(pcs.paddingLeft) - pf(pcs.paddingRight) - pf(pcs.borderLeftWidth) - pf(pcs.borderRightWidth);
if (content <= 0 || nb.width > content + 1.5) return false; // overflows the container → not content-sized
if (nb.width >= content - 1.5) continue; // fills the cross axis (stretch) — not content-sizing AT this vp
widths.push(nb.width); ratios.push(nb.width / content);
}
// After excluding stretch/fill viewports, a content-sized item's width still TRACKS its content
// (varies as text reflows). A width that is CONSTANT across the remaining viewports is a fixed
// width that merely fills/stretches at narrow widths (a `width:268px` CTA that goes full-width on
// mobile) — NOT content-sized; keep it so the desktop box doesn't collapse to the label.
if (widths.length < 2 || Math.max(...widths) - Math.min(...widths) <= 8) return false;
if (Math.max(...ratios) - Math.min(...ratios) < 0.03) return false; // constant share → percentage or true stretch, not content
return true;
}
/** A single flex-ROW item that is provably CONTENT-sized on the main axis, so `width:auto`
* reproduces it and the baked per-viewport px is just frozen content reflow (these are the menu /
* toolbar items whose px width changes only because their label reflows or they appear only at some
* widths). Per-item and cascade-free because we convert ONLY `flex:none` items — flex-grow:0,
* flex-shrink:0, flex-basis:auto — whose main size is ALWAYS their content size at every width,
* independent of siblings, so dropping the px shifts nothing. Two more guards make it provable:
* the width must VARY (else there's no band to remove), and its SHARE of the container must vary
* too — a constant ratio would be a percentage width, which `width:auto` would NOT reproduce.
* Replaced elements keep their intrinsic width. Verified by the layout + perceptual gates. */
function contentSizedFlexItemAuto(node: IRNode, parentNode: IRNode | undefined, viewports: number[]): boolean {
if (!parentNode || REPLACED.has(node.tag) || node.tag.includes("-")) return false;
const widths: number[] = []; const ratios: number[] = []; let canShrink = 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") continue;
if (!/^(flex|inline-flex)$/.test(pcs.display || "")) return false;
const dir = pcs.flexDirection || "row";
if (dir !== "row" && dir !== "row-reverse") return false; // width = main axis only for rows
const pos = cs.position || "static";
if (pos !== "static" && pos !== "relative") return false;
if ((cs.float || "none") !== "none") return false;
if (cs.marginLeft === "auto" || cs.marginRight === "auto") return false;
if (pf(cs.flexGrow) > 0) return false; // a grower expands past content
if (pf(cs.flexShrink) !== 0) canShrink = true; // a shrinker MAY drop below content (guarded below)
if ((cs.flexBasis || "auto") !== "auto") return false; // an explicit basis is authored size
const content = pb.width - pf(pcs.paddingLeft) - pf(pcs.paddingRight) - pf(pcs.borderLeftWidth) - pf(pcs.borderRightWidth);
if (content <= 0) return false;
widths.push(nb.width); ratios.push(nb.width / content);
}
if (widths.length < 2) return false;
// A CONSTANT share across a VARYING container is a percentage width (width:N%), which width:auto
// would not reproduce — exclude it. But a genuinely constant container can't distinguish percent
// from content, so only apply this when the container actually varies. (A constant content width —
// a button whose label never changes — is no longer rejected: the baked odd-decimal px it leaves
// is exactly what we want to drop; the slack + lockWidth guards keep auto safe.)
const conts = viewports.map((vp) => { const pb = parentNode.bboxByVp[vp]; const pcs = parentNode.computedByVp[vp]; return pb && pcs ? pb.width - pf(pcs.paddingLeft) - pf(pcs.paddingRight) : 0; }).filter((x) => x > 0);
const containerVaries = conts.length >= 2 && Math.max(...conts) - Math.min(...conts) > 8;
if (containerVaries && Math.max(...ratios) - Math.min(...ratios) < 0.03) return false; // constant share ⇒ percentage
// A shrinkable item (flex:0 1 auto, the default) is only proven to be AT its content size if its
// line never had to shrink anyone — i.e. there is positive free space at every width. With slack,
// nothing shrank, so the measured width IS the content width and `width:auto` reproduces it.
if (canShrink && !flexRowHasSlack(parentNode, viewports)) return false;
return true;
}
/** True when a flex ROW leaves positive free space (no item had to flex-shrink) at every width:
* the visible in-flow items + their margins + the gaps sum to strictly LESS than the container's
* content width. Used to prove a shrinkable content item never actually shrank. */
function flexRowHasSlack(container: IRNode, viewports: number[]): boolean {
let checked = 0;
for (const vp of viewports) {
const pcs = container.computedByVp[vp]; const pb = container.bboxByVp[vp];
if (!pcs || !pb) continue;
if (!/^(flex|inline-flex)$/.test(pcs.display || "")) return false;
const content = pb.width - pf(pcs.paddingLeft) - pf(pcs.paddingRight) - pf(pcs.borderLeftWidth) - pf(pcs.borderRightWidth);
if (content <= 0) continue;
const gap = pf(pcs.columnGap && pcs.columnGap !== "normal" ? pcs.columnGap : pcs.gap);
let sum = 0; let vis = 0;
for (const it of container.children) {
if (isTextChild(it)) continue;
const cs = it.computedByVp[vp]; const b = it.bboxByVp[vp];
if (!cs || !b || !it.visibleByVp[vp] || (cs.display || "") === "none") continue;
const pos = cs.position || "static";
if (pos === "absolute" || pos === "fixed") continue;
sum += b.width + pf(cs.marginLeft) + pf(cs.marginRight); vis++;
}
if (!vis) continue;
if (sum + gap * Math.max(0, vis - 1) > content - 1.5) return false; // packed/overflowing ⇒ shrink may have fired
checked++;
}
return checked >= 1;
}
/** Whether a node's height can FLOW (drop the baked px) — true when, at every sampled width, its
* border-box height is either content-driven (== the extent of its in-flow children + padding) or
* cross-axis STRETCH (a flex-row / grid item filling its container's content height). Both
* re-derive the identical box when the height is dropped, so the only thing the measured px adds
* is a frozen value + a band. Excludes replaced/clipped/positioned/explicitly-taller boxes, where
* the height is load-bearing. Geometry fidelity is verified by the layout + perceptual gates. */
function heightFlows(node: IRNode, parentNode: IRNode | undefined, viewports: number[]): boolean {
if (REPLACED.has(node.tag) || node.tag === "canvas" || node.tag.includes("-")) return false;
const hasText = node.children.some(isTextChild);
let sampled = 0; let varies = false; const hs: number[] = [];
for (const vp of viewports) {
const cs = node.computedByVp[vp]; const nb = node.bboxByVp[vp];
if (!cs || !nb || !node.visibleByVp[vp]) continue;
const pos = cs.position || "static";
if (pos !== "static" && pos !== "relative") return false;
if ((cs.overflowY || cs.overflow || "visible") !== "visible") return false;
if ((cs.display || "") === "none") continue;
// The sizing probe proved this height AUTHORED-EXPLICIT: height:auto did NOT reproduce the box
// (hAuto:false) and the box is not a fill child of its own parent (hFill:false). The measured
// px is then load-bearing — dropping it collapses the box. This overrides the structural flow
// reasoning below, which can read the height as content-driven via a CIRCULAR pair (two nested
// authored-height boxes each "explaining" the other's extent) and drop both. Bail so the
// authored height survives. (heightProbeDrops, OR'd with this at the call site, also refuses to
// drop an hAuto:false box — so the two signals agree and the authored px is kept.)
const sz = node.sizingByVp?.[vp];
if (sz && sz.hAuto === false && sz.hFill === false) return false;
// A flex COLUMN that DISTRIBUTES free space (justify-content space-between/around/evenly, or the
// packed alignments that pin the last child away from the top): the box height is LOAD-BEARING —
// it sets the free space the children spread through. The content-extent check below would read
// the last child reaching the box bottom as "content-sized", but that extent only equals the box
// bottom BECAUSE the distribution pushed it there. Dropping the height collapses the gaps.
if (/flex/.test(cs.display || "") && /column/.test(cs.flexDirection || "row") &&
/^(space-between|space-around|space-evenly|center|flex-end|end)$/.test(cs.justifyContent || "")) {
return false;
}
// A flex COLUMN whose in-flow child fills it via flex-grow: the box's height is LOAD-BEARING,
// not content-derived. The content-extent check below would read the box as content-sized — but
// that extent IS the grown child, which only reaches the box bottom BECAUSE the box has this
// height (flex-grow distributes the box's free space). Dropping the height collapses the child to
// its own content (the changelog card <a grow> inside its <article> shrank to its text on a short
// entry → uneven cards). Keep the height.
if (/flex/.test(cs.display || "") && /column/.test(cs.flexDirection || "row") &&
node.children.some((c) => !isTextChild(c) && c.visibleByVp[vp] && (c.computedByVp[vp]?.display || "") !== "none" && pf(c.computedByVp[vp]?.flexGrow) > 0)) {
return false;
}
sampled++; hs.push(nb.height);
// content extent of the in-flow children — both the border-box bottom and the bottom WITH the
// child's trailing margin-bottom, because whether that margin extends the box (height includes
// it) or collapses through depends on the box; we accept the box if its height matches EITHER.
let bottom = nb.y + pf(cs.paddingTop) + pf(cs.borderTopWidth); let bottomMargin = bottom; let hasChild = false;
// Are ALL in-flow element children FILL children (height:100% / probe hFill) whose height DERIVES
// from THIS box? Then their extent reaching the box bottom is not content evidence — it is the
// box's own authored height reflected back (a `h-full aspect-video` hero image inside a fixed-height
// header). Dropping the height then lets the fill child re-derive from its aspect ratio and inflate
// (a 240px hero → 720px @16/9). Do NOT flow such a box's height.
let allInflowFill = true;
for (const c of node.children) {
if (isTextChild(c)) continue;
const ccs = c.computedByVp[vp]; const cb = c.bboxByVp[vp];
if (!ccs || !cb || !c.visibleByVp[vp] || (ccs.display || "") === "none") continue;
if (ccs.position === "absolute" || ccs.position === "fixed") continue;
if ((ccs.float || "none") !== "none") continue;
hasChild = true;
const csz = c.sizingByVp?.[vp];
if (!(csz && csz.hFill === true && csz.hAuto === false)) allInflowFill = false;
bottom = Math.max(bottom, cb.y + cb.height);
bottomMargin = Math.max(bottomMargin, cb.y + cb.height + Math.max(0, pf(ccs.marginBottom)));
}
// Authored height whose only in-flow children fill it (circular): keep the height (don't flow).
// Guarded by the parent's own probe: hAuto===false means auto did NOT reproduce the box → real
// authored height, not a content coincidence.
if (hasChild && allInflowFill && node.sizingByVp?.[vp]?.hAuto === false) return false;
const pad = pf(cs.paddingBottom) + pf(cs.borderBottomWidth);
const contentH = bottom - nb.y + pad; const contentHMargin = bottomMargin - nb.y + pad;
// Content-driven when the box is exactly its in-flow children's extent — with OR without their
// trailing margin (≤2px — realistic line-box/rounding slop; the layout per-leaf-position +
// responsive gates backstop any drift) — OR when it has only TEXT/inline content (no element
// children but real text): then its height IS its line-box content and `height:auto` re-derives
// it. An empty box (no children, no text) with a height is an authored spacer — left alone.
const contentDriven = (hasChild && (Math.abs(nb.height - contentH) <= 2 || Math.abs(nb.height - contentHMargin) <= 2)) || (!hasChild && hasText);
// stretch: a flex-row / grid item whose height fills the container's content height
let stretched = false;
const pcs = parentNode?.computedByVp[vp]; const pb = parentNode?.bboxByVp[vp];
if (pcs && pb && /flex|grid/.test(pcs.display || "")) {
const dir = pcs.flexDirection || "row";
const crossIsHeight = /grid/.test(pcs.display || "") || dir === "row" || dir === "row-reverse";
if (crossIsHeight) {
const pContentH = pb.height - pf(pcs.paddingTop) - pf(pcs.paddingBottom) - pf(pcs.borderTopWidth) - pf(pcs.borderBottomWidth);
if (Math.abs(nb.height - pContentH) <= 2) stretched = true;
}
}
if (!contentDriven && !stretched) return false;
}
if (sampled < 2) return false;
varies = Math.max(...hs) - Math.min(...hs) > 8;
return varies; // only flow when it actually removes a band (a constant height is left alone)
}
/** Does this node's PARENT layout auto-stretch the node's HEIGHT to fill the cross axis? A grid item
* (default/normal align ⇒ stretch) or a flex item in a ROW-direction flex fills the cross (height)
* at height:auto — no explicit 100% needed. A block / flex-COLUMN / inline parent does NOT, so a
* child that fills it needs an explicit height:100%. Per viewport. */
function parentAutoStretchesHeight(node: IRNode, parentNode: IRNode | undefined, vp: number): boolean {
const pcs = parentNode?.computedByVp[vp]; const cs = node.computedByVp[vp];
if (!pcs || !cs) return false;
const pd = pcs.display || "";
const self = cs.alignSelf && cs.alignSelf !== "auto" ? cs.alignSelf : (pcs.alignItems || "normal");
const stretch = self === "stretch" || self === "normal";
if (/grid/.test(pd)) return stretch;
if (/flex/.test(pd)) { const dir = pcs.flexDirection || "row"; return stretch && (dir === "row" || dir === "row-reverse"); }
return false;
}
/** A height:100% filler: this node fills its parent's content-box HEIGHT at every painted viewport (a
* card filling its stretched grid cell, a figure filling that card). The faithful emission is
* `height:100%` — NOT the baked px (freezes, bands) and NOT `auto` (a block/flex-column parent does
* not stretch it, so auto collapses it to content — the uneven-card bug: the long-quote figures had
* their px height dropped to auto and grew past their pinned neighbours). Gated so 100% actually
* resolves and reproduces the captured px:
* • parent height is DEFINITE (threaded: under a grid/flex-stretch cell or another 100% filler),
* • parent does NOT already auto-stretch this node (else auto fills — leave the existing path),
* • the box fills the parent content height (≤2px) at every painted vp, and the height VARIES
* (so 100% removes a baked band; a constant filler is left alone). */
function isHeightFill(node: IRNode, parentNode: IRNode | undefined, parentDefiniteHeight: boolean, viewports: number[]): boolean {
if (!parentDefiniteHeight || !parentNode || REPLACED.has(node.tag) || node.tag === "canvas" || node.tag.includes("-")) return false;
const hs: number[] = [];
for (const vp of viewports) {
const cs = node.computedByVp[vp]; const nb = node.bboxByVp[vp];
const pcs = parentNode.computedByVp[vp]; const pb = parentNode.bboxByVp[vp];
if (!cs || !nb || !pcs || !pb || !node.visibleByVp[vp] || (cs.display || "") === "none") continue;
const pos = cs.position || "static";
if (pos !== "static" && pos !== "relative") return false;
if ((cs.float || "none") !== "none") return false;
if (parentAutoStretchesHeight(node, parentNode, vp)) return false; // auto already fills the cross axis
const pContentH = pb.height - pf(pcs.paddingTop) - pf(pcs.paddingBottom) - pf(pcs.borderTopWidth) - pf(pcs.borderBottomWidth);
if (pContentH <= 0) return false;
if (Math.abs(nb.height - pContentH) > Math.max(2, 0.01 * pContentH)) return false; // must fill the parent
hs.push(nb.height);
}
return hs.length >= 2 && Math.max(...hs) - Math.min(...hs) > 4;
}
/** An absolutely/fixed-positioned layer that fills its containing block vertically. The generic
* height-fill path intentionally ignores positioned nodes, and the inset-drop probe can mark `top`
* / `bottom` as redundant because the static-position fallback happens to match at capture time.
* When the measured box starts at the containing block's top edge and its height equals the padding
* box height at every painted viewport, the authored intent is a vertical fill (`top-0 h-full` or
* inset-y-0), not a baked `h-[720px]`. */
function absoluteHeightFill(node: IRNode, parentNode: IRNode | undefined, viewports: number[]): boolean {
if (!parentNode || REPLACED.has(node.tag) || node.tag === "canvas" || node.tag.includes("-")) return false;
let painted = 0;
for (const vp of viewports) {
const cs = node.computedByVp[vp]; const nb = node.bboxByVp[vp];
const pcs = parentNode.computedByVp[vp]; const pb = parentNode.bboxByVp[vp];
if (!cs || !nb || !pcs || !pb || !node.visibleByVp[vp] || (cs.display || "") === "none") continue;
const pos = cs.position || "static";
if (pos !== "absolute" && pos !== "fixed") return false;
if (pos === "absolute" && !/^(relative|absolute|fixed|sticky)$/.test(pcs.position || "")) return false;
const top = pf(cs.top);
const cbY = pos === "fixed" ? 0 : pb.y + pf(pcs.borderTopWidth);
const cbH = pos === "fixed"
? (CAPTURE_VIEWPORT_HEIGHTS[vp] ?? Math.round(vp * 0.66))
: pb.height - pf(pcs.borderTopWidth) - pf(pcs.borderBottomWidth);
if (!(cbH > 0)) return false;
if (Math.abs(nb.y - (cbY + top)) > 2) return false;
if (Math.abs(nb.height - cbH) > Math.max(2, 0.01 * cbH)) return false;
painted++;
}
return painted >= 1;
}
/** Sizing-probe verdict for `h-full`: at EVERY painted viewport `height:100%` reproduced the box
* (within 0.5px, `hFill`) while `height:auto` did NOT (`!hAuto`). That pair is direct ground truth
* that the element FILLS a definite-height containing block (the source's `h-full`) rather than being
* content-sized (which drops to auto via heightProbeDrops). Unlike isHeightFill this needs no
* structural parentDefiniteHeight inference and no "varies" guard — the browser measured that 100%
* reproduces, so a CONSTANT filler qualifies too. Inert on captures
* predating the hFill probe (older sites): `hFill === undefined` → returns false. Replaced elements
* aren't probed (the probe skips them), so this never fires on img/svg. */
function heightProbeFills(node: IRNode, viewports: number[]): boolean {
if (process.env.NO_HFILL) return false; // diagnostic toggle
let painted = 0;
for (const vp of viewports) {
const cs = node.computedByVp[vp];
if (!cs || !node.visibleByVp[vp] || (cs.display || "") === "none") continue;
const pos = cs.position || "static";
if (pos !== "static" && pos !== "relative") return false;
const s = node.sizingByVp?.[vp];
if (!s || s.hFill === undefined) return false; // no probe data → inert (older captures)
if (!s.hFill || s.hAuto) return false; // must fill at 100% AND not be content-sized
painted++;
}
return painted >= 2;
}
/** Does this node confer a DEFINITE height to its children (so their height:100% resolves)? True when:
* • we just gave it height:100% (isHeightFill) — chaining the fill down a wrapper > card > figure nest,
* • it is a grid / row-flex-stretch item (auto-stretched to a definite cell/line height) every painted vp,
* • it is a flex-GROW item in a flex COLUMN whose own parent is already definite-height: the column
* hands it a definite share of the (definite) free space, so it has a definite USED height even when
* we drop its explicit height to `grow basis-0` (the source-matching emission). Without this the
* fill chain snaps at every `flex-1` link,
* • OR it KEEPS its own height in the clone (we did not drop it via the flow/probe path → we emit its
* captured px). A box whose height we bake is a definite containing block, so a child's height:100%
* resolves against it. `droppedHeight` is the node's own flow-drop (flowH); a dropped height becomes
* `auto` (content-derived) — NOT definite.
* This is broader than stretch-only, but SAFE: a content-derived parent is itself dropped (droppedHeight)
* so it never reaches the kept-height branch — the dangerous "parent merely wraps this child" case can't
* cascade — and the per-child `isHeightFill` gate (fills the parent content-box ≤2px at EVERY sampled vp
* AND varies) is the real filter on which children actually become 100%. Together these flow the
* `h-full` chain through grid-row anchors, baked-px panels and `flex-1` links alike. */
function confersDefiniteHeight(node: IRNode, parentNode: IRNode | undefined, gotHeightFill: boolean, droppedHeight: boolean, parentDefiniteHeight: boolean, viewports: number[]): boolean {
if (gotHeightFill) return true;
let total = 0; let stretched = 0; let flexGrowCol = 0;
for (const vp of viewports) {
const cs = node.computedByVp[vp];
if (!cs || !node.visibleByVp[vp] || (cs.display || "") === "none") continue;
total++;
if (parentAutoStretchesHeight(node, parentNode, vp)) stretched++;
const pcs = parentNode?.computedByVp[vp];
if (pcs && /flex/.test(pcs.display || "") && /column/.test(pcs.flexDirection || "") && pf(cs.flexGrow) > 0) flexGrowCol++;
}
if (total < 2) return false;
if (stretched === total) return true;
if (flexGrowCol === total && parentDefiniteHeight) return true; // flex-grow item in a definite column
return !droppedHeight; // keeps a baked-px height → definite containing block
}
/** Properties an INFINITE CSS animation perpetually drives, so their per-viewport captured values
* are frozen animation phases (the shutter caught a different point of the cycle at each width),
* not responsive design — e.g. a loading spinner's leaf opacity/rotation. Banding them bakes
* random-phase noise into breakpoints. We return the set to suppress so the base value holds at
* every width (one frozen frame instead of N); the runtime animation overrides it on load, and the
* gates cancel animations to the same settled base, so this is band-noise removal, not a paint
* change. transform-origin follows transform (skipped when no transform varies). */
const ANIM_OWNED = new Set(["opacity", "transform"]);
const EMPTY_SET = new Set<string>();
function animOwnedProps(node: IRNode, baseVp: number): Set<string> {
const cs = node.computedByVp[baseVp];
if (!cs || (cs.animationName || "none") === "none") return EMPTY_SET;
if (!/infinite/.test(cs.animationIterationCount || "1")) return EMPTY_SET;
return ANIM_OWNED;
}
function declsForViewport(
node: IRNode,
parentComputed: StyleMap | undefined,
vp: number,
assetMap: Map<string, string>,
isCentered = false,
colorVar?: (value: string) => string | null,
rootScrollHeight?: number,
widthPlan: WidthPlan = PLAN_FIXED,
gridCols?: string,
gridRows?: string,
flowHeight = false,
dropInsets: Set<string> = EMPTY_SET,
leftPct: string | null = null,
heightFill = false,
geometry: GeometryPlan = GEOMETRY_NONE,
dropGridRows = false,
dropViewportMaxWidth = false,
nowrapText = false,
keepIdentityTransform = false,
): Map<string, string> {
const cs = node.computedByVp[vp];
const out = new Map<string, string>();
if (!cs) return out;
const tag = node.tag;
// box-sizing — only emit when content-box (reset is border-box).
if (cs.boxSizing === "content-box") out.set("box-sizing", "content-box");
// width — a relative plan replaces the captured px so the box scales / stays centred / fills at
// any window width while still resolving to the IDENTICAL px at every captured viewport (so the
// layout/style gates, measured only at those widths, are unmoved):
// • fill → width:100% — a full-bleed flex/grid ITEM in a non-stretching column/grid that
// width:auto would shrink to its content (framer's centred-column sections);
// • auto → omit width — a full-bleed / fills-container block tracks its fluid container;
// • percent → width:N% — a stable fractional width;
// • flexfill → flex:1 1 0 — an equal-fill flex-row item;
// • fixed → replay the captured computed width, as before.
// Shared layout flags (used by both width and height below).
const disp = cs.display || "";
const pos = cs.position || "static";
const ov = cs.overflowY || cs.overflow || "visible";
const parentDisp = parentComputed?.display || "";
const isFlexGridItem = /flex|grid/.test(parentDisp);
// A chip in a horizontally-scrollable flex strip (an overflow-x:auto/scroll flex parent) relies on
// the CSS default `min-width:auto` to stay at its content width so the strip scrolls. The base
// viewport can report `min-width:0px` on such an item (e.g. a mobile-only strip collapsed to 0 at
// desktop), which would emit `min-w-0` and let the item shrink below its content — collapsing the
// strip and colliding its nowrap text. Suppress `min-w-0` for a nowrap flex item whose flex parent
// scrolls horizontally. This is scoped away from legitimate `min-w-0` truncation (overflow:hidden +
// ellipsis on the item itself, whose parent does NOT scroll-x), so it is safe.
const parentOverflowX = parentComputed ? (parentComputed.overflowX || parentComputed.overflow || "visible") : "visible";
const inScrollXFlexStrip =
/flex/.test(parentDisp) &&
/^(auto|scroll)$/.test(parentOverflowX) &&
(cs.whiteSpace || "normal") === "nowrap";
const isLeaf = !hasElementChild(node);
const hasText = node.children.some((c) => isTextChild(c) && c.text.trim() !== "");
const isTextLeaf = isLeaf && hasText && !REPLACED.has(tag) && tag !== "canvas" && !tag.includes("-");
const inFlow = pos === "static" || pos === "relative";
// NOTE: dropping a flex/grid-ITEM text leaf's width (to let it re-derive from content/track)
// was tried but is NOT provably gate-neutral — it shifts sibling/justify spacing on a handful
// of nodes (small margin deltas, still within tolerance but not exact). Kept OUT under the
// "provably-derivable only" bar; planWidth still handles block-level (non-flex-item) widths.
if (widthPlan.kind === "fill") out.set("width", "100%");
// Fill-to-cap: width:100% capped by the recovered max-width — reproduces min(parentContent, cap)
// fluidly, replacing the baked per-viewport px AND any banded/percentage max-width the source had.
else if (widthPlan.kind === "fillcap") { out.set("width", "100%"); out.set("max-width", widthPlan.cap); }
else if (widthPlan.kind === "percent") out.set("width", widthPlan.pct);
// Per-band fractional width (this viewport's regime ratio) — `w-1/2` desktop, `w-full` mobile.
// The band-delta machinery collapses equal neighbours, so equal regimes emit a single rule.
else if (widthPlan.kind === "percentVp") { const p = widthPlan.pctByVp[vp]; if (p) out.set("width", p); }
else if (widthPlan.kind === "flexfill") { out.set("flex-grow", "1"); out.set("flex-basis", "0%"); }
// A full-width shrink-0 carousel slide: definite main size = container content width, so the flex
// row stays exactly one container wide (no max-content over-widening). Keeps flex-shrink:0 (source).
else if (widthPlan.kind === "basisFull") { out.set("flex-basis", "100%"); out.set("flex-shrink", "0"); }
// A flex-line item's RECOVERED natural width (its flex base size), emitted as a constant at
// every viewport so the captured per-viewport px collapse to one value — flex-grow/shrink then
// re-derives the resolved widths. Verified to reproduce every sample before being chosen.
else if (widthPlan.kind === "basis") out.set("width", widthPlan.px);
else if (widthPlan.kind === "auto" && REPLACED.has(tag)) out.set("width", "auto");
else if (widthPlan.kind === "fixed" && cs.width && cs.width !== "auto") out.set("width", cs.width);
// height: replay the observed border-box height as a fixed value ONLY where
// margin-collapsing does not apply, otherwise forcing a height would suppress
// a child's collapsed margin that the source layout relies on. Normal-flow
// block boxes keep auto height (content-driven) so collapsing is preserved;
// replaced elements, flex/grid items, BFC establishers (flex/grid/inline-block/
// table/overflow!=visible), and out-of-flow boxes get the fixed height.
// (disp/pos/ov/parentDisp/isFlexGridItem/isLeaf hoisted above the width block.)
const isInlineOnly = disp === "inline";
// Leaf elements have no element children, so pinning their height cannot
// suppress a parent↔child collapsed margin — it's always safe and keeps text
// boxes matching the source exactly.
const noCollapse =
REPLACED.has(tag) ||
tag === "canvas" || tag.includes("-") || // canvas + custom elements (web components, e.g. <model-viewer>) are opaque/replaced-like; preserve their captured box
/flex|grid|inline-block|inline-flex|inline-grid|table|flow-root/.test(disp) ||
pos === "absolute" || pos === "fixed" || pos === "sticky" ||
ov === "auto" || ov === "hidden" || ov === "scroll" ||
isFlexGridItem;
// Explicit-height detection: a normal-flow block whose box is taller than its
// in-flow content has an authored height (e.g. a section sized via height with
// 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;
const nb = node.bboxByVp[vp];
if (!noCollapse && !isLeaf && cs.height && cs.height !== "auto" && nb) {
const top = nb.y + (parseFloat(cs.paddingTop || "0") || 0) + (parseFloat(cs.borderTopWidth || "0") || 0);
let contentBottom = top; // includes trailing margins (for taller detection)
let borderBottom = top; // border-box extent only (for overflow detection)
let inflowCount = 0;
// Are ALL the in-flow element children fill children whose height DERIVES from this
// node (height:100% / probe hFill)? If so their measured extent equals this box's
// height only BECAUSE they fill it — it is not content-derived evidence, so it must
// not be allowed to "explain away" an authored height below.
let allInflowFill = true;
for (const c of node.children) {
if (isTextChild(c)) continue;
const ccs = c.computedByVp[vp]; const cb = c.bboxByVp[vp];
if (!ccs || !cb || !c.visibleByVp[vp]) continue;
// Out-of-flow children (absolute/fixed) and floats do not contribute to a
// block's content height — a parent that contains only floats (clearfix)
// relies on an authored height, which we must therefore emit.
if (ccs.position === "absolute" || ccs.position === "fixed") continue;
if ((ccs.float || "none") !== "none") continue;
inflowCount++;
const csz = c.sizingByVp?.[vp];
// A fill child: the probe measured height:100% reproduces AND auto does not (hFill && !hAuto).
// No probe data (older captures) ⇒ treat as real content (conservative: leave allInflowFill off).
if (!(csz && csz.hFill === true && csz.hAuto === false)) allInflowFill = false;
contentBottom = Math.max(contentBottom, cb.y + cb.height + (parseFloat(ccs.marginBottom || "0") || 0));
borderBottom = Math.max(borderBottom, cb.y + cb.height);
}
const padBottom = (parseFloat(cs.paddingBottom || "0") || 0) + (parseFloat(cs.borderBottomWidth || "0") || 0);
const contentHeight = contentBottom - nb.y + padBottom;
if (nb.height > contentHeight + 2) explicitHeight = true;
if (inflowCount === 0 && nb.height > 2) explicitHeight = true;
// Authored height whose only in-flow children FILL it (height:100%). The child extent measured
// the parent's own height back at it (circular), so the "taller than content" test above can never
// fire — yet dropping the height lets each fill child re-derive its height from content/aspect
// (a `h-full aspect-video` child then resolves 16/9 of its width, inflating a 240px hero to 720px).
// Keep the authored height so the fill chain has a definite basis. Probe hFill (not `!hAuto` on the
// parent) is the guard: hAuto:false on the parent means auto did NOT reproduce, i.e. real authored.
if (inflowCount > 0 && allInflowFill && node.sizingByVp?.[vp]?.hAuto === false && nb.height > 2) explicitHeight = true;
// Overflow case: the box is meaningfully SHORTER than its in-flow content's
// border boxes. Content cannot shrink a box below its own size, so the height
// is authored (e.g. html,body{height:100%} with overflowing content). Compare
// against border-box bottom — never trailing margin — so a margin that
// collapses out of the box is not mistaken for overflow.
if (inflowCount > 0 && nb.height + 2 < borderBottom - nb.y + padBottom) explicitHeight = true;
}
// A CSS table's computed height already includes its caption, but the caption
// (display:table-caption) is laid out outside the table grid box — so emitting
// that height makes the caption add a second time (Wikipedia figures rendered
// one caption-height too tall). Let such tables size to content instead.
const isTableWithCaption = (disp === "table" || disp === "inline-table") &&
node.children.some((c) => !isTextChild(c) && (c as IRNode).computedByVp[vp]?.display === "table-caption");
// Inline non-replaced boxes (spans/links) have auto height, so skip it — but
// inline *replaced* elements (img/svg/video…) carry a real, often non-intrinsic
// height that must be emitted, else they collapse to their attribute/intrinsic
// size (e.g. a Wikipedia inline logo rendering 27px instead of 124px).
// Root scroll-lock un-clamp (fidelity tail): a site that locks scrolling during an
// intro — body/html { height:100vh; overflow-y:scroll|hidden } — can be captured in
// that transient state, pinning the root to one viewport so it CLIPS its overflowing
// content; the clone's document.scrollHeight then collapses to one viewport and the
// page renders truncated (paco.me). The lock is provably transient when the source's
// OWN captured scrollHeight exceeds the pinned height (the real document scrolled
// taller) — unlike a genuine internal-scroll shell, whose scrollHeight matches. Drop
// the clamp (height + clipping overflow-y) so the root grows to its content.
const clampH = parseFloat(cs.height || "");
const rootUnclamp =
(tag === "body" || tag === "html") &&
!!rootScrollHeight && clampH > 0 && rootScrollHeight > clampH + 4 &&
/^(scroll|hidden|auto|clip)$/.test(cs.overflowY || cs.overflow || "visible");
// A pure-text leaf's height is fully content-derived (padding/border + lines × line-height).
// We reproduce the font, line-height, padding and width, so its height auto-resolves to the
// SAME px at every captured width — emitting it just freezes the box (and breaks the moment
// the copy is edited, the #1 readability complaint). Drop it for an in-flow, non-clipping,
// non-replaced text leaf; keep it where the box clips (overflow) or is positioned, where the
// height is load-bearing. Gate-neutral: the computed height is unchanged at 375/768/1280/1920.
// Drop the baked height for a text leaf OR a flow-eligible flex/grid box (heightFlows). Geometry
// fidelity is enforced by the layout (sections + leaf size + position) and perceptual gates.
// A text leaf's height is content-derived ONLY when it equals padding/border + an integer number
// of line-boxes. A control authored TALLER than its text — a button with height:32px vertically
// centering a 20px label — is NOT content-derived, and dropping it squashes the button to the
// text height (the "Start for Free" / CTA buttons rendered ~8px short). Keep the height there.
const lineH = pf(cs.lineHeight);
const padBorderV = pf(cs.paddingTop) + pf(cs.paddingBottom) + pf(cs.borderTopWidth) + pf(cs.borderBottomWidth);
const rows = lineH > 0 && nb ? (nb.height - padBorderV) / lineH : 1;
// Flowing text lives in a block/inline box. A flex/grid box sizes and (usually) centers its content,
// so its height is LOAD-BEARING: a single-line label in a button whose height is an exact multiple
// of the line-height reads as rows≈2 here but is NOT two lines of text — dropping it squashes the
// button to the label height. Only block/inline flow has a genuinely content-derived height.
const flowsText = /^(block|inline|inline-block|list-item)$/.test(cs.display || "");
const textHeightIsContentDerived = flowsText && (!(lineH > 0) || Math.abs(rows - Math.round(rows)) < 0.15);
const plannedHeight = geometry.heightByVp?.[vp];
const plannedAspect = geometry.aspectByVp?.[vp];
const dropHeight = plannedAspect !== undefined || (isTextLeaf && inFlow && ov === "visible" && textHeightIsContentDerived) || flowHeight;
if (plannedHeight) {
out.set("height", plannedHeight);
} else if (heightFill) {
out.set("height", "100%"); // fills its (definite) parent — overrides the baked px / the flow drop
} else if (cs.height && cs.height !== "auto" && !isTableWithCaption && !rootUnclamp && !dropHeight && (!isInlineOnly || REPLACED.has(tag)) && (noCollapse || isLeaf || explicitHeight)) {
out.set("height", cs.height);
}
if (cs.minHeight && cs.minHeight !== "0px" && cs.minHeight !== "auto") {
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
// identical (width + style + color), emit the un-sided SHORTHAND (border-width/style/color)
// — a 1:1 equivalent of four equal longhands (so getComputedStyle is unchanged, gate-neutral)
// that the Tailwind emitter renders as `border border-solid border-<token>` instead of the
// 12-utility per-side wall.
const SIDES = ["Top", "Right", "Bottom", "Left"];
const bw = (s: string) => cs[`border${s}Width`];
const bs = (s: string) => cs[`border${s}Style`] || "solid";
const bcRaw = (s: string) => cs[`border${s}Color`];
const allFour = SIDES.every((s) => { const w = bw(s); return w && parseFloat(w) > 0; });
const uniform = allFour &&
SIDES.every((s) => bw(s) === bw("Top")) &&
SIDES.every((s) => bs(s) === bs("Top")) &&
SIDES.every((s) => bcRaw(s) === bcRaw("Top"));
if (uniform) {
out.set("border-width", bw("Top")!);
out.set("border-style", bs("Top"));
const c = bcRaw("Top");
if (c) out.set("border-color", (colorVar && colorVar(c)) || c);
} else {
for (const side of SIDES) {
const w = bw(side);
if (w && parseFloat(w) > 0) {
out.set(`border-${side.toLowerCase()}-width`, w);
out.set(`border-${side.toLowerCase()}-style`, bs(side));
const c = bcRaw(side);
if (c) out.set(`border-${side.toLowerCase()}-color`, (colorVar && colorVar(c)) || c);
}
}
}
const hasTransform = cs.transform && cs.transform !== "none";
// A scroll/view-timeline-driven animation reports its resolved `animation-duration` as
// `auto` (the duration is derived from the timeline range, not a time). The clone has no
// scroll timeline, so emitting such an animation makes it jump straight to its END keyframe
// (`fill-mode:both` + a 0s time-based duration), freezing the element at the fully-progressed
// state (e.g. a scroll-linked text-fill stuck 100% filled). We do NOT replay scroll-linked
// animations; the goal is the correct AT-REST render. So suppress the animation-* props for
// an animation whose duration is `auto`, and let the captured static properties (now recorded
// at-rest, scroll reset + timeline animations canceled before the snapshot) stand.
const isScrollTimelineAnim = (cs.animationDuration || "").split(",").some((d) => d.trim() === "auto");
const hasAnimation = cs.animationName && cs.animationName !== "none" && !isScrollTimelineAnim;
for (const { prop, def } of GENERIC) {
const value = cs[prop];
if (value === undefined || value === "") continue;
if (SPECIAL.has(prop)) continue;
// The `gap` shorthand is fully reconstructed by row-gap + column-gap (both always reported by
// getComputedStyle and emitted below). Emitting it too produces a redundant `gap-[normal_10px]`
// beside `gap-x-…`; drop it so only the axis utilities (which collapse to `gap-N` when equal) remain.
if (prop === "gap") continue;
// grid-template-columns is replaced by the fluid (fr) template when one was inferred —
// emitted once below so it never bands per-viewport-px.
if (prop === "gridTemplateColumns" && gridCols !== undefined) continue;
// grid-template-rows likewise replaced by the fluid (1fr) template when one was inferred.
if (prop === "gridTemplateRows" && (gridRows !== undefined || dropGridRows)) continue;
// fill-to-cap sets max-width to the recovered cap (above); don't let the source value re-emit it.
if (prop === "maxWidth" && widthPlan.kind === "fillcap") continue;
// A max-width equal to the viewport at every sampled width is a resolved guard like
// max-width:100vw, not a fixed cap. Emitting the used px per band freezes full-bleed roots.
if (prop === "maxWidth" && dropViewportMaxWidth) continue;
// Recovered aspect-ratio replaces baked height/grid rows.
if (prop === "aspectRatio" && plannedAspect) continue;
// Special-case defaults expressed as sentinels.
if (prop === "transformOrigin" && !hasTransform) continue;
if (prop === "webkitTextFillColor") {
if (cs.webkitBackgroundClip !== "text" && value === cs.color) continue;
}
if (prop === "webkitTextStroke") {
if (/^0px(\s|$)/.test(value) || value === "0") continue;
}
if (ANIMATION_PROPS.has(prop) && !hasAnimation) continue;
if (prop === "listStyleType") {
if (!/^(ul|ol|li|menu)$/.test(tag)) continue;
// list reset is none; emit whatever the source uses (incl. none).
} else if (prop === "minWidth") {
if (value === "auto" || (value === "0px" && !isFlexGridItem) || (value === "0px" && inScrollXFlexStrip)) continue;
} else if (prop === "transform") {
// Emit the identity `transform:none` explicitly at a viewport whose value is `none` WHEN the node
// carries a non-identity transform at some OTHER band — otherwise the non-identity value cascades
// across bands and freezes at a width the source left untransformed. (Identity matrices are already
// canonicalized to the literal "none" upstream, so the string compare is reliable.) When the node
// is identity everywhere, this stays a normal default elision.
if (value === "none" && !keepIdentityTransform) continue;
} else if (def === "__never__") {
// always emit (display/color/fontFamily/fontSize handled below for inherit)
} else if (isDefault(def, value)) {
continue;
}
// Inherited: skip when equal to parent's value (inheritance handles it).
// Exception: the reset (`ul, ol, menu { list-style: none; }`) breaks the list-marker
// inheritance chain on those tags, so parent-equality is not a safe elision there —
// a source <ul> with `disc` equals its parent's initial `disc` yet must still emit
// or the reset erases the markers. <li> inherits from the ul, which now emits.
if (INHERITED.has(prop) && parentComputed && parentComputed[prop] === value) {
const listMarkerReset = (prop === "listStyleType" || prop === "listStylePosition") && /^(ul|ol|menu)$/.test(tag);
if (!listMarkerReset) continue;
}
let outValue = value;
if (prop === "backgroundImage" || prop === "maskImage" || prop === "filter" || prop === "clipPath") {
if (value.includes("url(")) outValue = rewriteUrls(value, assetMap);
} else if ((prop === "color" || prop === "backgroundColor") && colorVar) {
// Stage 3.5: reference the semantic color token (var(--…)) when this value is
// tokenized; the token resolves to the same value (±2 cluster, within the
// grader's color tolerance), so this is fidelity-neutral.
outValue = colorVar(value) || outValue;
}
out.set(kebab(prop), outValue);
}
// Wrap-vulnerable single-line text: the text renders on one line at every captured width and its
// unwrapped width nearly equals the container's available width, so a sub-pixel width shortfall in
// the clone (a column resolving ~0.6px narrower) tips it onto a second line, shifting everything
// below. `white-space:nowrap` holds it to one line — identical to the capture (already single-line
// at every width) but immune to the rounding shortfall. Only the source `whiteSpace` value that
// already collapses runs (`normal`/`nowrap`) is overridden; `pre`/`pre-wrap`/`break-spaces` preserve
// authored whitespace and are left untouched (the caller's detector also excludes them).
if (nowrapText) out.set("white-space", "nowrap");
// Centered max-width container: emit auto side margins so the browser centers
// it at every width. getComputedStyle sometimes reports 0 for resolved auto
// margins at wide viewports, so replaying px would left-align the clone.
if (isCentered) {
out.set("margin-left", "auto");
out.set("margin-right", "auto");
}
// Root un-clamp: relax the clipping vertical overflow so the document scrolls at the
// window level (no spurious scrollbar gutter) and grows to its content height.
if (rootUnclamp) out.set("overflow-y", "visible");
// Fluid grid columns (fr) replace the baked per-viewport px tracks so the grid scales.
if (gridCols !== undefined) out.set("grid-template-columns", gridCols);
// Fluid grid rows (1fr) likewise replace baked equal per-viewport px row tracks.
if (gridRows !== undefined) out.set("grid-template-rows", gridRows);
// Drop an over-constrained inset. For an absolutely/fixed-positioned box that pins one edge
// AND its size, getComputedStyle still reports the opposite edge as a resolved px (e.g.
// top:0 + height:44px on a full-height container → bottom:5525px, the distance to the far
// edge). That value is fully derived — emitting it just freezes a measurement (the absurd
// `bottom-[5525px]` on a sticky bar). Dropping it is gate-neutral: with the edge + size kept,
// the browser re-derives the identical resolved opposite edge at every captured width.
if (pos === "absolute" || pos === "fixed") {
// Ground truth first: drop every side the inset-anchor probe proved is a
// filled-in used value (setting it to auto didn't move the box at any painted width). This
// catches anchors the static rule below misses — `right` derived from an explicit width with NO
// baked `width` util, `bottom` under a min-height, the 0×0 overlay whose right = viewport width.
for (const side of dropInsets) out.delete(side);
// Static fallback for nodes the probe didn't read (no overlay): an over-constrained box that
// pins one edge AND its size reports the opposite edge as a resolved px (top:0 + height → the
// absurd bottom-[5525px] on a sticky bar). Gate-neutral: edge + size re-derive it.
if (out.has("top") && out.has("bottom") && out.has("height")) out.delete("bottom");
if (out.has("left") && out.has("right") && out.has("width")) out.delete("right");
// Snap a near-zero remaining inset to 0 — a kept anchor reported as 0.015625px is rounding noise
// for `right: 0` and should read `right-0`, not `right-[0.015625px]`. Sub-half-pixel ⇒ gate-neutral.
for (const side of ["top", "right", "bottom", "left"]) {
const v = out.get(side);
if (v && v !== "auto" && Math.abs(parseFloat(v)) < 0.5) out.set(side, "0px");
}
// Centred overlay: replace the baked `left:Npx` with the constant `left:N%` so it tracks the
// centre between breakpoints (the translate is already replayed). Same px at every captured width.
if (leftPct) out.set("left", leftPct);
const plannedLeft = geometry.leftByVp?.[vp];
const plannedTop = geometry.topByVp?.[vp];
if (plannedLeft && out.has("left")) out.set("left", plannedLeft);
if (plannedTop && out.has("top")) out.set("top", plannedTop);
}
if (plannedAspect) out.set("aspect-ratio", plannedAspect);
// Replaced elements (img/svg/video/canvas/controls) have integer intrinsic pixels; a sub-pixel
// display width/height is layout rounding, so a logo bakes the absurd w-[131.188px]. Snap to the
// nearest integer — within the style gate's tolerance ⇒ gate-neutral — so it reads w-[131px].
if (REPLACED.has(tag)) {
for (const k of ["width", "height"]) {
const v = out.get(k);
if (v && /^-?[\d.]+px$/.test(v)) {
const n = parseFloat(v);
if (Number.isFinite(n) && Math.abs(n - Math.round(n)) > 0.01) out.set(k, `${Math.round(n)}px`);
}
}
}
// Line clamp (`display:-webkit-box; -webkit-box-orient:vertical; -webkit-line-clamp:N`): truncates
// text to N lines, the mechanism that holds a card to a fixed height regardless of how long its
// text is. Without it the engine sees only the RESULTING px height and bakes or drops it per card,
// so a long quote overflows while its neighbours stay short — the uneven-card bug. Emit the single
// `-webkit-line-clamp:N` decl (→ Tailwind `line-clamp-N`, which itself sets the display /
// box-orient / overflow), suppressing those companions so they don't double-emit.
const lc = pf(cs.webkitLineClamp);
if (lc >= 1 && Number.isInteger(lc) && ((cs.webkitBoxOrient && /vertical/.test(cs.webkitBoxOrient)) || (cs.overflow || cs.overflowY) === "hidden")) {
out.set("-webkit-line-clamp", String(lc));
out.delete("display"); // line-clamp-N provides display:-webkit-box
if ((out.get("overflow") || "") === "hidden") out.delete("overflow");
}
return out;
}
/** Detect `margin: … auto`-style horizontal centering of a normal-flow block at a
* specific viewport (decided per-band by the caller). Two signals:
* 1) explicit equal positive side margins, or
* 2) the box sits with equal positive gaps inside its parent's content box while
* reporting ~0 margins — getComputedStyle sometimes resolves auto margins to
* 0px, which would otherwise left-align the clone.
* Excludes out-of-flow/float boxes and flex/grid items (the parent's own layout,
* which we replay, positions those) and inline-level boxes (centered via text-align). */
/** Do this node's left margins differ by >1px across the viewports where it's painted? Auto-margin
* centering slack varies with the container width; fixed structural spacing stays constant. Used to
* keep a constant equal margin literal (not `auto`) so it survives a shrink-wrapping parent. */
function marginsVaryAcrossVps(node: IRNode): boolean {
const ms: number[] = [];
for (const key of Object.keys(node.computedByVp)) {
const vp = Number(key);
if (!node.visibleByVp[vp]) continue;
const ml = parseFloat(node.computedByVp[vp]?.marginLeft || "0");
if (Number.isFinite(ml)) ms.push(ml);
}
return ms.length >= 2 && Math.max(...ms) - Math.min(...ms) > 1;
}
function centeredAtVp(node: IRNode, parentNode: IRNode, vp: number): boolean {
const cs = node.computedByVp[vp];
const nb = node.bboxByVp[vp];
const pb = parentNode.bboxByVp[vp];
if (!cs || !nb || !pb) return false;
if (cs.position === "absolute" || cs.position === "fixed") return false;
if ((cs.float || "none") !== "none") return false;
const pcs = parentNode.computedByVp[vp];
const disp = cs.display || "";
const pdisp = pcs?.display || "";
const parentFlexGrid = /flex|grid/.test(pdisp);
// margin:auto only centers BLOCK-LEVEL normal-flow boxes. For inline-level boxes
// (inline/inline-block/inline-flex) auto margins resolve to 0 — converting an
// explicit equal margin to auto would *delete* the spacing (duolingo inline-block
// <li>s: 12px → 0). Flex/grid items are positioned by the parent layout we
// replay, so only explicit auto-margin evidence is allowed for them below.
// Block-LEVEL boxes are margin-auto-centerable: block/list-item/flow-root/table and
// also `grid`/`flex` *containers* (a grid/flex box is block-level in normal flow, so
// `margin: 0 auto` centers it — getComputedStyle resolves those autos to 0, so it
// must be re-centered like a block, e.g. a centered `<main class=grid>`). Parent
// flex/grid is already excluded above, so grid/flex *items* never reach here.
// inline-level boxes (inline/inline-block/inline-flex/inline-grid) are NOT centered
// by margin auto (they center via the parent's text-align), so they stay excluded.
if (!/^(block|list-item|flow-root|table|grid|flex)$/.test(disp)) return false;
const ml = parseFloat(cs.marginLeft || "0");
const mr = parseFloat(cs.marginRight || "0");
// Signal 1: explicit equal positive margins on a block with room to center
// (auto reproduces the same gap, and stays centered at intermediate widths). Gate on the margin
// actually VARYING across viewports: margin-auto centering slack grows/shrinks with the container,
// whereas a CONSTANT equal margin is fixed structural spacing (e.g. an 8px inter-card gutter in a
// marquee). Converting fixed spacing to `auto` deletes it when the parent shrink-wraps the child
// (auto resolves to 0) — exactly how the testimonial inter-card gap vanished. Literal margins
// reproduce that spacing at every width regardless of the parent's resolved width.
// The width must ALSO be genuinely constrained: a box the sizing probe reads as a container-FILL
// (`width:100%` reproduces it) has no free space for auto margins to absorb — the emitted width
// is set to 100% (fill/fillcap), so `margin:auto` resolves to 0 and silently deletes real literal
// px side margins, blowing a padded pill/section out to full-bleed. Equal literal margins are the
// geometric TWIN of centering slack (both split the free space symmetrically), so this probe read
// is the only reliable discriminator between them; when the box fills, keep the literal margins.
const fillsAtVp = node.sizingByVp?.[vp]?.wFill === true;
if (ml > 0.5 && Math.abs(ml - mr) < 1 && nb.width < pb.width - 4 && marginsVaryAcrossVps(node) && !fillsAtVp) return true;
if (parentFlexGrid) return false;
// Signal 2: bbox-centered within the parent content box with ~0 reported margins.
if (Math.abs(ml) > 0.5 || Math.abs(mr) > 0.5) return false; // margins already explain position
const padL = (parseFloat(pcs?.paddingLeft || "0") || 0) + (parseFloat(pcs?.borderLeftWidth || "0") || 0);
const padR = (parseFloat(pcs?.paddingRight || "0") || 0) + (parseFloat(pcs?.borderRightWidth || "0") || 0);
const contentLeft = pb.x + padL;
const contentRight = pb.x + pb.width - padR;
const leftGap = nb.x - contentLeft;
const rightGap = contentRight - (nb.x + nb.width);
return leftGap > 1 && rightGap > 1 && Math.abs(leftGap - rightGap) < 1.5 && nb.width < contentRight - contentLeft - 2;
}
/** A single-line text leaf whose unwrapped width nearly fills its container's available width at
* EVERY captured viewport where it's painted — so any sub-pixel width shortfall in the clone (a
* column resolving fractionally narrower) tips it onto a second line. Such a node earns an explicit
* `white-space:nowrap` (see declsForViewport) to stay one line as it did in the capture.
* Conservative by construction — every guard must hold at every painted viewport:
* • text leaf (direct text, no element children), whitespace not already preserved (`pre*`);
* • single line: box height ≈ one line box (line-height + vertical padding/border), so a genuinely
* wrapping paragraph (≥2 line boxes tall) is excluded;
* • wrap-vulnerable: max-content (unwrapped) width ≥ available container width 2 and ≤ it + 1 —
* the text needs essentially all the available width, with no slack for a rounding shortfall;
* • genuinely wrappable: min-content < max-content 2, so a single unbreakable token (which can
* never wrap, making nowrap a redundant no-op) is skipped to keep the emission minimal.
* Relies on the sizing probe's wMin/wMax (present only for in-flow probed leaves); absent ⇒ no emit. */
function nowrapWrapVulnerable(node: IRNode, parentNode: IRNode | undefined, viewports: number[]): boolean {
if (!parentNode) return false;
if (hasElementChild(node)) return false;
if (!node.children.some((c) => isTextChild(c) && c.text.trim() !== "")) return false;
if (REPLACED.has(node.tag) || node.tag === "canvas" || node.tag.includes("-")) return false;
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;
if (!(nb.width > 0) || !(nb.height > 0)) continue;
const ws = cs.whiteSpace || "normal";
if (ws !== "normal" && ws !== "nowrap") return false; // pre/pre-wrap/break-spaces preserve authored whitespace
const sz = node.sizingByVp?.[vp];
const wMax = sz?.wMax; const wMin = sz?.wMin;
if (wMax == null || wMin == null) return false;
const lineBox = pf(cs.lineHeight) + pf(cs.paddingTop) + pf(cs.paddingBottom) + pf(cs.borderTopWidth) + pf(cs.borderBottomWidth);
if (!(lineBox > 0) || Math.abs(nb.height - lineBox) > 2.5) return false; // not single-line
const avail = containingWidthAt(node, parentNode, vp);
if (avail == null) return false;
if (!(wMax >= avail - 2 && wMax <= avail + 1)) return false; // not right at the container edge
if (!(wMin < wMax - 2)) return false; // single unbreakable token — can't wrap
painted++;
}
return painted >= 1;
}
function hasPxMaxWidthCap(node: IRNode, viewports: number[]): boolean {
const caps: number[] = [];
for (const vp of viewports) {
if (!node.visibleByVp[vp]) continue;
const cs = node.computedByVp[vp];
if (!cs || (cs.display || "") === "none") continue;
const maxW = cs.maxWidth || "";
if (maxW.endsWith("px")) caps.push(pf(maxW));
else if (maxW && maxW !== "none") return false;
}
if (!caps.length) return false;
const max = Math.max(...caps);
const min = Math.min(...caps);
return max > 0 && max - min <= Math.max(2, max * 0.02);
}
function hasElementChild(node: IRNode): boolean {
for (const c of node.children) if (!isTextChild(c)) return true;
return false;
}
function hasVisibleText(node: IRNode): boolean {
for (const c of node.children) {
if (isTextChild(c)) {
if (c.text.trim()) return true;
} else if (hasVisibleText(c)) return true;
}
return false;
}
function isTransparentColor(value: string | undefined): boolean {
if (!value) return true;
const v = value.trim().toLowerCase();
if (!v || v === "transparent" || v === "rgba(0, 0, 0, 0)" || v === "rgba(0,0,0,0)") return true;
const rgba = v.match(/^rgba?\((.*)\)$/);
if (rgba) {
const parts = rgba[1]!.split(/[,/]/).map((p) => p.trim()).filter(Boolean);
const alpha = parts[3];
if (alpha != null && Number.parseFloat(alpha) === 0) return true;
}
return false;
}
function isEmptyPseudoContent(value: string | undefined): boolean {
return !value || value === "none" || value === "normal" || value === '""' || value === "''";
}
function isPinnedInset(value: string | undefined): boolean {
return value != null && value !== "" && value !== "auto";
}
function isRestingInteractiveFillPseudo(node: IRNode, styleByVp: Record<number, StyleMap>, viewports: number[]): boolean {
const interactive = node.tag === "a" || node.tag === "button" || node.attrs.role === "button";
if (!interactive || !hasVisibleText(node)) return false;
let checked = 0;
for (const vp of viewports) {
const host = node.computedByVp[vp];
const pseudo = styleByVp[vp];
if (!host || !pseudo || pseudo.display === "none") continue;
checked++;
if (!isEmptyPseudoContent(pseudo.content)) return false;
if (!/^(absolute|fixed)$/.test(pseudo.position || "")) return false;
if (pf(pseudo.zIndex) >= 0) return false;
if (![pseudo.top, pseudo.right, pseudo.bottom, pseudo.left].every(isPinnedInset)) return false;
if (isTransparentColor(pseudo.backgroundColor) || (pseudo.backgroundImage && pseudo.backgroundImage !== "none")) return false;
if (isTransparentColor(host.backgroundColor) || (host.backgroundImage && host.backgroundImage !== "none")) return false;
}
return checked > 0;
}
// Type props (color/size/leading/tracking/align) only render when the pseudo has TEXT. An empty
// `content:""` box (the common decorative overlay / hit-area / focus-ring) inherits a font-size &
// line-height from its host that getComputedStyle reports but that paint nothing — baking them is
// pure noise (and a pile of sub-pixel decimals). Drop them when content is empty; keep when there's
// an actual glyph/counter/attr to render.
const PSEUDO_TEXT_ONLY = new Set(["color", "fontSize", "fontWeight", "lineHeight", "letterSpacing", "textAlign", "whiteSpace"]);
function pseudoDecls(style: StyleMap, assetMap: Map<string, string>): Map<string, string> {
const decls = new Map<string, string>();
const content = style.content;
const contentVal = content && content !== "none" ? content : '""';
decls.set("content", contentVal);
const emptyContent = contentVal === '""' || contentVal === "''";
const map: Array<[string, string | string[]]> = [
["display", "inline"], ["position", "static"], ["top", "auto"], ["right", "auto"],
["bottom", "auto"], ["left", "auto"], ["zIndex", "auto"],
["float", "none"], ["clear", "none"], // clearfix ::after { clear: both } must survive
["width", "auto"], ["height", "auto"],
["marginTop", "0px"], ["marginRight", "0px"], ["marginBottom", "0px"], ["marginLeft", "0px"],
["paddingTop", "0px"], ["paddingRight", "0px"], ["paddingBottom", "0px"], ["paddingLeft", "0px"],
["color", "__never__"], ["fontSize", "__never__"], ["fontWeight", "400"],
["lineHeight", "normal"], ["letterSpacing", "normal"], ["textAlign", "start"],
["backgroundColor", ["rgba(0, 0, 0, 0)", "transparent"]], ["backgroundImage", "none"],
["backgroundSize", "auto"], ["backgroundPosition", ["0% 0%", "0px 0px"]], ["backgroundRepeat", "repeat"],
["boxShadow", "none"], ["opacity", "1"], ["transform", "none"], ["transformOrigin", "__skip__"],
["filter", "none"], ["overflow", "visible"], ["objectFit", "fill"], ["borderTopLeftRadius", "0px"],
["flexShrink", "1"], ["flexGrow", "0"], ["flexBasis", "auto"], ["alignSelf", "auto"], ["order", "0"],
];
const hasTransform = style.transform && style.transform !== "none";
for (const [prop, def] of map) {
const v = style[prop];
if (v === undefined || v === "") continue;
if (emptyContent && PSEUDO_TEXT_ONLY.has(prop)) continue; // inert on an empty pseudo
if (prop === "transformOrigin" && !hasTransform) continue;
if (def !== "__never__" && isDefault(def, v)) continue;
let outV = v;
if ((prop === "backgroundImage") && v.includes("url(")) outV = rewriteUrls(v, assetMap);
decls.set(kebab(prop), outV);
}
return decls;
}
// Structured per-node rule (base decls + per-band deltas + pseudo rules). Computed
// once (collectNodeRules) and consumed by BOTH the legacy per-node emitter
// (generateCss → `.c<id>{…}`) and the semantic class-map emitter (classMap.ts →
// shared, named classes). Keeping one source of truth means the two emitters can
// never disagree about a node's styles — so deduping nodes by their rule guarantees
// the grouped class produces identical computed styles (fidelity-neutral).
export type BandRule = { media: string; decls: Map<string, string> };
export type PseudoRule = { base: Map<string, string>; bands: BandRule[] };
export type NodeRule = { base: Map<string, string>; bands: BandRule[]; before?: PseudoRule; after?: PseudoRule; placeholder?: PseudoRule };
/** ::placeholder declarations for a form control. Color always emits (the UA default is
* its own gray, NOT inherited from the input, so equality with the host proves nothing);
* font/spacing props DO inherit from the input inside the pseudo, so they emit only when
* they differ from the host's own computed value. */
function placeholderDecls(style: StyleMap, host: StyleMap | undefined): Map<string, string> {
const decls = new Map<string, string>();
if (style.color) decls.set("color", style.color);
if (style.opacity && style.opacity !== "1") decls.set("opacity", style.opacity);
for (const prop of ["fontFamily", "fontSize", "fontWeight", "fontStyle", "letterSpacing", "textTransform"]) {
const v = style[prop];
if (!v || (host && host[prop] === v)) continue;
decls.set(kebab(prop), v);
}
return decls;
}
/** Banded ::placeholder rule (mirrors collectPseudoRule's base+delta shape). */
function collectPlaceholderRule(styleByVp: Record<number, StyleMap>, hostByVp: Record<number, StyleMap>, baseVp: number, bands: Band[], tokenResolver?: TokenResolver): PseudoRule | undefined {
const baseStyle = styleByVp[baseVp] ?? Object.values(styleByVp)[0];
if (!baseStyle) return undefined;
const out: PseudoRule = { base: finalizeDecls(placeholderDecls(baseStyle, hostByVp[baseVp]), tokenResolver), bands: [] };
for (const b of bands) {
if (!b.media) continue;
const st = styleByVp[b.vp];
if (!st) continue; // control not rendered at this width — the host rule already hides it
const vpDecls = finalizeDecls(placeholderDecls(st, hostByVp[b.vp]), tokenResolver);
const delta = new Map<string, string>();
for (const [k, v] of vpDecls) if (out.base.get(k) !== v) delta.set(k, v);
for (const [k] of out.base) if (!vpDecls.has(k)) delta.set(k, resetValue(k));
if (delta.size > 0) out.bands.push({ media: b.media, decls: delta });
}
return out.base.size > 0 ? out : undefined;
}
/** Collect a pseudo-element's banded rule (its size/position can be responsive —
* e.g. flex spacer pseudo-elements in horizontal scrollers). `hostContentWidthByVp`
* is the host node's content-box width per viewport, used to re-fluidise a pseudo whose
* baked px width is really `width:100%` (a `display:table` clearfix ::before/::after). */
function collectPseudoRule(styleByVp: Record<number, StyleMap>, baseVp: number, bands: Band[], assetMap: Map<string, string>, tokenResolver?: TokenResolver, hostContentWidthByVp?: Record<number, number>, hostPadBoxWByVp?: Record<number, number>, hostPadBoxHByVp?: Record<number, number>): PseudoRule {
const out: PseudoRule = { base: new Map(), bands: [] };
const baseStyle = styleByVp[baseVp] ?? Object.values(styleByVp)[0];
if (!baseStyle) return out;
const baseDecls = finalizeDecls(pseudoDecls(baseStyle, assetMap), tokenResolver);
out.base = baseDecls;
for (const b of bands) {
if (!b.media) continue;
const st = styleByVp[b.vp];
// Pseudo not rendered at this width (responsive ::before/::after, e.g. a
// desktop-only decorative shape): hide it so the clone does not paint the
// canonical-viewport pseudo at a viewport where the source omits it.
if (!st) { out.bands.push({ media: b.media, decls: new Map([["display", "none"]]) }); continue; }
const vpDecls = finalizeDecls(pseudoDecls(st, assetMap), tokenResolver);
const delta = new Map<string, string>();
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));
if (delta.size > 0) out.bands.push({ media: b.media, decls: delta });
}
// A pseudo whose baked px width tracks the host's content box at EVERY rendered width (and varies)
// was authored `width:100%` — most often a `display:table` clearfix ::before/::after. The engine
// bakes the resolved px per band (`before:w-320` + `md:max-lg:before:w-192` …), which freezes a
// FULL-VIEWPORT-WIDTH box inside the host and so pins the host's min-content to the capture px:
// ridge's hero clearfix forces its whole section to 1280, clipping it on a 1024 window. Emit
// `width:100%` instead — identical px at every captured width (gate-neutral), but it no longer
// pins the host. Restricted to in-flow pseudos (an absolute pseudo's 100% has a different
// containing block) and proven against every sampled width, the frTemplate discipline.
//
// CRUCIAL guard: the pseudo must have NO vertical box of its own — height AND padding-top/bottom
// all ≈ 0. A clearfix is `display:table; height:0` (its width is purely cosmetic, safe to fill).
// An ASPECT-RATIO SPACER is the opposite: `width:X; padding-top:X` makes a SQUARE whose width is
// tied to its height.
// There the width is load-bearing — forcing it to 100% un-squares the box and the card's height
// goes inconsistent. The zero-box test cleanly separates the two.
if (hostContentWidthByVp) {
const vps = Object.keys(styleByVp).map(Number)
.filter((vp) => hostContentWidthByVp[vp]! > 0 && /^(static|relative)$/.test(styleByVp[vp]!.position || "static"));
const wAt = (vp: number): number => parseFloat(styleByVp[vp]!.width || "");
const zeroBox = (vp: number): boolean => {
const s = styleByVp[vp]!;
return Math.abs(pf(s.height)) <= 2 && Math.abs(pf(s.paddingTop)) <= 2 && Math.abs(pf(s.paddingBottom)) <= 2;
};
const measured = vps.filter((vp) => Number.isFinite(wAt(vp)));
const fills = measured.length >= 2
&& measured.every((vp) => zeroBox(vp)) // a true clearfix, not an aspect-ratio spacer
&& measured.every((vp) => Math.abs(wAt(vp) - hostContentWidthByVp[vp]!) <= Math.max(1.5, 0.01 * hostContentWidthByVp[vp]!))
&& Math.max(...measured.map(wAt)) - Math.min(...measured.map(wAt)) > 8;
if (fills) {
out.base.set("width", "100%");
for (const band of out.bands) band.decls.delete("width"); // 100% holds at every band
}
}
// An absolutely-positioned overlay pseudo pinned on BOTH opposing edges (inset-0 and the like — a
// `.card::before` border/gradient/mask that fills the card) needs no width/height: for an absolute
// box the offsets define the size from the host's PADDING box (its containing block). The engine
// bakes the resolved px per band anyway (`before:w-[25.4rem]` + max-md/md:max-lg/2xl variants),
// freezing a fixed-size overlay inside a fluid card and spawning a breakpoint pile. When the baked
// size actually SPANS the host (reaches the opposite inset) at every rendered width AND varies,
// drop it — left/right (top/bottom) stretch the box fluidly and reproduce every captured sample.
// Proven per axis against the host padding box (the frTemplate discipline); an over-constrained
// width that DOESN'T reach `right` (width < span) keeps its baked px. Only when the host is the
// pseudo's containing block (positioned/transformed) is the padding box passed in.
if (hostPadBoxWByVp || hostPadBoxHByVp) {
const vps = Object.keys(styleByVp).map(Number).filter((vp) => styleByVp[vp]);
const isAbs = (vp: number): boolean => /^(absolute|fixed)$/.test(styleByVp[vp]!.position || "static");
const pin = (v: string | undefined): boolean => v != null && v !== "auto";
// Per axis, the vps that baked a px size (auto vps carry no baked px — the source `.card::before`
// has `inset:0` and no width, so getComputedStyle only resolves a px width where it's RENDERED;
// a display:none-on-mobile card reports width:auto there, which is the true authored value and is
// skipped). Drop the baked size iff every baked-px vp is absolute, pinned on both opposing edges,
// and the px equals the inset-implied span of the host padding box — and ≥2 such vps vary.
const axisDrop = (box: Record<number, number> | undefined, dim: "width" | "height", a: string, b: string, mA: string, mB: string): boolean => {
if (!box) return false;
const pxVps = vps.filter((vp) => pf(styleByVp[vp]![dim]) > 0 && styleByVp[vp]![dim] !== "auto");
if (pxVps.length < 2) return false;
const vals = pxVps.map((vp) => pf(styleByVp[vp]![dim]));
if (Math.max(...vals) - Math.min(...vals) <= 8) return false;
return pxVps.every((vp) => {
const s = styleByVp[vp]!;
if (!isAbs(vp) || !pin((s as Record<string, string>)[a]) || !pin((s as Record<string, string>)[b])) return false;
const pad = box[vp]; if (!(pad! > 0)) return false;
const implied = pad! - pf((s as Record<string, string>)[a]) - pf((s as Record<string, string>)[b]) - pf((s as Record<string, string>)[mA]) - pf((s as Record<string, string>)[mB]);
return Math.abs(pf(s[dim]) - implied) <= Math.max(1.5, 0.01 * pad!);
});
};
const dropW = axisDrop(hostPadBoxWByVp, "width", "left", "right", "marginLeft", "marginRight");
const dropH = axisDrop(hostPadBoxHByVp, "height", "top", "bottom", "marginTop", "marginBottom");
if (dropW) { out.base.delete("width"); for (const band of out.bands) band.decls.delete("width"); }
if (dropH) { out.base.delete("height"); for (const band of out.bands) band.decls.delete("height"); }
}
return out;
}
/** Stage 5 (motion): the @keyframes a node's `animation-name` references. Names are
* collected from every node's computed animation-name across viewports (a comma list
* may name several); only matching captured @keyframes blocks are emitted (with url()
* rewritten), so an animation whose keyframes weren't captured (cross-origin sheet)
* stays inert — frozen — rather than emitting a dangling reference. `includeNode`
* scopes the referenced-name set for multi-route shared layout, as elsewhere. */
function keyframeNameOf(block: string): string | null {
const m = /@(?:-webkit-)?keyframes\s+("[^"]+"|'[^']+'|[^\s{]+)/i.exec(block);
return m ? m[1]!.replace(/^['"]|['"]$/g, "") : null;
}
function collectReferencedAnimations(ir: IR, includeNode?: (id: string) => boolean): Set<string> {
const names = new Set<string>();
const walk = (node: IRNode): void => {
if (!includeNode || includeNode(node.id)) {
for (const vp of ir.doc.viewports) {
const an = node.computedByVp[vp]?.animationName;
if (an && an !== "none") for (const n of an.split(",")) { const t = n.trim(); if (t && t !== "none") names.add(t); }
}
}
for (const c of node.children) if (!isTextChild(c)) walk(c);
};
walk(ir.root);
return names;
}
export function keyframesCss(ir: IR, assetMap: Map<string, string>, includeNode?: (id: string) => boolean): string {
const kfs = ir.doc.keyframes ?? [];
if (!kfs.length) return "";
const referenced = collectReferencedAnimations(ir, includeNode);
if (!referenced.size) return "";
const out: string[] = [];
const emitted = new Set<string>();
for (const block of kfs) {
const name = keyframeNameOf(block);
if (!name || !referenced.has(name) || emitted.has(block)) continue;
emitted.add(block);
out.push(block.includes("url(") ? rewriteUrls(block, assetMap) : block);
}
return out.join("\n");
}
/** Compute the structured rule for every emitted node (base decls + per-band deltas +
* pseudo rules), in document pre-order. The single source of truth behind both the
* per-node CSS emitter (generateCss) and the semantic class-map emitter (classMap.ts).
* `includeNode` scopes which nodes are emitted (multi-route shared layout) while still
* recursing so inheritance diffing against parents stays correct. */
export function collectNodeRules(ir: IR, assetMap: Map<string, string>, includeNode?: (id: string) => boolean, colorVar?: (value: string) => string | null, tokenResolver?: TokenResolver, reflow = false, lottieMounts?: ReadonlySet<string>): Map<string, NodeRule> {
const bands = computeBands(ir.doc.viewports, ir.doc.canonicalViewport);
const baseVp = ir.doc.canonicalViewport;
const rules = new Map<string, NodeRule>();
// ids of items in a content-sized flex row whose width should flow (width:auto). Populated when
// their container is visited (pre-order), consumed at each child's width plan. All-or-nothing
// per line so the line's free space — and thus sibling positions — is unchanged.
const autoWidthFlex = new Set<string>();
const walk = (node: IRNode, parentNode: IRNode | undefined, parentFluid: boolean, layoutParent: IRNode | undefined, cbAncestor: IRNode | undefined, parentDefiniteHeight: boolean): void => {
// Full-bleed fluidity propagates as a CONTIGUOUS chain from the root: a node is fluid
// only if its containing block is also fluid — otherwise width:auto fills a fixed-width
// ancestor instead of the viewport (a full-bleed row inside a fixed 1280 wrapper would
// render 1280 at every width). Decided once (vp-independent) and applied to base + bands.
// `layoutParent` is the nearest ancestor that actually generates a box: a display:contents
// wrapper is transparent to layout, so it passes the fluid state + containing block straight
// through to its children (otherwise the chain — and the page width — dead-ends at a contents
// <div>, e.g. framer's <nav>/<main> wrappers). The full-bleed chain marks a viewport-spanning
// box fluid via `auto` (a block/auto box that fills) or `fill` (a full-bleed flex/grid item a
// centred column/grid would otherwise shrink → width:100%).
const isContents = node.computedByVp[baseVp]?.display === "contents";
// Size inference runs over the DENSE sample widths (not just the 4 band widths) — more
// samples make "fills its container" / "fractional" / "clamped" provable instead of
// under-determined, and let a shrunk item's natural size surface. Bands are still emitted
// only at the standard breakpoints (ir.doc.viewports).
const sampleVps = ir.doc.sampleViewports;
// If this node is a content-sized flex row, let its items' widths flow (width:auto) — their
// per-viewport px were just content reflow. Whole-line so sibling positions don't shift.
if (!isContents) { const auto = contentSizedFlexRow(node, sampleVps); if (auto) for (const id of auto) autoWidthFlex.add(id); }
let fluidMode: "auto" | "fill" | null = null;
if (!isContents && parentFluid) {
if (isFluidFullBleed(node, layoutParent, sampleVps)) fluidMode = "auto";
else if (isFluidFillItem(node, layoutParent, sampleVps)) fluidMode = "fill";
}
const childFluid = isContents ? parentFluid : fluidMode !== null;
const childLayoutParent = isContents ? layoutParent : node;
// The containing block for absolutely-positioned descendants: the nearest ancestor that is
// positioned (relative/absolute/fixed/sticky) or establishes a containing block via a transform
// (transform/translate/rotate/scale/perspective/filter). display:contents is transparent.
const bcs = node.computedByVp[baseVp];
const establishesCb = !!bcs && (
/^(relative|absolute|fixed|sticky)$/.test(bcs.position || "static") ||
(!!bcs.transform && bcs.transform !== "none") || (!!bcs.translate && bcs.translate !== "none") ||
(!!bcs.rotate && bcs.rotate !== "none") || (!!bcs.scale && bcs.scale !== "none") ||
(!!bcs.perspective && bcs.perspective !== "none") || (!!bcs.filter && bcs.filter !== "none"));
const childCb = isContents ? cbAncestor : (establishesCb ? node : cbAncestor);
// height:100% fill (a card filling its stretched grid cell, a figure filling that card). Decided
// top-down: a node fills its parent's content height where the parent has a definite height and
// doesn't itself auto-stretch the node. The fill chains — a node we give 100% confers a definite
// height to ITS children — so a wrapper>card>figure nest all fill, equalising every card (instead
// of the long-quote figures' px height being dropped to auto and overflowing their neighbours).
// h-full when EITHER the structural inference proves it (isHeightFill: fills a definite-height
// 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);
const mediaCover = !isContents && absoluteMediaCoversParent(node, parentNode, sampleVps);
const absHeightFill = !isContents && absoluteHeightFill(node, parentNode, sampleVps);
const buttonFixedHeight = !isContents && (fixedHeightButtonLike(node, sampleVps) || sourceFixedSize);
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
// documented at the original site below.
const flowH = !isContents && !buttonFixedHeight && (heightFlows(node, parentNode, sampleVps) || heightProbeDrops(node, sampleVps, reflow));
const childDefiniteHeight = isContents ? parentDefiniteHeight : confersDefiniteHeight(node, parentNode, heightFill, flowH, parentDefiniteHeight, sampleVps);
// Generalised fluidity (centred max-width container / fills-container / fractional width),
// inferred from the per-viewport samples — independent of the full-bleed chain (auto/% always
// reproduce the captured px at every sampled width, whatever the ancestor sizing). Measured
// against the real DOM parent exactly as on main: a display:contents parent makes these bail,
// leaving the full-bleed chain above to carry the fluidity through the wrapper.
const inferred = planWidth(node, parentNode, sampleVps);
// A grid item whose width is set by its (fluid) track span: drop the baked px so it follows the
// track. A `flex gap w-1/3` equal-fill row item: re-express as flex:1 1 0 so it scales.
const gridFill = isGridItemFill(node, parentNode, sampleVps);
const flexFill = !gridFill && isFlexFillItem(node, parentNode, sampleVps);
// A carousel slide (`shrink-0` flex/grid item) whose width was set only by the library's injected
// inline px and whose children all FILL it → the probe reads it as fill/content and drops the
// width, collapsing the slide + its fill children + the track to 0×0. Pin the captured px so the
// fill chain has a definite basis. Wins over the probe/fill detectors below (it IS the width source).
const circularSlide = !isContents && !gridFill && !flexFill && isCircularShrinkSlide(node, parentNode, sampleVps);
// A full-VIEWPORT-width shrink-0 carousel slide → flex-basis:100% (definite main size) instead of
// width:100%, so the flex row can't over-widen from the slide's max-content (the splide02 hero).
const fullWidthSlide = !isContents && !gridFill && !flexFill && !circularSlide && isFullWidthShrinkSlide(node, parentNode, sampleVps);
const fillsContainer = !gridFill && !flexFill && !circularSlide && !fullWidthSlide && isFillsContainerWidth(node, parentNode, sampleVps);
const replacedFill = replacedFillsContainer(node, parentNode, sampleVps); // an img/video filling its cell → w-full
const sourceFill = sourceWidthFillIntent(node);
// A full-bleed banner image (width ≈ viewport, grows) baked to per-vp px → w-full so it keeps
// filling beyond the widest captured band instead of leaving a gutter on a wide monitor.
const bleedImg = !isContents && !replacedFill && fullBleedImageFill(node, parentNode, sampleVps);
// An inline <svg> logo sized by height with width:auto (provably aspect-scaled, viewBox-verified)
// → drop the baked per-vp width to `auto` so it tracks the height instead of pinning per breakpoint.
const svgAuto = !isContents && !replacedFill && !bleedImg && svgAspectWidthAuto(node, sampleVps);
const maxWidthFill = !gridFill && !flexFill && !fillsContainer && isMaxWidthFill(node, parentNode, sampleVps);
// A block-level child filling its block parent (the commonest fluid case) → drop width (auto).
const blockFill = !gridFill && !flexFill && !fillsContainer && !maxWidthFill && fillsBlockContainer(node, parentNode, sampleVps);
// An absolute box pinned by both left+right insets → drop width (auto stretches between them).
const insetSpanned = !isContents && insetSpannedAbsolute(node, parentNode, sampleVps);
// If that inset-spanned box ALSO carries an authored aspect-ratio (a full-bleed hero slide:
// `absolute inset-x-0 aspect-video min-h-[…]`), `width:auto` does NOT stay pinned to the insets:
// with a definite height (min-height) and a definite aspect-ratio, the width BACK-COMPUTES from
// height × aspect and over-widens at any width the capture didn't sample (the splide02 hero
// measuring 768/641px wide inside a 572px window — the responsive full-bleed violations). Emit an
// explicit `width:100%` instead: it fills the containing block (identical to the inset-0 span at
// every sampled width, gate-neutral) and, being definite, WINS the width axis so the aspect-ratio
// drives only the height. Scoped to inset-spanned boxes that actually have an aspect-ratio.
const insetSpannedAspect = insetSpanned && sampleVps.some((vp) => {
const ar = node.computedByVp[vp]?.aspectRatio;
return !!ar && ar !== "auto" && node.visibleByVp[vp];
});
// Combine: the full-bleed chain wins (it proved the box spans the viewport), then grid/flex
// fill, then "fills its container" / "fills under a max-width cap" (→ width:100%), then the
// inferred generalisations.
// A fill-to-cap container (width = min(parentContent, CAP)) MUST be decided before the
// content-sized detectors below — they misread a capped fill as content and drop its width.
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 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
// constrains it, but dropping the fill collapses flex-column wrappers to their content width.
// Therefore fillCap outranks the probe; the probe owns the remaining content/fill cases.
const probe = !isContents && !buttonFixedWidth ? sizingVerdict(node, sampleVps) : null;
// A width that's a stable fraction of its container per breakpoint regime (possibly a different
// fraction per regime: 100% stacked on mobile, 50% side-by-side on desktop) → per-band `width:N%`
// so it grows/shrinks fluidly instead of freezing each regime at baked px. Last fluid resort
// before the inferred block-only generalisations, and only for genuinely load-bearing widths.
const percentVp = (!lockWidth && !isContents) ? fluidPercentByVp(node, parentNode, sampleVps) : null;
// A box that fills its container at some widths but is a fixed scroll-track at others (a desktop
// card grid that becomes a mobile horizontal scroller) → per-vp width: 100% where it fills, baked
// px where it overflows. Only when the clean per-band fraction law (percentVp) didn't already fit.
const mixedFill = (!lockWidth && !isContents && !percentVp) ? mixedFillByVp(node, parentNode, sampleVps) : null;
const widthPlan: WidthPlan =
fillCap ? { kind: "fillcap", cap: fillCap }
: sourceFixedSize ? inferred.plan
: circularSlide ? { kind: "fixed" }
: fullWidthSlide ? { kind: "basisFull" }
: insetSpannedAspect ? { kind: "fill" }
: probe === "auto" ? { kind: "auto" }
: probe === "fill" ? { kind: "fill" }
: (!lockWidth && autoWidthFlex.has(node.id)) ? { kind: "auto" }
: fluidMode === "fill" || fillsContainer || maxWidthFill || replacedFill || sourceFill || bleedImg || mediaCover ? { kind: "fill" }
: svgAuto ? { kind: "auto" }
: (!lockWidth && !isContents && contentSizedColumnItem(node, parentNode, sampleVps)) ? { kind: "auto" }
: (!lockWidth && !isContents && contentSizedFlexItemAuto(node, parentNode, sampleVps)) ? { kind: "auto" }
: (!lockWidth && !isContents && isInlineContentBox(node, sampleVps)) ? { kind: "auto" }
: (!lockWidth && !isContents && isContentSizedColumnTextLeaf(node, parentNode, sampleVps)) ? { kind: "auto" }
: blockFill || insetSpanned ? { kind: "auto" }
: fluidMode === "auto" || gridFill ? { kind: "auto" }
: (!lockWidth && flexFill) ? { kind: "flexfill" }
: percentVp ? { kind: "percentVp", pctByVp: percentVp }
: mixedFill ? { kind: "percentVp", pctByVp: mixedFill }
: inferred.plan;
const centerAlways = inferred.centerAlways;
const centeredAtAnySample = !!layoutParent && sampleVps.some((vp) => centeredAtVp(node, layoutParent, vp));
// A single-line text leaf sitting flush against its container's available width at every painted
// width → `white-space:nowrap` so a sub-pixel column shortfall in the clone can't wrap it. Decided
// once per node (uniform across bands); measured against layoutParent to see through contents wrappers.
const nowrapText = !isContents && nowrapWrapVulnerable(node, layoutParent, sampleVps);
// If this node has a NON-identity transform at any painted band, the identity `none` must be
// emitted at the bands that have it so the transform can't cascade across bands (freezing a
// width the source left untransformed). Identity matrices are canonicalized to "none" upstream.
let hasNonIdentityTransform = false, hasIdentityTransform = false;
for (const vp of sampleVps) {
const t = node.computedByVp[vp]?.transform;
if (t === undefined) continue;
if (t === "none") hasIdentityTransform = true;
else hasNonIdentityTransform = true;
}
const keepIdentityTransform = hasNonIdentityTransform && hasIdentityTransform;
const stableCenter =
centerAlways ||
sourceMarginAutoIntent(node) ||
((!!fillCap || hasPxMaxWidthCap(node, sampleVps)) && centeredAtAnySample);
// 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
// safe. Multi-row grids are handled by the older equal-track detector.
const singleRowsByVp = !isContents ? singleFluidGridRow(node, sampleVps) : null;
// A lottie mount pins its captured height (replaced-like); it wins over the flow/media laws so the
// runtime player's aspect-sized svg fills a definite box instead of inflating past its neighbours.
const lottieHeight = !isContents && lottieMounts?.has(node.id) ? lottieMountHeight(node, sampleVps) : null;
const mediaGeometry = lottieHeight ? { heightByVp: lottieHeight } : singleRowsByVp ? mediaHeightGeometry(node, sampleVps) : {};
const leftClampByVp = !isContents ? centeredInsetClamp(node, cbAncestor, sampleVps, "x") : null;
const topClampByVp = !isContents ? centeredInsetClamp(node, cbAncestor, sampleVps, "y") : null;
const geometry: GeometryPlan = (mediaGeometry.heightByVp || mediaGeometry.aspectByVp || leftClampByVp || topClampByVp)
? { ...mediaGeometry, ...(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);
const dropGridRows = reflow && gridRowsByVp == null &&
sampleVps.some((vp) => /^(grid|inline-grid)$/.test(node.computedByVp[vp]?.display || ""));
const dropViewportMaxWidth = !isContents && maxWidthTracksViewport(node, sampleVps);
const emit = !includeNode || includeNode(node.id);
if (!emit) {
for (const c of node.children) if (!isTextChild(c)) walk(c, node, childFluid, childLayoutParent, childCb, childDefiniteHeight);
return;
}
// Centering is decided PER VIEWPORT (an element can be an auto-centred block at one width but a
// left-aligned flex item at another — emitting margin:auto globally wrongly centred duolingo's
// flex-item links) — except a centred max-width container, centred at EVERY width (centerAlways)
// so it never freezes at one band's baked margins. Measured against layoutParent so it sees
// through display:contents wrappers.
const centeredBase = stableCenter || (layoutParent ? centeredAtVp(node, layoutParent, baseVp) : false);
// Height dropping. Two complementary signals, OR'd:
// • heightFlows — a static structural check (content-extent / flex-stretch) that only drops a
// VARYING height (removes a band), with strict 2px guards.
// • heightProbeDrops — the browser-as-oracle reading: height:auto reproduced this box within
// 0.5px at EVERY painted viewport. Catches content-derived heights heightFlows' guards miss
// (rounding >2px, constant heights), incl. the ones that became per-viewport `h-[…]` bands.
// Naive per-element dropping once broke the layout gate because vertical slop accumulates down the
// stacked page; the 0.5px unanimous-across-viewports probe + refineSizing convergence (re-measures
// after the drop) is what makes it safe. The layout/responsive/perceptual gates backstop drift.
// (flowH is computed earlier in the walk so it can also feed childDefiniteHeight.)
// A centred overlay's baked `left:Npx` → `left:50%` so it tracks the centre (the translate is
// already replayed). Constant-fraction-of-containing-block, proven against every sample.
const leftPct = isContents ? null : centerLeftPct(node, cbAncestor, sampleVps);
let dropInsets = isContents ? EMPTY_SET : insetDropSides(node, sampleVps);
// The inset-anchor probe judges each side's redundancy ASSUMING the baked width stays. But
// `insetSpanned` drops the width (→ auto, stretching between left&right). If both fire, the box
// loses its width AND the insets that would re-derive it → it collapses to content width (0 for an
// empty full-bleed layer → its gradient paints nothing; shrink-wrapped for a bar like the navbar →
// bunched to one edge). When the width comes FROM the inset span, those insets are load-bearing:
// keep left+right so `width:auto inset-x-0` reproduces the full-bleed box.
if (insetSpanned && (dropInsets.has("left") || dropInsets.has("right"))) {
dropInsets = new Set(dropInsets);
dropInsets.delete("left");
dropInsets.delete("right");
}
if (absHeightFill && (dropInsets.has("top") || dropInsets.has("bottom"))) {
dropInsets = new Set(dropInsets);
dropInsets.delete("top");
dropInsets.delete("bottom");
}
if (mediaCover && (dropInsets.has("top") || dropInsets.has("right") || dropInsets.has("bottom") || dropInsets.has("left"))) {
dropInsets = new Set(dropInsets);
dropInsets.delete("top");
dropInsets.delete("right");
dropInsets.delete("bottom");
dropInsets.delete("left");
}
const animOwned = animOwnedProps(node, baseVp); // opacity/transform driven by an infinite animation
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 nr: NodeRule = { base: baseDecls, bands: [] };
// Per-band overrides (delta vs base), using the parent's value AT THAT viewport.
for (const b of bands) {
if (!b.media) continue;
// Node was not in the observed DOM at this width (responsive conditional
// rendering): hide it so the clone matches the source at this viewport.
if (!node.computedByVp[b.vp]) { nr.bands.push({ media: b.media, decls: new Map([["display", "none"]]) }); continue; }
// The node isn't PAINTED at this width. HOW it is hidden decides what to emit, because only
// `display:none` takes the box out of layout — a `visibility:hidden` box still occupies space
// and can extend the scrollable area. The base rule bakes CANONICAL geometry unconditionally,
// so skipping every override here can park e.g. a desktop `left:548px` slider arrow inside a
// 375px viewport: invisible, but +210px of sideways scroll.
const vpCs = node.computedByVp[b.vp]!;
const ownNone = (vpCs.display || "") === "none";
// The node ITSELF is visibility:hidden here (its parent isn't → not inherited from an
// ancestor whose own rule already carries the hide).
const ownHidden = !ownNone && /^(hidden|collapse)$/.test(vpCs.visibility || "") &&
!/^(hidden|collapse)$/.test(parentNode?.computedByVp[b.vp]?.visibility || "");
if (ownHidden) {
const bb = node.bboxByVp[b.vp];
if (!bb || bb.width <= 0 || bb.height <= 0) {
// The hidden box occupied NOTHING in the capture (0×0 — e.g. an uninitialised swiper
// arrow collapsed by its container). `display:none` reproduces "invisible, takes no
// space" exactly and guarantees it cannot extend scroll bounds at this width.
nr.bands.push({ media: b.media, decls: new Map([["display", "none"]]) });
continue;
}
// Non-zero bbox: the invisible box occupies real layout space. Fall through to the normal
// per-viewport delta so it sits where the capture measured it at THIS width — not at the
// canonical geometry the base rule baked. The hide itself rides along in the delta
// (declsForViewport emits `visibility:hidden`; parent-equality keeps it own-only).
} else if (ownNone || !node.visibleByVp[b.vp]) {
const shownAtBase = node.visibleByVp[baseVp] && (node.computedByVp[baseVp]?.display || "") !== "none";
// Hidden by an ANCESTOR's visibility here AND at base (the ancestor's own rule carries
// the hide at every width) — but a visibility:hidden box still PARTICIPATES in layout,
// and the base rule bakes CANONICAL geometry. Same policy as the own-hidden path above:
// a 0x0 box gets display:none; an occupying box falls through to the per-viewport delta
// so it sits where the capture measured it at THIS width — not parked at e.g. a desktop
// left:548px inside a 375px viewport (the cropin.com/cotton slider arrow, +210px of
// sideways scroll at 375 with the hide inherited from an elementor-widget ancestor).
const ancestorHiddenHere = !ownNone && /^(hidden|collapse)$/.test(vpCs.visibility || "");
if (ancestorHiddenHere && !shownAtBase) {
const bb = node.bboxByVp[b.vp];
if (!bb || bb.width <= 0 || bb.height <= 0) {
nr.bands.push({ media: b.media, decls: new Map([["display", "none"]]) });
continue;
}
// Occupying: fall through to the normal per-viewport delta below.
} else {
if (ownNone) {
// Own display:none removes the box from layout entirely. Emit the hide even when the node
// is ALSO hidden at base — a visibility:hidden base still bakes an OCCUPYING box (see
// above), so without this band the canonical geometry would render at this width. Skip
// only when the base itself is display:none (the band would be redundant).
if ((node.computedByVp[baseVp]?.display || "") !== "none") {
nr.bands.push({ media: b.media, decls: new Map([["display", "none"]]) });
}
} else if (shownAtBase) {
// Hidden by an ancestor (or zero-size / opacity:0) but visible at base: geometry
// overrides are breakpoint noise — the ancestor's own hide (or the reveal replay,
// for scroll-reveal opacity) covers it. Emit only the hide the node itself carries.
const hide = new Map<string, string>();
if (pf(vpCs.opacity) === 0 && !animOwned.has("opacity")) hide.set("opacity", "0");
if (/^(hidden|collapse)$/.test(vpCs.visibility || "")) hide.set("visibility", "hidden");
if (hide.size) nr.bands.push({ media: b.media, decls: hide });
}
continue;
}
}
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 delta = new Map<string, string>();
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));
// Drop bands for properties an infinite animation owns: the per-viewport delta is frozen
// phase noise, so let the base value hold at every width (the animation drives it live).
for (const p of animOwned) delta.delete(p);
if (delta.size > 0) nr.bands.push({ media: b.media, decls: delta });
}
if (node.beforeByVp || node.afterByVp) {
// Host content-box width per viewport — lets a clearfix/spacer pseudo whose baked px width is
// really `width:100%` re-fluidise (and stop pinning the host's min-content to the capture px).
const hostCW: Record<number, number> = {};
// Padding box = border box borders: the containing block of an ABSOLUTE pseudo (inset-0
// border/overlay). Only meaningful when this host establishes that containing block (it is
// positioned/transformed); otherwise the pseudo's CB is an ancestor and we pass nothing.
const hostPadW: Record<number, number> = {};
const hostPadH: Record<number, number> = {};
for (const vp of ir.doc.viewports) {
const cs = node.computedByVp[vp]; const nb = node.bboxByVp[vp];
if (!cs || !nb) continue;
const w = nb.width - pf(cs.paddingLeft) - pf(cs.paddingRight) - pf(cs.borderLeftWidth) - pf(cs.borderRightWidth);
if (w > 0) hostCW[vp] = w;
const pw = nb.width - pf(cs.borderLeftWidth) - pf(cs.borderRightWidth);
const ph = nb.height - pf(cs.borderTopWidth) - pf(cs.borderBottomWidth);
if (pw > 0) hostPadW[vp] = pw;
if (ph > 0) hostPadH[vp] = ph;
}
const padW = establishesCb ? hostPadW : undefined;
const padH = establishesCb ? hostPadH : undefined;
if (node.beforeByVp && !isRestingInteractiveFillPseudo(node, node.beforeByVp, ir.doc.viewports)) {
nr.before = collectPseudoRule(node.beforeByVp, baseVp, bands, assetMap, tokenResolver, hostCW, padW, padH);
}
if (node.afterByVp && !isRestingInteractiveFillPseudo(node, node.afterByVp, ir.doc.viewports)) {
nr.after = collectPseudoRule(node.afterByVp, baseVp, bands, assetMap, tokenResolver, hostCW, padW, padH);
}
}
if (node.placeholderByVp) {
nr.placeholder = collectPlaceholderRule(node.placeholderByVp, node.computedByVp, baseVp, bands, tokenResolver);
}
rules.set(node.id, nr);
for (const c of node.children) if (!isTextChild(c)) walk(c, node, childFluid, childLayoutParent, childCb, childDefiniteHeight);
};
// The root's containing block is the viewport-filling <html>, so it starts the fluid chain.
walk(ir.root, undefined, true, undefined, undefined, false);
return rules;
}
/** Assemble a CSS document from per-selector rules: base block then media-banded
* blocks, with @keyframes first. Shared by the per-node and class-map emitters so
* both produce byte-identical structure. `selBase`/`selBand` map a key (cid or class)
* to its rule; `order` is the emission order. */
export function assembleCss(
order: string[],
ruleFor: (key: string) => NodeRule,
selOf: (key: string) => string,
bands: Band[],
keyframes: string,
): string {
const baseRules: string[] = [];
const bandRules = new Map<string, string[]>();
for (const b of bands) if (b.media) bandRules.set(b.media, []);
for (const key of order) {
const nr = ruleFor(key);
const sel = selOf(key);
if (nr.base.size > 0) baseRules.push(formatRule(sel, nr.base));
if (nr.before) baseRules.push(formatRule(`${sel}::before`, nr.before.base));
if (nr.after) baseRules.push(formatRule(`${sel}::after`, nr.after.base));
if (nr.placeholder) baseRules.push(formatRule(`${sel}::placeholder`, nr.placeholder.base));
for (const b of nr.bands) bandRules.get(b.media)?.push(formatRule(sel, b.decls));
if (nr.before) for (const b of nr.before.bands) bandRules.get(b.media)?.push(formatRule(`${sel}::before`, b.decls));
if (nr.after) for (const b of nr.after.bands) bandRules.get(b.media)?.push(formatRule(`${sel}::after`, b.decls));
if (nr.placeholder) for (const b of nr.placeholder.bands) bandRules.get(b.media)?.push(formatRule(`${sel}::placeholder`, b.decls));
}
const parts: string[] = [];
if (keyframes) parts.push(keyframes);
parts.push(baseRules.join("\n"));
for (const b of bands) {
if (!b.media) continue;
const rules = bandRules.get(b.media)!;
if (rules.length === 0) continue;
parts.push(`${b.media} {\n${rules.join("\n")}\n}`);
}
return parts.join("\n\n") + "\n";
}
export function generateCss(ir: IR, assetMap: Map<string, string>, includeNode?: (id: string) => boolean, colorVar?: (value: string) => string | null, tokenResolver?: TokenResolver): string {
const rules = collectNodeRules(ir, assetMap, includeNode, colorVar, tokenResolver);
const bands = computeBands(ir.doc.viewports, ir.doc.canonicalViewport);
// Stage 5: emit referenced @keyframes first so the per-node `animation-*` decls resolve.
const kf = keyframesCss(ir, assetMap, includeNode);
return assembleCss([...rules.keys()], (cid) => rules.get(cid)!, (cid) => `.c${cid}`, bands, kf);
}
// Reset value for a property removed at a viewport band (to override the base).
const RESET_VALUES: Record<string, string> = {
display: "block", width: "auto", height: "auto", "min-height": "0",
position: "static", top: "auto", right: "auto", bottom: "auto", left: "auto",
};
// Inherited properties (kebab-case) — when removed at a band, restore inheritance
// from the parent rather than resetting to the CSS initial value.
const INHERITED_KEBAB = new Set([
"color", "font-family", "font-size", "font-weight", "font-style", "line-height",
"letter-spacing", "word-spacing", "text-align", "text-transform", "white-space",
"word-break", "overflow-wrap", "text-indent", "font-variant-caps", "font-feature-settings",
"list-style-type", "list-style-position", "writing-mode", "direction", "cursor",
"text-shadow", "visibility", "text-decoration-color", "-webkit-text-stroke", "-webkit-text-fill-color",
]);
function resetValue(prop: string): string {
if (INHERITED_KEBAB.has(prop)) return "inherit";
if (RESET_VALUES[prop]) return RESET_VALUES[prop]!;
if (prop.startsWith("margin") || prop.startsWith("padding")) return "0";
if (prop.startsWith("border") && prop.endsWith("width")) return "0";
if (prop === "transform") return "none";
if (prop === "background-image") return "none";
if (prop.startsWith("max-")) return "none";
if (prop.startsWith("min-")) return "0";
return "initial";
}
function formatRule(sel: string, decls: Map<string, string>): string {
const body = [...decls.entries()].map(([k, v]) => `${k}:${v}`).join(";");
return `${sel}{${body}}`;
}
function formatRuleStr(sel: string, decls: string[]): string {
return `${sel}{${decls.join(";")}}`;
}