Files
ditto.site/compiler/test/css.test.ts
Samraaj BathandClaude Opus 4.8 27cccd8964 Don't lock content-derived button widths; nowrap icon+text chips
Two general fixes for single-line chips/pills whose labels wrapped onto
a second line in generated output:

1. fixedWidthButtonLike treated any constant-width button as authored
   fixed-width, but a content-sized chip whose label never changes paints
   the same width at every viewport. When the sizing probe proved
   width:auto reproduces the box at its own max-content at every painted
   sample (with genuinely wrappable text inside), the width is
   content-derived - don't lock it. Baking that px invited the emitters'
   quantization (snapLen 0.1px rounding, snapBase spacing-scale snap) to
   land fractionally below the intrinsic single-line width, wrapping the
   last word. Authored intent (sourceFixedWidth/sourceFixedSize) still
   locks.

2. nowrapWrapVulnerable bailed on any element child, so an icon svg
   beside the label disqualified the classic icon+text chip shape.
   Replaced children (svg/img/...) can't wrap and their constant width is
   already inside the probe's wMin/wMax, so they're now permitted; any
   non-replaced element child still bails.

525 tests (5 new), typecheck clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 13:36:09 -07:00

1520 lines
102 KiB
TypeScript
Raw Permalink 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 { describe, it } from "node:test";
import assert from "node:assert/strict";
import type { IR, IRNode, IRChild, StyleMap, BBox } from "../src/normalize/ir.js";
import type { RawSizing } from "../src/capture/walker.js";
import { generateCss, collectNodeRules, RESET_CSS } from "../src/generate/css.js";
const VPS = [375, 1280];
const CANONICAL = 1280;
/** Computed style with the minimum the emitter reads, per viewport. */
function computed(over: StyleMap = {}): StyleMap {
return { display: "block", position: "static", visibility: "visible", listStyleType: "disc", listStylePosition: "outside", ...over };
}
function node(id: string, tag: string, cs: StyleMap, children: IRChild[] = [], visible = true): IRNode {
const computedByVp: Record<number, StyleMap> = {};
const bboxByVp: Record<number, BBox> = {};
const visibleByVp: Record<number, boolean> = {};
for (const vp of VPS) {
computedByVp[vp] = { ...cs };
bboxByVp[vp] = { x: 0, y: 0, width: vp, height: 100 };
visibleByVp[vp] = visible;
}
return { id, tag, attrs: {}, visibleByVp, bboxByVp, computedByVp, children };
}
function irWith(root: IRNode): IR {
return {
doc: {
sourceUrl: "https://example.test/css",
title: "CSS Fixture",
lang: "en",
charset: "UTF-8",
metaViewport: "width=device-width, initial-scale=1",
viewports: VPS,
sampleViewports: VPS,
canonicalViewport: CANONICAL,
perViewport: Object.fromEntries(VPS.map((vp) => [vp, { scrollHeight: 800, scrollWidth: vp, htmlBg: "rgb(255, 255, 255)", bodyBg: "rgb(255, 255, 255)", bodyColor: "rgb(0, 0, 0)", bodyFont: "Arial" }])),
nodeCount: 4,
keyframes: [],
},
root,
};
}
/** The base-rule body for a selector (first non-banded `.c<id>{…}` block). */
function baseRule(css: string, id: string): string {
const m = css.match(new RegExp(`\\.c${id}\\{([^}]*)\\}`));
return m?.[1] ?? "";
}
// Root scroll-lock un-clamp: a popup vendor locks the page with
// body{position:absolute;overflow:hidden;height:100vh}, which the capture bakes as the body's
// computed style AND collapses document.scrollHeight down to the clamp (so the captured-scrollHeight
// trigger can never fire). The IR's in-flow CONTENT EXTENT (a footer laid out at y=5000) still
// exceeds the clamp, so the un-clamp must fire off that signal and strip the lock.
describe("generateCss root scroll-lock un-clamp (content-extent trigger)", () => {
it("drops the body height/position/overflow lock when IR content extent exceeds the clamp", () => {
const footer = node("n1", "footer", computed());
// Footer sits far below the one-viewport clamp at every viewport.
for (const vp of VPS) footer.bboxByVp[vp] = { x: 0, y: 4000, width: vp, height: 1000 }; // bottom = 5000
const bodyCs = computed({ position: "absolute", overflow: "hidden", overflowY: "hidden", height: "812px", top: "0px", left: "0px" });
const root = node("n0", "body", bodyCs, [footer]);
for (const vp of VPS) root.bboxByVp[vp] = { x: 0, y: 0, width: vp, height: 812 };
// perViewport.scrollHeight stays collapsed to 800 (the lock's doing) — so ONLY the content
// extent can trigger the un-clamp.
const css = generateCss(irWith(root), new Map());
const body = baseRule(css, "n0");
assert.ok(!/height:812px/.test(body), `body height clamp dropped (got: ${body})`);
assert.ok(!/position:absolute/.test(body), `body position:absolute stripped (got: ${body})`);
assert.ok(/overflow-y:visible/.test(body), `overflow-y forced visible (got: ${body})`);
assert.ok(!/overflow:hidden/.test(body), `overflow:hidden not emitted (got: ${body})`);
});
it("does NOT un-clamp a genuine one-screen page (content extent within the clamp)", () => {
const inner = node("n1", "div", computed());
for (const vp of VPS) inner.bboxByVp[vp] = { x: 0, y: 0, width: vp, height: 700 };
const bodyCs = computed({ overflow: "hidden", overflowY: "hidden", height: "800px" });
const root = node("n0", "body", bodyCs, [inner]);
for (const vp of VPS) root.bboxByVp[vp] = { x: 0, y: 0, width: vp, height: 800 };
const css = generateCss(irWith(root), new Map());
const body = baseRule(css, "n0");
// The clamp is legitimate (content fits) — height kept, overflow not forced to visible.
assert.ok(/height:800px/.test(body), `legit body height preserved (got: ${body})`);
});
});
describe("generateCss list markers", () => {
it("re-establishes list-style on a <ul> whose disc equals the parent's initial value", () => {
// list-style-type's initial value is `disc` on EVERY element, so a real <ul disc>
// equals its parent <div>'s computed value — but the reset (`ul, ol, menu
// { list-style: none; }`) breaks the inheritance chain, so it must still emit.
const li = node("n2", "li", computed({ display: "list-item" }));
const ul = node("n1", "ul", computed(), [li]);
const root = node("n0", "body", computed(), [ul]);
const css = generateCss(irWith(root), new Map());
assert.ok(baseRule(css, "n1").includes("list-style-type:disc"));
// The <li> inherits from the ul (not reset), so parent-equality elision still applies.
assert.ok(!baseRule(css, "n2").includes("list-style-type"));
});
it("does not emit list-style-type on non-list tags at the initial disc", () => {
const div = node("n1", "div", computed());
const root = node("n0", "body", computed(), [div]);
const css = generateCss(irWith(root), new Map());
assert.ok(!baseRule(css, "n1").includes("list-style-type"));
});
});
describe("generateCss visibility", () => {
it("emits visibility:hidden for a node hidden at the canonical viewport", () => {
const hidden = node("n1", "div", computed({ visibility: "hidden" }), [], false);
const shown = node("n2", "div", computed());
const root = node("n0", "body", computed(), [hidden, shown]);
const css = generateCss(irWith(root), new Map());
assert.ok(baseRule(css, "n1").includes("visibility:hidden"));
assert.ok(!baseRule(css, "n2").includes("visibility"));
});
it("restores inherited visibility at a band where the node is shown", () => {
const n = node("n1", "div", computed({ visibility: "hidden" }), [], false);
n.computedByVp[375]!.visibility = "visible";
n.visibleByVp[375] = true;
const root = node("n0", "body", computed(), [n]);
const css = generateCss(irWith(root), new Map());
assert.ok(baseRule(css, "n1").includes("visibility:hidden"));
const band = css.match(/@media \(max-width: \d+px\) \{\n([\s\S]*?)\n\}/);
assert.ok(band?.[1]?.includes("visibility:inherit"));
});
it("stays silent on the descendants of a hidden subtree (inheritance covers them)", () => {
const child = node("n2", "div", computed({ visibility: "hidden" }), [], false);
const parent = node("n1", "div", computed({ visibility: "hidden" }), [child], false);
const root = node("n0", "body", computed(), [parent]);
const css = generateCss(irWith(root), new Map());
assert.ok(baseRule(css, "n1").includes("visibility:hidden"));
assert.ok(!baseRule(css, "n2").includes("visibility"));
});
});
// Hidden-node banded geometry. A `visibility:hidden` box still PARTICIPATES in layout (unlike
// `display:none`), so the emitter must not let the base rule's baked CANONICAL geometry stand at
// widths where the capture measured something else — that is how a desktop `left:548px` slider
// arrow ends up parked, invisibly, 210px past the right edge of a 375px viewport.
describe("generateCss hidden-node banded geometry", () => {
const HVPS = [375, 1280, 1920];
type VpState = { cs?: StyleMap; bbox?: BBox; visible?: boolean };
function nodeAt(id: string, tag: string, byVp: Record<number, VpState>, children: IRChild[] = []): IRNode {
const computedByVp: Record<number, StyleMap> = {};
const bboxByVp: Record<number, BBox> = {};
const visibleByVp: Record<number, boolean> = {};
for (const vp of HVPS) {
const s = byVp[vp] ?? {};
computedByVp[vp] = computed(s.cs);
bboxByVp[vp] = s.bbox ?? { x: 0, y: 0, width: vp, height: 100 };
visibleByVp[vp] = s.visible ?? true;
}
return { id, tag, attrs: {}, visibleByVp, bboxByVp, computedByVp, children };
}
function ir3(root: IRNode): IR {
const ir = irWith(root);
ir.doc.viewports = HVPS;
ir.doc.sampleViewports = HVPS;
ir.doc.perViewport = Object.fromEntries(HVPS.map((vp) => [vp, { scrollHeight: 800, scrollWidth: vp, htmlBg: "rgb(255, 255, 255)", bodyBg: "rgb(255, 255, 255)", bodyColor: "rgb(0, 0, 0)", bodyFont: "Arial" }]));
return ir;
}
/** The `.c<id>{…}` body inside the first @media block whose query matches `mediaRe`. */
function bandRule(css: string, mediaRe: RegExp, id: string): string {
for (const m of css.matchAll(/@media ([^{]+) \{\n([\s\S]*?)\n\}/g)) {
if (!mediaRe.test(m[1]!)) continue;
const r = m[2]!.match(new RegExp(`\\.c${id}\\{([^}]*)\\}`));
if (r) return r[1]!;
}
return "";
}
const arrowCs = (left: string, hidden: boolean): StyleMap =>
({ display: "flex", position: "absolute", left, ...(hidden ? { visibility: "hidden" } : {}) });
it("emits the captured per-band geometry for a box its own visibility:hidden leaves occupying space", () => {
// Hidden at base AND at the mobile band with DIFFERENT lefts (the swiper-arrow shape): the
// band must carry the mobile left, not inherit the baked canonical one.
const arrow = nodeAt("n1", "div", {
375: { cs: arrowCs("37px", true), bbox: { x: 37, y: 0, width: 46, height: 46 }, visible: false },
1280: { cs: arrowCs("548px", true), bbox: { x: 548, y: 0, width: 46, height: 46 }, visible: false },
1920: { cs: arrowCs("588px", false), bbox: { x: 588, y: 0, width: 46, height: 46 } },
});
const root = nodeAt("n0", "body", { 1280: { cs: { position: "relative" } } }, [arrow]);
const css = generateCss(ir3(root), new Map());
assert.ok(baseRule(css, "n1").includes("visibility:hidden"));
assert.ok(baseRule(css, "n1").includes("left:548px"));
const mobile = bandRule(css, /max-width/, "n1");
assert.ok(mobile.includes("left:37px"), `mobile band should carry the captured left, got: ${mobile}`);
assert.ok(!mobile.includes("display:none"), "an occupying hidden box must stay in layout");
// The wide band where the node becomes visible keeps working: visibility restored + its left.
const wide = bandRule(css, /min-width/, "n1");
assert.ok(wide.includes("visibility:inherit"), `wide band should restore visibility, got: ${wide}`);
assert.ok(wide.includes("left:588px"), `wide band should carry the 1920 left, got: ${wide}`);
});
it("hides a visibility:hidden box whose captured bbox is 0x0 with display:none at that band", () => {
// At 375 the hidden arrow occupied NOTHING (uninitialised swiper) — display:none reproduces
// "renders nothing, takes no space" and cannot extend the scrollable area.
const arrow = nodeAt("n1", "div", {
375: { cs: arrowCs("calc(50% - 52px)", true), bbox: { x: 0, y: 0, width: 0, height: 0 }, visible: false },
1280: { cs: arrowCs("548px", true), bbox: { x: 548, y: 0, width: 46, height: 46 }, visible: false },
1920: { cs: arrowCs("588px", false), bbox: { x: 588, y: 0, width: 46, height: 46 } },
});
const root = nodeAt("n0", "body", { 1280: { cs: { position: "relative" } } }, [arrow]);
const css = generateCss(ir3(root), new Map());
const mobile = bandRule(css, /max-width/, "n1");
assert.ok(mobile.includes("display:none"), `0x0 hidden band should be display:none, got: ${mobile}`);
const wide = bandRule(css, /min-width/, "n1");
assert.ok(wide.includes("visibility:inherit") && wide.includes("left:588px"));
});
it("emits display:none at a band where the node turns display:none even when hidden at base", () => {
// The base rule bakes an OCCUPYING visibility:hidden box (canonical geometry); without the
// band that box would render at mobile widths where the source had display:none.
const wrap = nodeAt("n1", "div", {
375: { cs: { display: "none", visibility: "hidden" }, bbox: { x: 0, y: 0, width: 0, height: 0 }, visible: false },
1280: { cs: { visibility: "hidden" }, bbox: { x: 40, y: 0, width: 1200, height: 574 }, visible: false },
1920: { cs: {}, bbox: { x: 320, y: 0, width: 1280, height: 533 } },
});
const root = nodeAt("n0", "body", {}, [wrap]);
const css = generateCss(ir3(root), new Map());
assert.ok(baseRule(css, "n1").includes("visibility:hidden"));
const mobile = bandRule(css, /max-width/, "n1");
assert.ok(mobile.includes("display:none"), `own display:none band must emit even when hidden at base, got: ${mobile}`);
});
it("emits per-band geometry for an occupying box an ancestor hides at base AND at the band", () => {
// The cropin.com/cotton slider arrow: the elementor-widget ANCESTOR is visibility:hidden at
// 375/1280 (so the arrow is never ownHidden and never shownAtBase) yet the arrow's absolute
// box still occupies layout. Without a band the base's canonical left:548px parks it 210px
// past the right edge of a 375px viewport — the band must carry the captured mobile left.
const arrow = nodeAt("n2", "div", {
375: { cs: arrowCs("120px", true), bbox: { x: 112, y: 0, width: 46, height: 46 }, visible: false },
1280: { cs: arrowCs("548px", true), bbox: { x: 548, y: 0, width: 46, height: 46 }, visible: false },
1920: { cs: arrowCs("588px", false), bbox: { x: 588, y: 0, width: 46, height: 46 } },
});
const parent = nodeAt("n1", "div", {
375: { cs: { position: "relative", visibility: "hidden" }, visible: false },
1280: { cs: { position: "relative", visibility: "hidden" }, visible: false },
1920: { cs: { position: "relative" } },
}, [arrow]);
const root = nodeAt("n0", "body", {}, [parent]);
const css = generateCss(ir3(root), new Map());
const mobile = bandRule(css, /max-width/, "n2");
assert.ok(mobile.includes("left:120px"), `mobile band should carry the captured left, got: ${mobile}`);
assert.ok(!mobile.includes("display:none"), "an occupying hidden box must stay in layout");
});
it("still emits only the hide for a box an ANCESTOR's visibility:hidden covers", () => {
// Inherited hides stay breakpoint noise: the ancestor's own rule (and its geometry
// correction) covers the subtree — the child emits its hide, not geometry overrides.
const child = nodeAt("n2", "div", {
375: { cs: { position: "absolute", left: "10px", visibility: "hidden" }, bbox: { x: 10, y: 0, width: 40, height: 40 }, visible: false },
1280: { cs: { position: "absolute", left: "500px" }, bbox: { x: 500, y: 0, width: 40, height: 40 } },
1920: { cs: { position: "absolute", left: "500px" }, bbox: { x: 500, y: 0, width: 40, height: 40 } },
});
const parent = nodeAt("n1", "div", {
375: { cs: { position: "relative", visibility: "hidden" }, visible: false },
1280: { cs: { position: "relative" } },
1920: { cs: { position: "relative" } },
}, [child]);
const root = nodeAt("n0", "body", {}, [parent]);
const css = generateCss(ir3(root), new Map());
const mobile = bandRule(css, /max-width/, "n2");
assert.ok(mobile.includes("visibility:hidden"), `child should carry the hide, got: ${mobile}`);
assert.ok(!mobile.includes("left:10px"), `ancestor-hidden child must not emit geometry, got: ${mobile}`);
});
it("emits per-band geometry for an occupying slide off-viewport at EVERY width, base included", () => {
// A carousel slide 612: a shrink-0 flex item off-screen RIGHT at every captured width — 375,
// the 1280 canonical base, AND 1920 — so it is never ownHidden, never ancestor-hidden, and never
// visibleByVp[base]=true (the walker marks `bbox.x >= vpW` invisible). It is NOT suppressed
// (no opacity:0 / visibility:hidden) and stays IN FLOW, so it still sets its flex track's
// cross-size. Without a band the base's canonical desktop width freezes across every viewport and
// inflates the mobile track. The band must carry THIS width's measured geometry.
const slide = nodeAt("n2", "div", {
375: { cs: { display: "block", position: "static", opacity: "1", width: "220px" }, bbox: { x: 400, y: 0, width: 220, height: 432 }, visible: false },
1280: { cs: { display: "block", position: "static", opacity: "1", width: "285px" }, bbox: { x: 1500, y: 0, width: 285, height: 532 }, visible: false },
1920: { cs: { display: "block", position: "static", opacity: "1", width: "285px" }, bbox: { x: 2200, y: 0, width: 285, height: 532 }, visible: false },
});
const track = nodeAt("n1", "div", {
375: { cs: { display: "flex", position: "relative" }, bbox: { x: 0, y: 0, width: 375, height: 432 } },
1280: { cs: { display: "flex", position: "relative" }, bbox: { x: 0, y: 0, width: 1280, height: 532 } },
1920: { cs: { display: "flex", position: "relative" }, bbox: { x: 0, y: 0, width: 1920, height: 532 } },
}, [slide]);
const root = nodeAt("n0", "body", {}, [track]);
const css = generateCss(ir3(root), new Map());
assert.ok(baseRule(css, "n2").includes("width:285px"), `base bakes canonical width, got: ${baseRule(css, "n2")}`);
const mobile = bandRule(css, /max-width/, "n2");
assert.ok(mobile.includes("width:220px"), `mobile band must carry the measured 220px width, got: ${mobile}`);
assert.ok(!mobile.includes("display:none"), "an occupying off-viewport slide must stay in layout");
});
});
// Fix 1 — mobile nav chip-strip overlap. A horizontally-scrollable flex strip (overflow-x:auto
// flex <ul>) holds nowrap chips; the base viewport can report `min-width:0px` on those flex-item
// chips (e.g. a mobile-only strip collapsed to 0 at desktop). Emitting `min-w-0` lets the chips
// compress below their content width instead of overflowing, collapsing the scroll strip so the
// nowrap chip text collides ("Pizza OvenSpiral Mixe…"). The emitter must suppress `min-w-0` for a
// nowrap flex item inside an overflow-x:auto/scroll flex parent.
describe("generateCss scroll-strip chip min-width", () => {
it("suppresses min-w-0 on a nowrap chip inside an overflow-x:auto flex strip", () => {
const chip = node("n2", "li", computed({ display: "list-item", minWidth: "0px", whiteSpace: "nowrap" }));
const ul = node("n1", "ul", computed({ display: "flex", overflowX: "auto", columnGap: "8px" }), [chip]);
const root = node("n0", "body", computed(), [ul]);
const css = generateCss(irWith(root), new Map());
assert.ok(!baseRule(css, "n2").includes("min-width"), `chip must not carry min-width:0 in a scroll strip, got: ${baseRule(css, "n2")}`);
});
it("still emits min-w-0 for a nowrap flex item whose parent does NOT scroll horizontally", () => {
// A truncation/ellipsis flex child (parent overflow-x:visible) legitimately needs min-w-0 to
// shrink below content — the fix is scoped to overflow-x:auto/scroll parents, so this is kept.
const item = node("n2", "div", computed({ minWidth: "0px", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }));
const row = node("n1", "div", computed({ display: "flex" }), [item]);
const root = node("n0", "body", computed(), [row]);
const css = generateCss(irWith(root), new Map());
assert.ok(baseRule(css, "n2").includes("min-width:0"), `non-scrolling flex row keeps min-w-0, got: ${baseRule(css, "n2")}`);
});
});
// Fix 3 — scroll-linked text-fill frozen at end state. A scroll/view-timeline animation reports
// its resolved `animation-duration` as `auto`. The clone has no scroll timeline, so emitting the
// animation-* props makes it jump straight to its END keyframe (fill-mode:both + a 0s time-based
// duration), freezing e.g. a text-fill 100% filled at rest. The emitter must suppress the
// animation-* longhands when animation-duration is `auto`, leaving the captured at-rest statics.
describe("generateCss scroll-timeline animation suppression", () => {
it("drops animation-* props for an animation-duration:auto (scroll-timeline) node", () => {
const em = node("n1", "em", computed({
animationName: "fillAnimation", animationDuration: "auto",
animationTimingFunction: "linear", animationFillMode: "both",
backgroundClip: "text",
}));
const root = node("n0", "body", computed(), [em]);
const css = generateCss(irWith(root), new Map());
const rule = baseRule(css, "n1");
assert.ok(!rule.includes("animation-name"), `scroll-timeline node must not emit animation-name, got: ${rule}`);
assert.ok(!rule.includes("animation-duration"), `scroll-timeline node must not emit animation-duration, got: ${rule}`);
});
it("still emits animation-* for a normal time-based (finite duration) animation", () => {
const el = node("n1", "div", computed({
animationName: "fadeInUp", animationDuration: "1.25s",
animationTimingFunction: "ease", animationFillMode: "both",
}));
const root = node("n0", "body", computed(), [el]);
const css = generateCss(irWith(root), new Map());
const rule = baseRule(css, "n1");
assert.ok(rule.includes("animation-name:fadeInUp"), `time-based reveal keeps its animation, got: ${rule}`);
});
});
// ---------------------------------------------------------------------------
// Per-viewport node builder (independent bbox / computed / sizing per width) plus a matching
// 3-viewport IR wrapper — the fluid/centring/wrap detectors need ≥2 varying widths to run.
const XVPS = [375, 768, 1280];
type XPerVp = { cs?: StyleMap; bbox: BBox; sizing?: RawSizing; visible?: boolean };
function xNode(id: string, tag: string, byVp: Record<number, XPerVp>, children: IRChild[] = []): IRNode {
const computedByVp: Record<number, StyleMap> = {};
const bboxByVp: Record<number, BBox> = {};
const visibleByVp: Record<number, boolean> = {};
const sizingByVp: Record<number, RawSizing> = {};
for (const vp of XVPS) {
const s = byVp[vp]!;
computedByVp[vp] = computed(s.cs);
bboxByVp[vp] = s.bbox;
visibleByVp[vp] = s.visible ?? true;
if (s.sizing) sizingByVp[vp] = s.sizing;
}
const n: IRNode = { id, tag, attrs: {}, visibleByVp, bboxByVp, computedByVp, children };
if (Object.keys(sizingByVp).length) n.sizingByVp = sizingByVp;
return n;
}
function xIr(root: IRNode): IR {
const ir = irWith(root);
ir.doc.viewports = XVPS;
ir.doc.sampleViewports = XVPS;
ir.doc.perViewport = Object.fromEntries(XVPS.map((vp) => [vp, { scrollHeight: 800, scrollWidth: vp, htmlBg: "rgb(255, 255, 255)", bodyBg: "rgb(255, 255, 255)", bodyColor: "rgb(0, 0, 0)", bodyFont: "Arial" }]));
return ir;
}
/** Every `.c<id>{…}` body (base + banded) concatenated, for asserting a value appears at some vp. */
function allRulesX(css: string, id: string): string {
const re = new RegExp(`\\.c${id}\\{([^}]*)\\}`, "g");
let out = "", m: RegExpExecArray | null;
while ((m = re.exec(css))) out += m[1] + ";";
return out;
}
/** The `.c<id>{…}` body inside the first @media block whose query matches `mediaRe`. */
function xBandRule(css: string, mediaRe: RegExp, id: string): string {
for (const m of css.matchAll(/@media ([^{]+) \{\n([\s\S]*?)\n\}/g)) {
if (!mediaRe.test(m[1]!)) continue;
const r = m[2]!.match(new RegExp(`\\.c${id}\\{([^}]*)\\}`));
if (r) return r[1]!;
}
return "";
}
// BUG A — a padded pill/section with LITERAL equal side margins (a fraction of the viewport, varying
// across widths) must keep those px margins per band. The width-fill sizing probe is what tells the
// centring detector these are load-bearing spacing, not margin-auto centring slack: on a box the
// probe reads as a container-fill (width:100% reproduces it), `margin:auto` resolves to 0 and would
// blow the box out to full-bleed, deleting the real margins.
describe("generateCss literal-margin vs auto-centring", () => {
const fill = (): RawSizing => ({ wAuto: false, wFill: true, hAuto: true, hFill: true });
// A flex parent spanning the whole viewport, holding one padded pill child that fills the space
// BETWEEN its literal side margins (box + 2×margin == container at every width).
function pill() {
const child = xNode("n1", "div", {
375: { cs: { display: "flex", marginLeft: "15px", marginRight: "15px", width: "345px" }, bbox: { x: 15, y: 0, width: 345, height: 62 }, sizing: fill() },
768: { cs: { display: "flex", marginLeft: "30.7188px", marginRight: "30.7188px", width: "706.562px" }, bbox: { x: 30.72, y: 0, width: 706.56, height: 62 }, sizing: fill() },
1280: { cs: { display: "flex", marginLeft: "25.5938px", marginRight: "25.5938px", width: "1228.81px" }, bbox: { x: 25.59, y: 0, width: 1228.81, height: 62 }, sizing: fill() },
});
const parent = xNode("n0", "body", {
375: { cs: { display: "flex" }, bbox: { x: 0, y: 0, width: 375, height: 62 } },
768: { cs: { display: "flex" }, bbox: { x: 0, y: 0, width: 768, height: 62 } },
1280: { cs: { display: "flex" }, bbox: { x: 0, y: 0, width: 1280, height: 62 } },
}, [child]);
return parent;
}
it("keeps the literal px side margins on a width-filling pill (no mx-auto)", () => {
const css = generateCss(xIr(pill()), new Map());
const all = allRulesX(css, "n1");
assert.ok(!/margin-left:auto/.test(all), `filling pill must not be centred with auto margins, got: ${all}`);
assert.ok(baseRule(css, "n1").includes("margin-left:25.5938px"), `base keeps the 1280 literal margin, got: ${baseRule(css, "n1")}`);
// The narrowest band (a `max-width` query with no `min-width`) carries the 375-vp literal margin.
const mobile = xBandRule(css, /^\(max-width/, "n1");
assert.ok(/margin-left:15px/.test(mobile), `mobile band keeps its literal 15px margin, got: ${mobile}`);
});
it("still emits margin:auto for a genuinely centred, width-CONSTRAINED block", () => {
// Content-sized (not a fill): the probe says width:auto re-derives it, width narrower than the
// container with symmetric slack that varies with width — real margin-auto centring.
const auto = (): RawSizing => ({ wAuto: true, wFill: false, hAuto: true, hFill: false });
const child = xNode("n1", "div", {
375: { cs: { display: "block", marginLeft: "15px", marginRight: "15px", width: "345px" }, bbox: { x: 15, y: 0, width: 345, height: 40 }, sizing: auto() },
768: { cs: { display: "block", marginLeft: "84px", marginRight: "84px", width: "600px" }, bbox: { x: 84, y: 0, width: 600, height: 40 }, sizing: auto() },
1280: { cs: { display: "block", marginLeft: "340px", marginRight: "340px", width: "600px" }, bbox: { x: 340, y: 0, width: 600, height: 40 }, sizing: auto() },
});
const parent = xNode("n0", "body", {
375: { cs: { display: "block" }, bbox: { x: 0, y: 0, width: 375, height: 40 } },
768: { cs: { display: "block" }, bbox: { x: 0, y: 0, width: 768, height: 40 } },
1280: { cs: { display: "block" }, bbox: { x: 0, y: 0, width: 1280, height: 40 } },
}, [child]);
const css = generateCss(xIr(parent), new Map());
const all = allRulesX(css, "n1");
assert.ok(/margin-left:auto/.test(all), `a constrained centred block should still auto-centre, got: ${all}`);
});
// A centred `max-width` container whose cap is FLUID — `max-width: min(1272px, 100vw 2·gutter)`
// resolves to a DIFFERENT px at every width (311/673/1145). The block is centred at every sample
// (symmetric positive gaps that scale with width). It must auto-centre (`mx-auto`) with per-band
// caps, NOT freeze to literal per-band `margin-left` (which pins it left at non-captured widths).
it("auto-centres a container with a per-viewport (fluid) max-width cap", () => {
// border-box block, max-width per vp, box width == cap, symmetric gaps everywhere.
const child = xNode("n1", "div", {
375: { cs: { display: "block", boxSizing: "border-box", maxWidth: "311px", marginLeft: "0px", marginRight: "0px", width: "311px" }, bbox: { x: 32, y: 0, width: 311, height: 200 } },
768: { cs: { display: "block", boxSizing: "border-box", maxWidth: "673.2px", marginLeft: "0px", marginRight: "0px", width: "673.2px" }, bbox: { x: 47.4, y: 0, width: 673.2, height: 200 } },
1280: { cs: { display: "block", boxSizing: "border-box", maxWidth: "1145.08px", marginLeft: "0px", marginRight: "0px", width: "1145.08px" }, bbox: { x: 67.46, y: 0, width: 1145.08, height: 200 } },
});
const parent = xNode("n0", "body", {
375: { cs: { display: "block" }, bbox: { x: 0, y: 0, width: 375, height: 200 } },
768: { cs: { display: "block" }, bbox: { x: 0, y: 0, width: 768, height: 200 } },
1280: { cs: { display: "block" }, bbox: { x: 0, y: 0, width: 1280, height: 200 } },
}, [child]);
const css = generateCss(xIr(parent), new Map());
const all = allRulesX(css, "n1");
assert.ok(/margin-left:auto/.test(all), `fluid-cap centred container must auto-centre, got: ${all}`);
assert.ok(!/margin-left:0?67|margin-left:32px|margin-left:47/.test(all), `must NOT bake literal per-band left margins, got: ${all}`);
// The per-viewport caps survive as banded max-width (the canonical 1145.08 at base, others banded).
assert.ok(baseRule(css, "n1").includes("max-width:1145.08px"), `base carries the canonical cap, got: ${baseRule(css, "n1")}`);
const mobile = xBandRule(css, /^\(max-width/, "n1");
assert.ok(/max-width:311px/.test(mobile), `mobile band carries its own fluid cap, got: ${mobile}`);
});
// Same centred `width:100%; max-width:min(Wpx, …); margin:0 auto` container, but it is a FLEX ITEM
// (its parent is display:flex). planWidth case (a) and centeredAtVp both bail on flex/grid items, so
// without isCappedCenteredContainer it would freeze its captured per-band side margins (32/47/67px)
// and left-align at every non-captured width. margin:auto centres a flex item correctly, and the
// retained per-band max-width cap keeps it safe (auto only absorbs the centring slack).
it("auto-centres a fluid-max-width container that is a FLEX ITEM (parent is flex)", () => {
const child = xNode("n1", "div", {
375: { cs: { display: "block", boxSizing: "border-box", maxWidth: "311px", marginLeft: "32px", marginRight: "32px", width: "311px" }, bbox: { x: 32, y: 0, width: 311, height: 200 } },
768: { cs: { display: "block", boxSizing: "border-box", maxWidth: "673.2px", marginLeft: "47.4px", marginRight: "47.4px", width: "673.2px" }, bbox: { x: 47.4, y: 0, width: 673.2, height: 200 } },
1280: { cs: { display: "block", boxSizing: "border-box", maxWidth: "1145.08px", marginLeft: "67.46px", marginRight: "67.46px", width: "1145.08px" }, bbox: { x: 67.46, y: 0, width: 1145.08, height: 200 } },
});
const parent = xNode("n0", "body", {
375: { cs: { display: "flex", flexDirection: "column" }, bbox: { x: 0, y: 0, width: 375, height: 200 } },
768: { cs: { display: "flex", flexDirection: "column" }, bbox: { x: 0, y: 0, width: 768, height: 200 } },
1280: { cs: { display: "flex", flexDirection: "column" }, bbox: { x: 0, y: 0, width: 1280, height: 200 } },
}, [child]);
const css = generateCss(xIr(parent), new Map());
const all = allRulesX(css, "n1");
assert.ok(/margin-left:auto/.test(all), `flex-item fluid-cap centred container must auto-centre, got: ${all}`);
assert.ok(!/margin-left:32px|margin-left:47|margin-left:0?67/.test(all), `must NOT bake literal per-band left margins, got: ${all}`);
assert.ok(baseRule(css, "n1").includes("max-width:1145.08px"), `base carries the canonical cap, got: ${baseRule(css, "n1")}`);
});
});
// BUG C — a single-line text leaf whose unwrapped width nearly fills its column at every width gets
// `white-space:nowrap`, so a sub-pixel column shortfall in the clone can't wrap it to a second line.
describe("generateCss wrap-vulnerable single-line text", () => {
// Text bbox exactly fills the column (wMax == avail == bbox.width) at every width, single line
// (height == line-height), and is genuinely wrappable (wMin < wMax).
function edgeText(wMin: number) {
const szAt = (w: number): RawSizing => ({ wAuto: true, wFill: false, hAuto: true, hFill: false, wMin, wMax: w });
const leaf = xNode("n1", "div", {
375: { cs: { display: "block", lineHeight: "24px" }, bbox: { x: 0, y: 0, width: 107.81, height: 24 }, sizing: szAt(107.81) },
768: { cs: { display: "block", lineHeight: "24px" }, bbox: { x: 0, y: 0, width: 107.81, height: 24 }, sizing: szAt(107.81) },
1280: { cs: { display: "block", lineHeight: "20px" }, bbox: { x: 0, y: 0, width: 107.81, height: 20 }, sizing: szAt(107.81) },
}, [{ text: "CEO — Academy" }]);
const parent = xNode("n0", "body", {
375: { cs: { display: "block" }, bbox: { x: 0, y: 0, width: 107.81, height: 24 } },
768: { cs: { display: "block" }, bbox: { x: 0, y: 0, width: 107.81, height: 24 } },
1280: { cs: { display: "block" }, bbox: { x: 0, y: 0, width: 107.81, height: 20 } },
}, [leaf]);
return parent;
}
it("emits white-space:nowrap for text flush against its column edge", () => {
const css = generateCss(xIr(edgeText(58.33)), new Map());
assert.ok(baseRule(css, "n1").includes("white-space:nowrap"), `edge-flush single-line text should get nowrap, got: ${baseRule(css, "n1")}`);
});
it("does NOT emit nowrap for a single unbreakable token (wMin == wMax — can't wrap)", () => {
const css = generateCss(xIr(edgeText(107.81)), new Map());
assert.ok(!baseRule(css, "n1").includes("white-space:nowrap"), `an unbreakable token needs no nowrap, got: ${baseRule(css, "n1")}`);
});
it("does NOT emit nowrap for a genuinely wrapping multi-line paragraph", () => {
// Two line boxes tall (height ≈ 2×line-height) → already wrapping, must stay wrappable.
const wMin = 100, wMax = 400;
const szAt = (): RawSizing => ({ wAuto: true, wFill: false, hAuto: true, hFill: false, wMin, wMax });
const para = xNode("n1", "p", {
375: { cs: { display: "block", lineHeight: "24px" }, bbox: { x: 0, y: 0, width: 345, height: 72 }, sizing: szAt() },
768: { cs: { display: "block", lineHeight: "24px" }, bbox: { x: 0, y: 0, width: 700, height: 48 }, sizing: szAt() },
1280: { cs: { display: "block", lineHeight: "24px" }, bbox: { x: 0, y: 0, width: 400, height: 48 }, sizing: szAt() },
}, [{ text: "A longer paragraph that wraps across multiple lines depending on the width." }]);
const parent = xNode("n0", "body", {
375: { cs: { display: "block" }, bbox: { x: 0, y: 0, width: 345, height: 72 } },
768: { cs: { display: "block" }, bbox: { x: 0, y: 0, width: 700, height: 48 } },
1280: { cs: { display: "block" }, bbox: { x: 0, y: 0, width: 400, height: 48 } },
}, [para]);
const css = generateCss(xIr(parent), new Map());
assert.ok(!allRulesX(css, "n1").includes("white-space:nowrap"), `a wrapping paragraph must not be forced nowrap, got: ${allRulesX(css, "n1")}`);
});
it("does NOT emit nowrap for text with comfortable slack in its container", () => {
const szAt = (): RawSizing => ({ wAuto: true, wFill: false, hAuto: true, hFill: false, wMin: 60, wMax: 90 });
const leaf = xNode("n1", "div", {
375: { cs: { display: "block", lineHeight: "20px" }, bbox: { x: 0, y: 0, width: 90, height: 20 }, sizing: szAt() },
768: { cs: { display: "block", lineHeight: "20px" }, bbox: { x: 0, y: 0, width: 90, height: 20 }, sizing: szAt() },
1280: { cs: { display: "block", lineHeight: "20px" }, bbox: { x: 0, y: 0, width: 90, height: 20 }, sizing: szAt() },
}, [{ text: "Nav link" }]);
// Wide container (300px+) — the 90px text has plenty of room, no wrap risk.
const parent = xNode("n0", "body", {
375: { cs: { display: "block" }, bbox: { x: 0, y: 0, width: 375, height: 20 } },
768: { cs: { display: "block" }, bbox: { x: 0, y: 0, width: 768, height: 20 } },
1280: { cs: { display: "block" }, bbox: { x: 0, y: 0, width: 1280, height: 20 } },
}, [leaf]);
const css = generateCss(xIr(parent), new Map());
assert.ok(!baseRule(css, "n1").includes("white-space:nowrap"), `slack text needs no nowrap, got: ${baseRule(css, "n1")}`);
});
});
// BUG C, icon+text chip variant — the label sits beside a replaced icon (<svg>), so the vulnerable
// node is NOT a pure text leaf and the leaf-only guard skipped it. The chip's button ancestor bakes
// a quantized px width that can land fractionally below the intrinsic single-line width, wrapping
// the label ("Recreate a / screenshot"). The inner [icon, text] row must still earn nowrap: the icon
// can't wrap, its constant width is already inside the probe's wMin/wMax, and only the text is at risk.
describe("generateCss wrap-vulnerable icon+text chip", () => {
// Captured shape: pill button 193.44×32 (px 12 + 1px border per side) → inner row 167.44×20
// (icon 15 + gap 8 + text), line-height 20, wMin 94.88 < wMax 167.44 == avail == bbox.width.
function chipRow(iconTag: string) {
const szAt = (): RawSizing => ({ wAuto: true, wFill: true, hAuto: true, hFill: true, wMin: 94.88, wMax: 167.44 });
const iconPer = (): XPerVp => ({ cs: { display: "inline-block" }, bbox: { x: 13, y: 8.5, width: 15, height: 15 } });
const icon = xNode("n2", iconTag, { 375: iconPer(), 768: iconPer(), 1280: iconPer() });
const rowPer = (): XPerVp => ({ cs: { display: "flex", lineHeight: "20px" }, bbox: { x: 13, y: 6, width: 167.44, height: 20 }, sizing: szAt() });
const row = xNode("n1", "div", { 375: rowPer(), 768: rowPer(), 1280: rowPer() }, [icon, { text: "Recreate a screenshot" }]);
const btnPer = (): XPerVp => ({
cs: { display: "flex", cursor: "pointer", paddingLeft: "12px", paddingRight: "12px", borderLeftWidth: "1px", borderRightWidth: "1px", borderTopLeftRadius: "9999px", borderTopRightRadius: "9999px", lineHeight: "20px" },
bbox: { x: 0, y: 0, width: 193.44, height: 32 },
});
const btn = xNode("n0", "button", { 375: btnPer(), 768: btnPer(), 1280: btnPer() }, [row]);
return btn;
}
it("emits white-space:nowrap for a single-line [icon, text] row flush against its pill's content edge", () => {
const css = generateCss(xIr(chipRow("svg")), new Map());
assert.ok(baseRule(css, "n1").includes("white-space:nowrap"), `icon+text chip row should get nowrap, got: ${baseRule(css, "n1")}`);
});
it("does NOT emit nowrap when the sibling is a non-replaced element (it may carry its own text)", () => {
const css = generateCss(xIr(chipRow("span")), new Map());
assert.ok(!baseRule(css, "n1").includes("white-space:nowrap"), `non-replaced sibling must keep the leaf-only bail, got: ${baseRule(css, "n1")}`);
});
});
// A pill <button> whose captured width is CONSTANT across viewports (the label never changes) looks
// authored-fixed to the button-width heuristic — but the sizing probe proved `width:auto` reproduces
// the box AND it paints exactly at its own max-content width at every sample: the width is content,
// not authored. Baking it invites the emitters' quantization (0.1px rounding / spacing-scale snap) to
// land fractionally below the intrinsic single-line width and wrap the label; the width must be
// dropped so the clone re-derives it intrinsically at every band.
describe("generateCss content-fit button width (probe-proven auto beats the constant-width lock)", () => {
function pillBody(sizing?: () => RawSizing) {
const per = (): XPerVp => ({
cs: { display: "flex", cursor: "pointer", width: "144.188px", borderTopLeftRadius: "9999px", borderTopRightRadius: "9999px", lineHeight: "20px" },
bbox: { x: 0, y: 0, width: 144.19, height: 32 },
...(sizing ? { sizing: sizing() } : {}),
});
const btn = xNode("n1", "button", { 375: per(), 768: per(), 1280: per() }, [{ text: "Clone LinkedIn" }]);
return xNode("n0", "body", {
375: { cs: { display: "flex" }, bbox: { x: 0, y: 0, width: 375, height: 32 } },
768: { cs: { display: "flex" }, bbox: { x: 0, y: 0, width: 768, height: 32 } },
1280: { cs: { display: "flex" }, bbox: { x: 0, y: 0, width: 1280, height: 32 } },
}, [btn]);
}
it("drops the width when the probe proves auto AND the button paints at its max-content everywhere", () => {
const sz = (): RawSizing => ({ wAuto: true, wFill: false, hAuto: true, hFill: false, wMin: 103.14, wMax: 144.19 });
const css = generateCss(xIr(pillBody(sz)), new Map());
assert.ok(!allRulesX(css, "n1").includes("width:144"), `content-fit pill width must be dropped, got: ${allRulesX(css, "n1")}`);
});
it("still bakes the width without probe evidence (no sizing data)", () => {
const css = generateCss(xIr(pillBody()), new Map());
assert.ok(allRulesX(css, "n1").includes("width:144"), `unproven button width must stay locked, got: ${allRulesX(css, "n1")}`);
});
it("still bakes the width when the probe proves it load-bearing (wAuto:false)", () => {
const sz = (): RawSizing => ({ wAuto: false, wFill: false, hAuto: true, hFill: false, wMin: 103.14, wMax: 144.19 });
const css = generateCss(xIr(pillBody(sz)), new Map());
assert.ok(allRulesX(css, "n1").includes("width:144"), `load-bearing button width must stay locked, got: ${allRulesX(css, "n1")}`);
});
});
// Cross-band transform identity — a node with a NON-identity transform at one band must emit the
// explicit identity `transform:none` at the bands where the source is untransformed, so the
// transform can't cascade across bands and freeze at a width the source left untransformed.
describe("generateCss cross-band transform identity", () => {
it("emits transform:none at a band where a node with a non-identity transform elsewhere is identity", () => {
const el = xNode("n1", "div", {
375: { cs: { transform: "none" }, bbox: { x: 0, y: 0, width: 375, height: 40 } },
768: { cs: { transform: "matrix(1, 0, 0, 1, 40, 0)" }, bbox: { x: 40, y: 0, width: 375, height: 40 } },
1280: { cs: { transform: "matrix(1, 0, 0, 1, 40, 0)" }, bbox: { x: 40, y: 0, width: 375, height: 40 } },
});
const root = xNode("n0", "body", { 375: { bbox: { x: 0, y: 0, width: 375, height: 40 } }, 768: { bbox: { x: 0, y: 0, width: 768, height: 40 } }, 1280: { bbox: { x: 0, y: 0, width: 1280, height: 40 } } }, [el]);
const css = generateCss(xIr(root), new Map());
const all = allRulesX(css, "n1");
assert.ok(/transform:matrix/.test(all), `the non-identity transform must be emitted, got: ${all}`);
assert.ok(/transform:none/.test(all), `the identity band must emit transform:none so it can't cascade, got: ${all}`);
});
it("does NOT emit transform:none for a node that is identity at every band", () => {
const el = xNode("n1", "div", {
375: { cs: { transform: "none" }, bbox: { x: 0, y: 0, width: 375, height: 40 } },
768: { cs: { transform: "none" }, bbox: { x: 0, y: 0, width: 375, height: 40 } },
1280: { cs: { transform: "none" }, bbox: { x: 0, y: 0, width: 375, height: 40 } },
});
const root = xNode("n0", "body", { 375: { bbox: { x: 0, y: 0, width: 375, height: 40 } }, 768: { bbox: { x: 0, y: 0, width: 768, height: 40 } }, 1280: { bbox: { x: 0, y: 0, width: 1280, height: 40 } } }, [el]);
const css = generateCss(xIr(root), new Map());
assert.ok(!allRulesX(css, "n1").includes("transform:none"), `an always-identity node needs no explicit transform, got: ${allRulesX(css, "n1")}`);
});
});
// BUG 1 — heightFlows must consult the sizing probe. A circular parent/child pair of authored-height
// boxes (two nested viewport-height containers whose only children are absolutely positioned) used to
// "explain each other away": the outer read content-driven (its content IS the inner box) and the
// inner read stretched (it equals the outer's content height) — both dropped, collapsing a hero to
// 0px. The probe now proves each height authored-explicit (hAuto:false, hFill:false); heightFlows
// bails so both survive.
describe("generateCss circular authored-height (probe bail)", () => {
const authored = (): RawSizing => ({ wAuto: false, wFill: false, hAuto: false, hFill: false });
function hero(hByVp: Record<number, number>) {
// an absolutely-positioned decoration inside the inner box (contributes no in-flow content height)
const abs = xNode("n3", "div", {
375: { cs: { position: "absolute" }, bbox: { x: 0, y: 0, width: 375, height: hByVp[375]! } },
768: { cs: { position: "absolute" }, bbox: { x: 0, y: 0, width: 768, height: hByVp[768]! } },
1280: { cs: { position: "absolute" }, bbox: { x: 0, y: 0, width: 1280, height: hByVp[1280]! } },
});
const inner = xNode("n2", "div", {
375: { cs: { display: "flex", height: `${hByVp[375]}px` }, bbox: { x: 0, y: 0, width: 375, height: hByVp[375]! }, sizing: authored() },
768: { cs: { display: "flex", height: `${hByVp[768]}px` }, bbox: { x: 0, y: 0, width: 768, height: hByVp[768]! }, sizing: authored() },
1280: { cs: { display: "flex", height: `${hByVp[1280]}px` }, bbox: { x: 0, y: 0, width: 1280, height: hByVp[1280]! }, sizing: authored() },
}, [abs]);
const outer = xNode("n1", "div", {
375: { cs: { display: "flex", height: `${hByVp[375]}px` }, bbox: { x: 0, y: 0, width: 375, height: hByVp[375]! }, sizing: authored() },
768: { cs: { display: "flex", height: `${hByVp[768]}px` }, bbox: { x: 0, y: 0, width: 768, height: hByVp[768]! }, sizing: authored() },
1280: { cs: { display: "flex", height: `${hByVp[1280]}px` }, bbox: { x: 0, y: 0, width: 1280, height: hByVp[1280]! }, sizing: authored() },
}, [inner]);
return xNode("n0", "body", {
375: { bbox: { x: 0, y: 0, width: 375, height: hByVp[375]! } },
768: { bbox: { x: 0, y: 0, width: 768, height: hByVp[768]! } },
1280: { bbox: { x: 0, y: 0, width: 1280, height: hByVp[1280]! } },
}, [outer]);
}
it("keeps both heights when the probe proves them authored-explicit", () => {
const css = generateCss(xIr(hero({ 375: 771, 768: 900, 1280: 800 })), new Map());
const outer = allRulesX(css, "n1");
const inner = allRulesX(css, "n2");
assert.ok(/height:800px/.test(outer), `outer must keep its canonical authored height, got: ${outer}`);
assert.ok(/height:800px/.test(inner), `inner must keep its canonical authored height, got: ${inner}`);
});
it("still flows a genuinely content-sized varying height (probe hAuto:true)", () => {
// Same shape but the probe says auto reproduces the box — the varying height is real content, safe
// to drop. Give it an in-flow text child so it reads content-driven.
const auto = (): RawSizing => ({ wAuto: true, wFill: false, hAuto: true, hFill: false });
const mk = (h: number, w: number): XPerVp => ({ cs: { display: "block", height: `${h}px` }, bbox: { x: 0, y: 0, width: w, height: h }, sizing: auto() });
const inner = xNode("n2", "div", { 375: mk(200, 375), 768: mk(260, 768), 1280: mk(320, 1280) }, [{ text: "flowing content" } as IRChild]);
// pad the box so height == content is plausible per contentDriven's text branch
const root = xNode("n0", "body", { 375: { bbox: { x: 0, y: 0, width: 375, height: 200 } }, 768: { bbox: { x: 0, y: 0, width: 768, height: 260 } }, 1280: { bbox: { x: 0, y: 0, width: 1280, height: 320 } } }, [inner]);
const css = generateCss(xIr(root), new Map());
assert.ok(!/height:320px/.test(allRulesX(css, "n2")), `a probe-auto content height should still flow, got: ${allRulesX(css, "n2")}`);
});
});
// BUG 2 — a space-distributing flex column (justify-content: space-between/around/evenly, or a packed
// center/flex-end) sets the free space its children spread through, so the last child reaching the box
// bottom is NOT content evidence. heightFlows used to read that as content-driven and drop the box's
// authored height, collapsing the distributed gaps.
describe("generateCss space-distributing flex column keeps its height", () => {
// hAuto:true (auto reproduces the box at capture, because distribution places the last child at the
// bottom) — so the sizing probe does NOT forbid the drop. Wrappable text inside makes heightProbeDrops
// abstain, isolating heightFlows' space-distribution disqualification as the sole arbiter.
const auto = (): RawSizing => ({ wAuto: true, wFill: false, hAuto: true, hFill: false });
const textChild = { text: "a line of distributed content" } as IRChild;
function distributed(justify: string) {
// two content-sized blocks; the last sits at the box bottom because the column distributes space
const top = xNode("n2", "div", {
375: { cs: { display: "block" }, bbox: { x: 0, y: 0, width: 375, height: 40 } },
768: { cs: { display: "block" }, bbox: { x: 0, y: 0, width: 768, height: 40 } },
1280: { cs: { display: "block" }, bbox: { x: 0, y: 0, width: 1280, height: 40 } },
}, [textChild]);
const bottomH = (h: number): XPerVp => ({ cs: { display: "block" }, bbox: { x: 0, y: h - 40, width: 375, height: 40 } });
const bot = xNode("n3", "div", { 375: bottomH(360), 768: bottomH(500), 1280: bottomH(600) }, [textChild]);
const col = xNode("n1", "div", {
375: { cs: { display: "flex", flexDirection: "column", justifyContent: justify, height: "360px" }, bbox: { x: 0, y: 0, width: 375, height: 360 }, sizing: auto() },
768: { cs: { display: "flex", flexDirection: "column", justifyContent: justify, height: "500px" }, bbox: { x: 0, y: 0, width: 768, height: 500 }, sizing: auto() },
1280: { cs: { display: "flex", flexDirection: "column", justifyContent: justify, height: "600px" }, bbox: { x: 0, y: 0, width: 1280, height: 600 }, sizing: auto() },
}, [top, bot]);
return xNode("n0", "body", { 375: { bbox: { x: 0, y: 0, width: 375, height: 360 } }, 768: { bbox: { x: 0, y: 0, width: 768, height: 500 } }, 1280: { bbox: { x: 0, y: 0, width: 1280, height: 600 } } }, [col]);
}
for (const j of ["space-between", "space-around", "space-evenly"]) {
it(`keeps the authored height under justify-content:${j}`, () => {
const css = generateCss(xIr(distributed(j)), new Map());
assert.ok(/height:600px/.test(allRulesX(css, "n1")), `${j} column must keep its distributing height, got: ${allRulesX(css, "n1")}`);
});
}
it("still flows a flex-start column whose height truly equals its content", () => {
// justify-content:flex-start (default) — the last child at the bottom IS content-driven, so the
// varying height flows (drops) as before.
const css = generateCss(xIr(distributed("flex-start")), new Map());
assert.ok(!/height:600px/.test(allRulesX(css, "n1")), `a packed-top column should still flow, got: ${allRulesX(css, "n1")}`);
});
});
// BUG 3 — a lottie mount pins its captured per-viewport height so the runtime player's aspect-sized
// svg fills a definite box instead of inflating past its neighbours. Threaded via collectNodeRules'
// lottieMounts set (populated from the lottie spec item cids).
describe("collectNodeRules lottie mount height pin", () => {
const auto = (): RawSizing => ({ wAuto: true, wFill: false, hAuto: true, hFill: false });
function mountFixture() {
// the mount is a flex box; its only child is a content-sized placeholder svg — without the pin the
// box height flows/collapses and the runtime svg inflates by aspect.
const svg = xNode("n2", "svg", {
375: { cs: { display: "block" }, bbox: { x: 0, y: 0, width: 375, height: 94 } },
768: { cs: { display: "block" }, bbox: { x: 0, y: 0, width: 768, height: 195 } },
1280: { cs: { display: "block" }, bbox: { x: 0, y: 0, width: 1280, height: 227 } },
});
const mount = xNode("n1", "div", {
375: { cs: { display: "flex", height: "94px" }, bbox: { x: 0, y: 0, width: 375, height: 94 }, sizing: auto() },
768: { cs: { display: "flex", height: "195px" }, bbox: { x: 0, y: 0, width: 768, height: 195 }, sizing: auto() },
1280: { cs: { display: "flex", height: "227px" }, bbox: { x: 0, y: 0, width: 1280, height: 227 }, sizing: auto() },
}, [svg]);
return xNode("n0", "body", { 375: { bbox: { x: 0, y: 0, width: 375, height: 94 } }, 768: { bbox: { x: 0, y: 0, width: 768, height: 195 } }, 1280: { bbox: { x: 0, y: 0, width: 1280, height: 227 } } }, [mount]);
}
it("pins the captured height on a mount node", () => {
const rules = collectNodeRules(xIr(mountFixture()), new Map(), undefined, undefined, undefined, false, new Set(["n1"]));
const nr = rules.get("n1");
assert.ok(nr, "mount rule must exist");
const all = [nr!.base.get("height"), ...nr!.bands.map((b) => b.decls.get("height"))].filter(Boolean).join(";");
assert.ok(/227px/.test(all), `canonical mount height must be pinned, got: ${all}`);
assert.ok(/94px/.test(all), `narrow-band mount height must be pinned, got: ${all}`);
});
it("leaves a non-mount box unpinned (flows its varying content height)", () => {
const rules = collectNodeRules(xIr(mountFixture()), new Map(), undefined, undefined, undefined, false, new Set());
const nr = rules.get("n1")!;
const all = [nr.base.get("height"), ...nr.bands.map((b) => b.decls.get("height"))].filter(Boolean).join(";");
assert.ok(!/227px/.test(all), `without the mount flag the varying height should flow, got: ${all}`);
});
});
// The reset ships a rule that forces the lottie runtime svg/canvas to fit its overlay box. The
// player re-mounts its media into an absolute overlay that fills the host's pinned (per-viewport)
// height; without this rule an aspect-mismatched viewBox (a portrait animation in a shorter,
// letterboxed source box) inflates past that height. Scoped to the runtime-marked host only, so it
// is inert when a page has no lottie.
describe("RESET_CSS lottie runtime-fit rule", () => {
it("constrains the runtime svg/canvas to the overlay box, scoped to the marked host", () => {
assert.match(RESET_CSS, /\[data-ditto-lottie\]\s*>\s*div\s*>\s*svg/);
assert.match(RESET_CSS, /\[data-ditto-lottie\]\s*>\s*div\s*>\s*canvas/);
// the rule must pin both dimensions so height:100% resolves against the definite overlay
const m = RESET_CSS.match(/\[data-ditto-lottie\][^{]*\{([^}]*)\}/);
assert.ok(m, "the data-ditto-lottie rule must be present");
assert.match(m![1], /width:\s*100%/);
assert.match(m![1], /height:\s*100%/);
});
it("does not affect the pre-swap placeholder (a direct-child svg, not nested under a div)", () => {
// The selector targets `> div > svg` (the runtime overlay), never a `> svg` placeholder, so the
// captured placeholder keeps its own pinned classes and pre/post-swap geometry stays identical.
assert.ok(
!/\[data-ditto-lottie\]\s*>\s*svg\b/.test(RESET_CSS),
"the rule must not target a direct-child placeholder svg",
);
});
});
// Defect E — a content-sized flex ROW's items are dropped to `width:auto` only when NO shrink fired
// (positive slack ⇒ items at content size). But a WRAPPING text leaf paints at its balanced-wrap
// width, which is BELOW its max-content: `width:auto` on the block child fills the line to
// max-content and left-aligns it, so the narrow right-pushed paragraph blows out to full-width. The
// sizing probe already read `wAuto:false` (auto does NOT reproduce the width); it must veto the
// whole line (the rule is all-or-nothing — dropping a subset shifts siblings via justify-content).
describe("generateCss content-sized flex row (probe veto for wrapping text)", () => {
// One <p> child, sole item of a `flex; justify-content:flex-end` row, `flex:0 1 auto`, that paints
// narrower than the container at every width (wraps below max-content) with positive slack.
function missionRow(pSizing: RawSizing) {
const p = xNode("n1", "p", {
375: { cs: { display: "block", flexGrow: "0", flexShrink: "1", flexBasis: "auto", width: "276px" }, bbox: { x: 69, y: 0, width: 276, height: 80 }, sizing: pSizing },
768: { cs: { display: "block", flexGrow: "0", flexShrink: "1", flexBasis: "auto", width: "529.922px" }, bbox: { x: 176, y: 0, width: 529.92, height: 60 }, sizing: pSizing },
1280: { cs: { display: "block", flexGrow: "0", flexShrink: "1", flexBasis: "auto", width: "774.141px" }, bbox: { x: 455, y: 0, width: 774.14, height: 40 }, sizing: pSizing },
}, [{ text: "Our UX staffing and recruiting teams place the best talent from around the world." }]);
return xNode("n0", "body", {
375: { cs: { display: "flex", flexDirection: "row", justifyContent: "flex-end" }, bbox: { x: 0, y: 0, width: 375, height: 80 } },
768: { cs: { display: "flex", flexDirection: "row", justifyContent: "flex-end" }, bbox: { x: 0, y: 0, width: 706.56, height: 60 } },
1280: { cs: { display: "flex", flexDirection: "row", justifyContent: "flex-end" }, bbox: { x: 0, y: 0, width: 1228.81, height: 40 } },
}, [p]);
}
it("keeps a width on a wrapping paragraph the probe proved is not auto-reproducible", () => {
// Probe ground truth: width:auto does NOT reproduce (wAuto:false) — the painted width is the
// balanced-wrap width, below max-content (wMax).
const notAuto = (): RawSizing => ({ wAuto: false, wFill: false, hAuto: true, hFill: false, wMin: 95.88, wMax: 706.56 });
const css = generateCss(xIr(missionRow(notAuto())), new Map());
const all = allRulesX(css, "n1");
// It must NOT be emitted as content-auto (which drops width entirely and fills the line). The
// width plan falls to a real width — a fluid percentage or baked px — so the narrow column survives.
assert.ok(/width:/.test(all), `wrapping paragraph must keep a width, not collapse to auto, got: ${all}`);
assert.ok(!/width:auto/.test(all), `probe said wAuto:false — must not emit width:auto, got: ${all}`);
});
it("still drops width:auto on a genuinely content-sized row item the probe confirms", () => {
// Probe agrees width:auto reproduces (wAuto:true) — a toolbar/menu item whose width is its
// content. The content-sized-flex-row law should still fire and emit auto (no baked px band).
const isAuto = (): RawSizing => ({ wAuto: true, wFill: false, hAuto: true, hFill: false, wMin: 95.88, wMax: 706.56 });
const css = generateCss(xIr(missionRow(isAuto())), new Map());
const all = allRulesX(css, "n1");
assert.ok(!/width:\d/.test(all), `content-sized item must drop its baked px width, got: ${all}`);
});
});
describe("generateCss fluid percent snap-up for content-fit nowrap pill (task #26)", () => {
// A nowrap flex pill, sole child of a flex COLUMN, that paints at its own max-content width
// (bbox.width == wMax) at every viewport — a load-bearing % width that, rounded to the nearest
// 0.5%, lands BELOW the intrinsic content and wraps to a second line. Real evidence: a footer
// pill 245.94px in a 343px column at 375 (71.70% → 71.5% → 245.24px < 245.94 → wrap).
// Numbers below are the captured run's (375 / 768 / 1280).
// Per-vp pill widths mirror the real run: at 768 the column is narrower than the pill's content, so
// the pill shrinks to fill it (221.33 == container); at 375/1280 it sits at its max-content (245.94).
// A CONSTANT max-content across vps keeps contentSizedColumnItem from firing (its widths don't vary),
// so the width falls through to the fluid-percent (percentVp) path — exactly the real code path.
const wMax = 245.94;
function pillIn(colWidths: [number, number, number], wMinOrWMax: { wMin: number; wMax: number }) {
const pillW: Record<number, number> = { 375: 245.94, 768: 221.33, 1280: 245.94 };
const sz = (): RawSizing => ({ wAuto: false, wFill: false, hAuto: true, hFill: true, ...wMinOrWMax });
const pill = xNode("n1", "a", {
375: { cs: { display: "flex", whiteSpace: "nowrap" }, bbox: { x: 0, y: 0, width: pillW[375]!, height: 40 }, sizing: sz() },
768: { cs: { display: "flex", whiteSpace: "nowrap" }, bbox: { x: 0, y: 0, width: pillW[768]!, height: 40 }, sizing: sz() },
1280: { cs: { display: "flex", whiteSpace: "nowrap" }, bbox: { x: 0, y: 0, width: pillW[1280]!, height: 40 }, sizing: sz() },
}, [{ text: "Certified B Corporation" }]);
return xNode("n0", "body", {
375: { cs: { display: "flex", flexDirection: "column" }, bbox: { x: 0, y: 0, width: colWidths[0], height: 40 } },
768: { cs: { display: "flex", flexDirection: "column" }, bbox: { x: 0, y: 0, width: colWidths[1], height: 40 } },
1280: { cs: { display: "flex", flexDirection: "column" }, bbox: { x: 0, y: 0, width: colWidths[2], height: 40 } },
}, [pill]);
}
/** Collect every emitted `width:N%` for `id` across the base rule and all @media bands. */
function emittedPercents(css: string, id: string) {
const bodies: string[] = [];
// base rule
const base = css.match(new RegExp(`\\.c${id}\\{([^}]*)\\}`));
if (base) bodies.push(base[1]!);
// banded rules
for (const m of css.matchAll(/@media ([^{]+) \{\n([\s\S]*?)\n\}/g)) {
const r = m[2]!.match(new RegExp(`\\.c${id}\\{([^}]*)\\}`));
if (r) bodies.push(r[1]!);
}
const pcts: number[] = [];
for (const b of bodies) {
const w = b.match(/width:([\d.]+)%/);
if (w) pcts.push(parseFloat(w[1]!));
}
return { pcts, bodies };
}
it("snaps the percent UP so the emitted width never lands below the pill's content (no wrap)", () => {
// 245.94 in 343 → 71.70% (nearest 71.5% = 245.24 < wMax); in 392 → 62.74% (nearest 62.5% = 245.0 < wMax).
const css = generateCss(xIr(pillIn([343, 221.33, 392], { wMin: 189.72, wMax })), new Map());
const { pcts } = emittedPercents(css, "n1");
assert.ok(pcts.length > 0, `pill should carry a fluid width:%, got none`);
// Every emitted % must clear the content width against the vp where it applies. The two content-
// fit viewports are 343 (71.70% band) and 392 (62.74% band); resolve each candidate % and require
// at least the 343-container % ≥ 245.94.
const at343 = pcts.filter((p) => Math.abs((p / 100) * 343 - 245.94) <= 3);
assert.ok(at343.some((p) => (p / 100) * 343 >= 245.94 - 0.01),
`375 pill % must resolve ≥ 245.94px in a 343px column, got pcts=${pcts.join(",")}${at343.map((p) => ((p / 100) * 343).toFixed(2)).join(",")}px`);
// Nearest-rounding would have emitted 71.5% (= 245.24). Assert the fix bumped it up to 72%.
assert.ok(pcts.includes(72) || pcts.some((p) => (p / 100) * 343 >= 245.94),
`expected snap-UP to 72% (246.96px), got ${pcts.join(",")}`);
});
it("keeps round-to-NEAREST for an ordinary box comfortably inside its container (no churn)", () => {
// Same geometry but NOT content-fit: bbox 245.94 sits well below wMax (400), so there is slack —
// wrapping can't happen, and the default nearest rounding (71.5%) must be preserved.
const rootPill = pillIn([343, 221.33, 392], { wMin: 100, wMax: 400 });
const css2 = generateCss(xIr(rootPill), new Map());
const { pcts } = emittedPercents(css2, "n1");
// 71.70% and 62.74% both round to nearest (71.5%, 63.0%... actually 62.74→62.5) — assert 71.5 present.
assert.ok(pcts.includes(71.5), `an ordinary box must keep nearest rounding (71.5%), got ${pcts.join(",")}`);
assert.ok(!pcts.includes(72), `an ordinary box must NOT be bumped up to 72%, got ${pcts.join(",")}`);
});
});
// Defect C (generate side) — a CSS marquee (infinite `@keyframes` animation) gated to a breakpoint
// (Webflow's `max-lg` logo/text tracks) is `animation:none` at the base but runs at the narrow band.
// The per-band transform delta at the animated width is a FROZEN mid-scroll phase; it must be
// suppressed so the base holds and the runtime keyframes drive the transform (from translateX(0)).
// (The upstream normalize pass zeroes the base residue — see neutralizeAnimatedTransforms; here the
// base is already `none`, and this test covers the band-delta suppression.)
describe("generateCss breakpoint-gated marquee transform band suppression (Defect C)", () => {
function logoStrip() {
const strip = xNode("n1", "div", {
375: { cs: { display: "grid", animationName: "track", animationIterationCount: "infinite", animationDuration: "35s", transform: "matrix(1, 0, 0, 1, -284.025, 0)" }, bbox: { x: 0, y: 0, width: 345, height: 100 } },
768: { cs: { display: "grid", animationName: "track", animationIterationCount: "infinite", animationDuration: "35s", transform: "matrix(1, 0, 0, 1, -280.982, 0)" }, bbox: { x: 0, y: 0, width: 706, height: 100 } },
1280: { cs: { display: "grid", animationName: "none", animationIterationCount: "1", transform: "none" }, bbox: { x: 0, y: 0, width: 1228, height: 145 } },
});
return xNode("n0", "body", { 375: { bbox: { x: 0, y: 0, width: 375, height: 100 } }, 768: { bbox: { x: 0, y: 0, width: 768, height: 100 } }, 1280: { bbox: { x: 0, y: 0, width: 1280, height: 145 } } }, [strip]);
}
it("does not bake the frozen mid-scroll translateX at the animated band", () => {
const css = generateCss(xIr(logoStrip()), new Map());
const all = allRulesX(css, "n1");
assert.ok(!/matrix\(1, ?0, ?0, ?1, ?-\d/.test(all), `frozen marquee translateX must not be emitted at any band, got: ${all}`);
});
it("keeps a per-band transform of a NON-animated element", () => {
// A non-animated element whose transform genuinely varies per band keeps it (no animation owns it).
const el = xNode("n1", "div", {
375: { cs: { transform: "matrix(1, 0, 0, 1, -40, 0)" }, bbox: { x: -40, y: 0, width: 345, height: 100 } },
768: { cs: { transform: "none" }, bbox: { x: 0, y: 0, width: 706, height: 100 } },
1280: { cs: { transform: "none" }, bbox: { x: 0, y: 0, width: 1228, height: 100 } },
});
const root = xNode("n0", "body", { 375: { bbox: { x: 0, y: 0, width: 375, height: 100 } }, 768: { bbox: { x: 0, y: 0, width: 768, height: 100 } }, 1280: { bbox: { x: 0, y: 0, width: 1280, height: 100 } } }, [el]);
const css = generateCss(xIr(root), new Map());
assert.ok(/matrix\(1, ?0, ?0, ?1, ?-40/.test(allRulesX(css, "n1")), `a non-animated per-band offset must be kept, got: ${allRulesX(css, "n1")}`);
});
});
// text-wrap emission: a heading authored `text-wrap:balance` must survive to the clone, or the
// title wraps differently. The initial `wrap` is elided (it's the default), so only authored
// balance/pretty/nowrap ship.
describe("generateCss text-wrap", () => {
it("emits an authored text-wrap:balance on a heading", () => {
const h1 = node("n1", "h1", computed({ textWrap: "balance" }));
const root = node("n0", "body", computed(), [h1]);
const css = generateCss(irWith(root), new Map());
assert.ok(/text-wrap:balance/.test(baseRule(css, "n1")), `text-wrap:balance emitted (got: ${baseRule(css, "n1")})`);
});
it("emits text-wrap:pretty", () => {
const p = node("n1", "p", computed({ textWrap: "pretty" }));
const root = node("n0", "body", computed(), [p]);
const css = generateCss(irWith(root), new Map());
assert.ok(/text-wrap:pretty/.test(baseRule(css, "n1")), `text-wrap:pretty emitted (got: ${baseRule(css, "n1")})`);
});
it("elides the default text-wrap:wrap (no noise)", () => {
const h1 = node("n1", "h1", computed({ textWrap: "wrap" }));
const root = node("n0", "body", computed(), [h1]);
const css = generateCss(irWith(root), new Map());
assert.ok(!/text-wrap/.test(baseRule(css, "n1")), `default text-wrap:wrap must not be emitted (got: ${baseRule(css, "n1")})`);
});
it("skips text-wrap on a child when it equals the parent (inherited)", () => {
// text-wrap is inherited: a child matching the parent's balance relies on inheritance.
const child = node("n2", "span", computed({ textWrap: "balance" }));
const h1 = node("n1", "h1", computed({ textWrap: "balance" }), [child]);
const root = node("n0", "body", computed(), [h1]);
const css = generateCss(irWith(root), new Map());
assert.ok(/text-wrap:balance/.test(baseRule(css, "n1")), "parent heading emits balance");
assert.ok(!/text-wrap/.test(baseRule(css, "n2")), `inherited child does not re-emit (got: ${baseRule(css, "n2")})`);
});
});
// FIX 4 — a library-sized carousel slide (a shrink-0 flex item with an injected inline `width:Npx`)
// whose only in-flow child is a display:block box. A block box's `width:auto` IS its fill width, so
// the sizing probe reports the child wAuto AND wFill both true. The circular-slide detector must count
// that block-level fill child as width-DERIVING (it never establishes a width) and pin the slide's
// captured px — otherwise the guard misreads the child as a genuine content-width source, bails, and
// the slide content-sizes to a different width (ragged carousel rails).
describe("generateCss circular carousel slide with a block-level fill child (FIX 4)", () => {
// The slide's child is display:block: its auto width equals the fill width, so BOTH probe bits fire.
const blockFill = (): RawSizing => ({ wAuto: true, wFill: true, hAuto: true, hFill: true });
function slideRow() {
// Uniform 220px slides at every viewport (Splide's inline width) inside a flex track.
const mkSlide = (id: string, childId: string): IRNode => {
const child = xNode(childId, "div", {
375: { cs: { display: "block", position: "static" }, bbox: { x: 0, y: 0, width: 220, height: 357 }, sizing: blockFill() },
768: { cs: { display: "block", position: "static" }, bbox: { x: 0, y: 0, width: 220, height: 357 }, sizing: blockFill() },
1280: { cs: { display: "block", position: "static" }, bbox: { x: 0, y: 0, width: 220, height: 357 }, sizing: blockFill() },
}, [{ text: "Men's Tops & Shirts" } as IRChild]);
const slideCs = { display: "block", position: "static", flexShrink: "0", width: "220px" };
return xNode(id, "div", {
375: { cs: slideCs, bbox: { x: 0, y: 0, width: 220, height: 357 } },
768: { cs: slideCs, bbox: { x: 0, y: 0, width: 220, height: 357 } },
1280: { cs: slideCs, bbox: { x: 0, y: 0, width: 220, height: 357 } },
}, [child]);
};
const trackCs = { display: "flex", position: "static" };
const track = xNode("n1", "ul", {
375: { cs: trackCs, bbox: { x: 0, y: 0, width: 375, height: 357 } },
768: { cs: trackCs, bbox: { x: 0, y: 0, width: 768, height: 357 } },
1280: { cs: trackCs, bbox: { x: 0, y: 0, width: 1280, height: 357 } },
}, [mkSlide("n2", "n3"), mkSlide("n4", "n5")]);
return xNode("n0", "body", {
375: { bbox: { x: 0, y: 0, width: 375, height: 357 } },
768: { bbox: { x: 0, y: 0, width: 768, height: 357 } },
1280: { bbox: { x: 0, y: 0, width: 1280, height: 357 } },
}, [track]);
}
it("pins the slide's captured width even though its block child reports wAuto:true", () => {
const css = generateCss(xIr(slideRow()), new Map());
const slide = allRulesX(css, "n2");
assert.ok(/width:220px/.test(slide), `the library-sized slide must keep its 220px width, got: ${slide}`);
});
});
// FIX 4b — the circular-slide detector must NOT misfire on a genuine fluid full-bleed section. A
// `shrink-0` flex/grid item whose box spans the FULL viewport at ≥2 distinct captured widths (x≈0,
// width≈vp) with zero horizontal margins provably HAS a width source (it fills a fluid container /
// was authored width:100%) — there is no circular collapse, so it must emit a fluid width, not freeze
// to per-breakpoint captured px (w-320/w-192/w-480). Same "proven fluid" bar (≥2 distinct widths).
describe("generateCss circular-slide guard skips fluid full-bleed section (FIX 4b)", () => {
const blockFill = (): RawSizing => ({ wAuto: true, wFill: true, hAuto: true, hFill: true });
function fullBleedSection() {
// A shrink-0 flex item whose bbox tracks the viewport (375/768/1280) with zero horizontal margins:
// a fluid full-bleed section, NOT a fixed-width carousel slide. Its block child fills it.
const child = xNode("n2", "div", {
375: { cs: { display: "block", position: "static" }, bbox: { x: 0, y: 0, width: 375, height: 80 }, sizing: blockFill() },
768: { cs: { display: "block", position: "static" }, bbox: { x: 0, y: 0, width: 768, height: 80 }, sizing: blockFill() },
1280: { cs: { display: "block", position: "static" }, bbox: { x: 0, y: 0, width: 1280, height: 80 }, sizing: blockFill() },
}, [{ text: "Full bleed" } as IRChild]);
// flex-shrink:0 item; its captured px equals the viewport at each width (what the library/probe saw).
// boxSizing:content-box keeps isFillsContainerWidth/isFullWidthShrinkSlide (border-box only) off, so
// WITHOUT the guard the circular-slide detector wins and freezes these per-breakpoint px.
const secCs = (w: number) => ({ display: "block", position: "static", flexShrink: "0", boxSizing: "content-box", marginLeft: "0px", marginRight: "0px", width: `${w}px` });
const section = xNode("n1", "nav", {
375: { cs: secCs(375), bbox: { x: 0, y: 0, width: 375, height: 80 } },
768: { cs: secCs(768), bbox: { x: 0, y: 0, width: 768, height: 80 } },
1280: { cs: secCs(1280), bbox: { x: 0, y: 0, width: 1280, height: 80 } },
}, [child]);
// A flex track with symmetric padding: its CONTENT width is narrower than the full-viewport section,
// so the "fills the flex container content" detectors (isFillsContainerWidth / isFullWidthShrinkSlide)
// do NOT fire — leaving the circular-slide detector as the width-plan winner it must be guarded from.
const trackCs = { display: "flex", position: "static", paddingLeft: "24px", paddingRight: "24px" };
return xNode("n0", "body", {
375: { cs: trackCs, bbox: { x: 0, y: 0, width: 375, height: 80 } },
768: { cs: trackCs, bbox: { x: 0, y: 0, width: 768, height: 80 } },
1280: { cs: trackCs, bbox: { x: 0, y: 0, width: 1280, height: 80 } },
}, [section]);
}
it("emits a fluid width for a full-viewport-span shrink-0 flex item, not frozen per-breakpoint px", () => {
const css = generateCss(xIr(fullBleedSection()), new Map());
const section = allRulesX(css, "n1");
assert.ok(!/width:375px|width:768px|width:1280px/.test(section), `a fluid full-bleed section must not freeze to captured px, got: ${section}`);
// A fluid outcome is either an explicit fluid width (width:100%/auto) OR no width at all — the block
// default resolves to auto and fills the container. Any FIXED px width would be the frozen misfire.
assert.ok(!/width:\d/.test(section) || /width:100%|width:auto/.test(section), `a fluid full-bleed section must emit a fluid width (or none), got: ${section}`);
});
});
// ===========================================================================
// Emission-geometry fix wave (T1/T2/T3/T5, C1 css side, C2)
// ===========================================================================
// T5 — a FULL-WIDTH block merely inset by a fixed gutter (`mx-4 md:mx-8`: box == container 2·gutter,
// width TRACKS the container) must NOT be auto-centred. Its symmetric margins vary per breakpoint and
// the probe reads wAuto (auto reproduces the inset width), so it slips past the fill guard — but
// emitting margin:auto on a width-dropped block resolves the margins to 0 and blows it full-bleed.
describe("generateCss auto-margin gutter-inset guard (T5)", () => {
const auto = (): RawSizing => ({ wAuto: true, wFill: false, hAuto: true, hFill: false });
it("keeps literal px margins on a full-width gutter-inset block (width tracks the container)", () => {
// mx-4 (16px) mobile, mx-8 (32px) desktop: box width == container 2·gutter at every width, so it
// TRACKS the container (343→704→1216). Not a centred content box.
const child = xNode("n1", "div", {
375: { cs: { display: "block", marginLeft: "16px", marginRight: "16px", width: "343px" }, bbox: { x: 16, y: 0, width: 343, height: 40 }, sizing: auto() },
768: { cs: { display: "block", marginLeft: "32px", marginRight: "32px", width: "704px" }, bbox: { x: 32, y: 0, width: 704, height: 40 }, sizing: auto() },
1280: { cs: { display: "block", marginLeft: "32px", marginRight: "32px", width: "1216px" }, bbox: { x: 32, y: 0, width: 1216, height: 40 }, sizing: auto() },
});
const parent = xNode("n0", "body", {
375: { cs: { display: "block" }, bbox: { x: 0, y: 0, width: 375, height: 40 } },
768: { cs: { display: "block" }, bbox: { x: 0, y: 0, width: 768, height: 40 } },
1280: { cs: { display: "block" }, bbox: { x: 0, y: 0, width: 1280, height: 40 } },
}, [child]);
const css = generateCss(xIr(parent), new Map());
const all = allRulesX(css, "n1");
assert.ok(!/margin-left:auto/.test(all), `a gutter-inset full-width block must not be auto-centred, got: ${all}`);
assert.ok(/margin-left:16px|margin-left:32px/.test(all), `it must keep its literal px margins, got: ${all}`);
});
});
// T3 — an authored height whose OWN probe says hAuto:false && hFill:false is load-bearing. The
// structural "taller than content" tests can't fire when an inline child's extent equals the box, so
// emission must trust the node's own probe (the twin of the heightFlows keep) and emit the height.
describe("generateCss authored-height kept via own probe (T3)", () => {
it("emits the height when the node's own probe is hAuto:false, hFill:false", () => {
const explicit = (): RawSizing => ({ wAuto: false, wFill: false, hAuto: false, hFill: false });
// Wrapper's only in-flow child is an inline <a> whose box equals the wrapper (content extent ==
// box height), so neither the taller-than-content nor overflow test can fire.
const link = xNode("n2", "a", {
375: { cs: { display: "inline", position: "static" }, bbox: { x: 0, y: 0, width: 220, height: 300 } },
768: { cs: { display: "inline", position: "static" }, bbox: { x: 0, y: 0, width: 220, height: 300 } },
1280: { cs: { display: "inline", position: "static" }, bbox: { x: 0, y: 0, width: 220, height: 300 } },
}, [{ text: "shop" } as IRChild]);
const wrapper = xNode("n1", "div", {
375: { cs: { display: "block", position: "static", height: "300px" }, bbox: { x: 0, y: 0, width: 220, height: 300 }, sizing: explicit() },
768: { cs: { display: "block", position: "static", height: "300px" }, bbox: { x: 0, y: 0, width: 220, height: 300 }, sizing: explicit() },
1280: { cs: { display: "block", position: "static", height: "300px" }, bbox: { x: 0, y: 0, width: 220, height: 300 }, sizing: explicit() },
}, [link]);
const parent = xNode("n0", "body", {
375: { bbox: { x: 0, y: 0, width: 375, height: 300 } },
768: { bbox: { x: 0, y: 0, width: 768, height: 300 } },
1280: { bbox: { x: 0, y: 0, width: 1280, height: 300 } },
}, [wrapper]);
const css = generateCss(xIr(parent), new Map());
const all = allRulesX(css, "n1");
assert.ok(/height:300px/.test(all), `authored height must survive on own-probe evidence, got: ${all}`);
});
});
// R2 — the own-probe height trust (T3) must NEVER bake a document-height wrapper. A body/html/main
// landmark, or any node whose captured box IS the page scroll height, probes hAuto:false/hFill:false
// as document-level re-measure noise (min-h-screen + %-height chain), not an authored height. Baking
// it pins the page to a fixed height — which, under an overflow:hidden main, hard-clips content drift
// and blinds the layout gate's heightDelta. Such wrappers must keep flowing (auto height).
describe("generateCss own-probe height trust never bakes a page-height wrapper (R2)", () => {
const explicit = (): RawSizing => ({ wAuto: false, wFill: false, hAuto: false, hFill: false });
// xIr sets perViewport.scrollHeight = 800 at every viewport.
function pageWrapper(tag: string, h: number) {
// A non-leaf wrapper with an in-flow child, whose own probe reads authored (hAuto/hFill false).
const child = xNode("n2", "div", Object.fromEntries(XVPS.map((vp) => [vp, { cs: { display: "block", position: "static" }, bbox: { x: 0, y: 0, width: vp, height: h } }])));
const wrap = xNode("n1", tag, Object.fromEntries(XVPS.map((vp) => [vp, { cs: { display: "block", position: "static", height: `${h}px` }, bbox: { x: 0, y: 0, width: vp, height: h }, sizing: explicit() }])), [child]);
return xNode("n0", "body", Object.fromEntries(XVPS.map((vp) => [vp, { bbox: { x: 0, y: 0, width: vp, height: h } }])), [wrap]);
}
it("does NOT bake a <main> landmark height even when its own probe is authored", () => {
const css = generateCss(xIr(pageWrapper("main", 800)), new Map());
const all = allRulesX(css, "n1");
assert.ok(!/height:800px/.test(all), `main landmark must keep flowing, got: ${all}`);
});
it("does NOT bake a wrapper whose captured height IS the page scroll height (±2%)", () => {
// 792 is within 2% of the 800px scrollHeight — a page-height wrapper, not authored content.
const css = generateCss(xIr(pageWrapper("div", 792)), new Map());
const all = allRulesX(css, "n1");
assert.ok(!/height:792px/.test(all), `near-page-height wrapper must keep flowing, got: ${all}`);
});
it("still bakes a genuinely authored height well below the page height", () => {
// 300px is far from the 800px scrollHeight — a real authored box (the T3 case) still survives.
const css = generateCss(xIr(pageWrapper("div", 300)), new Map());
const all = allRulesX(css, "n1");
assert.ok(/height:300px/.test(all), `authored sub-page height must still survive, got: ${all}`);
});
});
// FIX 1 — a banded AUTHORED fixed height (`h-[4rem] sm:h-[4.5rem] md:h-[6.25rem]`) on a grid/flex item
// whose fill child reproduces the box height (the fill↔content cycle: hFill:true) must NOT collapse to
// `height:100%`. The source authors a definite per-band height, geometry corroborates it (computed px ==
// bbox at every vp), so the baked per-band px must survive instead of the probe's circular fill verdict.
describe("generateCss authored banded fixed height survives the fill cycle (FIX 1)", () => {
const cycle = (): RawSizing => ({ wAuto: false, wFill: true, hAuto: false, hFill: true });
function tile(): IRNode {
// Inner filler: height:100% child (the fill side of the cycle).
const inner = xNode("n2", "span", Object.fromEntries(XVPS.map((vp) => [vp, {
cs: { display: "block", position: "static", height: "100%" },
bbox: { x: 0, y: 0, width: 100, height: vp === 375 ? 60 : vp === 768 ? 93.75 : 100 },
}])));
const t = xNode("n1", "div", {
375: { cs: { display: "flex", position: "static", height: "60px" }, bbox: { x: 0, y: 0, width: 100, height: 60 }, sizing: cycle() },
768: { cs: { display: "flex", position: "static", height: "93.75px" }, bbox: { x: 0, y: 0, width: 100, height: 93.75 }, sizing: cycle() },
1280: { cs: { display: "flex", position: "static", height: "100px" }, bbox: { x: 0, y: 0, width: 100, height: 100 }, sizing: cycle() },
}, [inner]);
t.srcClass = "px-2 flex h-[4rem] w-full items-center justify-center sm:h-[4.5rem] md:h-[6.25rem]";
// Grid parent that confers a definite height at every band, so isHeightFill/heightProbeFills would
// otherwise drop the tile to h-full.
return xNode("n0", "div", {
375: { cs: { display: "grid", position: "static", height: "60px" }, bbox: { x: 0, y: 0, width: 375, height: 60 } },
768: { cs: { display: "grid", position: "static", height: "93.75px" }, bbox: { x: 0, y: 0, width: 768, height: 93.75 } },
1280: { cs: { display: "grid", position: "static", height: "100px" }, bbox: { x: 0, y: 0, width: 1280, height: 100 } },
}, [t]);
}
it("keeps the baked per-band px height and never emits height:100%", () => {
const css = generateCss(xIr(tile()), new Map());
const all = allRulesX(css, "n1");
assert.ok(!/height:100%/.test(all), `authored fixed-height tile must not collapse to h-full, got: ${all}`);
assert.ok(/height:60px/.test(all), `375 band height (60px) must survive, got: ${all}`);
assert.ok(/height:93\.75px/.test(all), `768 band height (93.75px) must survive, got: ${all}`);
assert.ok(/height:100px/.test(all), `desktop band height (100px) must survive, got: ${all}`);
});
});
// FIX 3 — a testimonial-avatar wrapper authors a fixed square (`h-[2.5rem] w-[2.5rem] shrink-0`) whose
// img child fills it, so the sizing probe reads hAuto/wAuto:true and would drop BOTH dimensions, letting
// the box collapse to the img's intrinsic size. With geometry corroboration (computed px == bbox at
// every vp), the authored fixed width AND height must be emitted.
describe("generateCss authored fixed avatar size survives the content drop (FIX 3)", () => {
const drop = (): RawSizing => ({ wAuto: true, wFill: false, hAuto: true, hFill: false });
function avatar(): IRNode {
const img = xNode("n2", "img", Object.fromEntries(XVPS.map((vp) => [vp, {
cs: { display: "block", position: "static", height: "100%", width: "100%" },
bbox: { x: 0, y: 0, width: vp === 1280 ? 40 : 37.5, height: vp === 1280 ? 40 : 37.5 },
}])));
const a = xNode("n1", "div", {
375: { cs: { display: "block", position: "static", height: "37.5px", width: "37.5px" }, bbox: { x: 0, y: 0, width: 37.5, height: 37.5 }, sizing: drop() },
768: { cs: { display: "block", position: "static", height: "37.5px", width: "37.5px" }, bbox: { x: 0, y: 0, width: 37.5, height: 37.5 }, sizing: drop() },
1280: { cs: { display: "block", position: "static", height: "40px", width: "40px" }, bbox: { x: 0, y: 0, width: 40, height: 40 }, sizing: drop() },
}, [img]);
a.srcClass = "avatar-border-container h-[2.5rem] w-[2.5rem] shrink-0";
return xNode("n0", "div", Object.fromEntries(XVPS.map((vp) => [vp, { cs: { display: "flex", position: "static" }, bbox: { x: 0, y: 0, width: vp, height: 40 } }])), [a]);
}
it("emits both the authored fixed width and height per band", () => {
const css = generateCss(xIr(avatar()), new Map());
const all = allRulesX(css, "n1");
assert.ok(/width:37\.5px/.test(all), `narrow-band width (37.5px) must survive, got: ${all}`);
assert.ok(/height:37\.5px/.test(all), `narrow-band height (37.5px) must survive, got: ${all}`);
assert.ok(/width:40px/.test(all), `desktop width (40px) must survive, got: ${all}`);
assert.ok(/height:40px/.test(all), `desktop height (40px) must survive, got: ${all}`);
});
});
// FIX 2 — aspectHeightLaw must account for the aspect-ratio↔max-height WIDTH transfer. At a
// max-height-clamped viewport, choosing a ratio also caps the width at ratio×maxH; a square ratio for a
// 1.66:1 box (w=698, h=maxH=420) satisfies the height-only check but would clamp the clone's width to
// 420. The law must reject that ratio (and fall back to explicit per-band dimensions) rather than emit a
// wrong aspect-square.
describe("generateCss aspectHeightLaw rejects width-clamping ratio transfer (FIX 2)", () => {
function photo(): IRNode {
// 375: genuinely square, unclamped (304.69²). 768: 1.66:1 but height clamped to max-h 420 (w=698).
// 1280: genuinely square, unclamped (372²). The 768 clamp must not license aspect-square.
// Single-row grid (grid-template-rows == content height) so singleFluidGridRow fires and the
// media/aspect pass runs.
const container = xNode("n1", "div", {
375: { cs: { display: "grid", position: "static", maxHeight: "420px", aspectRatio: "auto", gridTemplateRows: "304.69px" }, bbox: { x: 0, y: 0, width: 304.69, height: 304.69 } },
768: { cs: { display: "grid", position: "static", maxHeight: "420px", aspectRatio: "auto", gridTemplateRows: "420px" }, bbox: { x: 0, y: 0, width: 697.69, height: 420 } },
1280: { cs: { display: "grid", position: "static", maxHeight: "none", aspectRatio: "auto", gridTemplateRows: "371.66px" }, bbox: { x: 0, y: 0, width: 371.66, height: 371.66 } },
});
return xNode("n0", "div", Object.fromEntries(XVPS.map((vp) => [vp, { cs: { display: "block", position: "static" }, bbox: { x: 0, y: 0, width: vp, height: 500 } }])), [container]);
}
it("does not emit aspect-square for a box whose max-height clamp would shrink its width", () => {
const css = generateCss(xIr(photo()), new Map());
const all = allRulesX(css, "n1");
assert.ok(!/aspect-ratio:\s*1\s*\/\s*1/.test(all) && !/aspect-ratio:1\/1/.test(all),
`must not force aspect-square where the max-height transfer would clamp width, got: ${all}`);
});
});
// FIX 4 — a full-screen hero authored `min-h-[100vh] md:min-h-[calc(100vh - 137px)]` gets its
// per-viewport min-height baked to px at capture, freezing the hero to one window height and leaving
// whitespace at other heights. With an authored vh-token corroboration, the constant-offset law
// `calc(100vh - 137px)` (k=1) must be recovered from samples spanning DIFFERENT viewport heights and
// re-emitted, instead of the frozen px. XVPS heights are 812/1024/800, so C=137 → 675/887/663px.
describe("generateCss recovers a viewport-relative min-height law (FIX 4)", () => {
function hero(src: string | undefined): IRNode {
const n = xNode("n1", "section", {
375: { cs: { display: "flex", position: "static", minHeight: "675px" }, bbox: { x: 0, y: 0, width: 375, height: 675 } },
768: { cs: { display: "flex", position: "static", minHeight: "887px" }, bbox: { x: 0, y: 0, width: 768, height: 887 } },
1280: { cs: { display: "flex", position: "static", minHeight: "663px" }, bbox: { x: 0, y: 0, width: 1280, height: 663 } },
});
if (src !== undefined) n.srcClass = src;
return xNode("n0", "body", Object.fromEntries(XVPS.map((vp) => [vp, { cs: { display: "block", position: "static" }, bbox: { x: 0, y: 0, width: vp, height: 900 } }])), [n]);
}
it("emits calc(100vh - 137px) and drops the frozen px when srcClass carries a vh token", () => {
const css = generateCss(xIr(hero("flex min-h-[100vh] md:min-h-[calc(100vh_-_137px)]")), new Map());
const all = allRulesX(css, "n1");
assert.ok(/min-height:calc\(100vh - 137px\)/.test(all), `hero min-height must become the viewport law, got: ${all}`);
assert.ok(!/min-height:663px/.test(all), `desktop min-height must not stay frozen px, got: ${all}`);
assert.ok(!/min-height:887px/.test(all), `768 min-height must not stay frozen px, got: ${all}`);
assert.ok(!/min-height:675px/.test(all), `375 min-height must not stay frozen px, got: ${all}`);
});
it("keeps the baked px when srcClass has NO viewport-relative token (corroboration guard)", () => {
const css = generateCss(xIr(hero("flex min-h-[675px] md:min-h-[663px]")), new Map());
const all = allRulesX(css, "n1");
assert.ok(!/calc\(100vh/.test(all), `a content-driven min-height must not be mistaken for a vh law, got: ${all}`);
assert.ok(/min-height:663px/.test(all) || /min-height:675px/.test(all) || /min-height:887px/.test(all),
`without a vh token the baked px min-height must survive, got: ${all}`);
});
});
// C1 (css side) — a computed `grid-template-rows: subgrid` is a STRUCTURAL keyword, not a baked px
// regime. It must NEVER be dropped by the reflow-mode dropGridRows path (which strips px row tracks).
describe("collectNodeRules never drops a subgrid rows template (C1)", () => {
function gridFixture() {
const layer = xNode("n2", "a", {
375: { cs: { display: "grid", position: "static", gridTemplateRows: "subgrid" }, bbox: { x: 0, y: 0, width: 375, height: 400 } },
768: { cs: { display: "grid", position: "static", gridTemplateRows: "subgrid" }, bbox: { x: 0, y: 0, width: 768, height: 400 } },
1280: { cs: { display: "grid", position: "static", gridTemplateRows: "subgrid" }, bbox: { x: 0, y: 0, width: 1280, height: 400 } },
});
const outer = xNode("n1", "div", {
375: { cs: { display: "grid", position: "static", gridTemplateRows: "139px 717px" }, bbox: { x: 0, y: 0, width: 375, height: 856 } },
768: { cs: { display: "grid", position: "static", gridTemplateRows: "139px 717px" }, bbox: { x: 0, y: 0, width: 768, height: 856 } },
1280: { cs: { display: "grid", position: "static", gridTemplateRows: "340px 340px" }, bbox: { x: 0, y: 0, width: 1280, height: 680 } },
}, [layer]);
return xNode("n0", "body", {
375: { bbox: { x: 0, y: 0, width: 375, height: 856 } },
768: { bbox: { x: 0, y: 0, width: 768, height: 856 } },
1280: { bbox: { x: 0, y: 0, width: 1280, height: 680 } },
}, [outer]);
}
it("emits grid-template-rows:subgrid even in reflow mode", () => {
const rules = collectNodeRules(xIr(gridFixture()), new Map(), undefined, undefined, undefined, true);
const nr = rules.get("n2");
assert.ok(nr, "subgrid layer rule must exist");
const all = [nr!.base.get("grid-template-rows"), ...nr!.bands.map((b) => b.decls.get("grid-template-rows"))].filter(Boolean).join(";");
assert.ok(/subgrid/.test(all), `subgrid keyword must survive reflow dropGridRows, got: ${all}`);
});
});
// C2 — a grid ITEM's `%` width resolves against its GRID AREA (track), not the grid container's
// content box. For a multi-column grid the parent-content-box ratio is the wrong base, so the fluid
// per-band percent path must BAIL (the item falls to auto/fixed) rather than emit a collapsing %.
describe("generateCss grid-item percent width bails to track (C2)", () => {
function gridItemFixture() {
// 4-column grid: each track ~302px. The item's width == its track (varies with the container), so
// fluidPercentByVp would compute ~24.5% of the 1240px content box — wrong (browser applies it to
// the 302px track → 74px). Must not emit a percent width.
const item = xNode("n2", "div", {
375: { cs: { display: "block", position: "static" }, bbox: { x: 0, y: 0, width: 343, height: 108 } },
768: { cs: { display: "block", position: "static" }, bbox: { x: 0, y: 0, width: 176, height: 108 } },
1280: { cs: { display: "block", position: "static" }, bbox: { x: 0, y: 0, width: 302, height: 108 } },
}, [{ text: "changelog card" } as IRChild]);
const grid = xNode("n1", "div", {
375: { cs: { display: "grid", position: "static", gridTemplateColumns: "343px" }, bbox: { x: 0, y: 0, width: 375, height: 108 } },
768: { cs: { display: "grid", position: "static", gridTemplateColumns: "176px 176px 176px" }, bbox: { x: 0, y: 0, width: 768, height: 108 } },
1280: { cs: { display: "grid", position: "static", gridTemplateColumns: "302px 302px 302px 302px" }, bbox: { x: 0, y: 0, width: 1240, height: 108 } },
}, [item]);
return xNode("n0", "body", {
375: { bbox: { x: 0, y: 0, width: 375, height: 108 } },
768: { bbox: { x: 0, y: 0, width: 768, height: 108 } },
1280: { bbox: { x: 0, y: 0, width: 1280, height: 108 } },
}, [grid]);
}
it("does not emit a container-content-box percent width for a multi-column grid item", () => {
const css = generateCss(xIr(gridItemFixture()), new Map());
const all = allRulesX(css, "n2");
assert.ok(!/width:24\.5%|width:24%/.test(all), `grid item must not resolve % against the wrong containing block, got: ${all}`);
});
});
// T1 — an off-screen-right (invisible-by-vp) in-flow flex slide still OCCUPIES layout and sets its
// track's cross-size. The base rule bakes canonical (desktop) geometry; without a per-viewport delta
// the frozen desktop width inflates the mobile track. The band must carry the slide's measured
// per-viewport geometry, not just a hide.
describe("collectNodeRules off-screen in-flow slide per-viewport geometry (T1)", () => {
function carousel() {
// Slide is visible at 1280 (285px desktop) but off-screen-right at 375 (invisible-by-vp, measured
// 220px). It is in-flow (static), display:block, non-zero bbox at 375 — occupying.
const slide = xNode("n2", "li", {
375: { cs: { display: "block", position: "static", flexShrink: "0", width: "220px", opacity: "1" }, bbox: { x: 400, y: 0, width: 220, height: 300 }, visible: false },
768: { cs: { display: "block", position: "static", flexShrink: "0", width: "220px", opacity: "1" }, bbox: { x: 500, y: 0, width: 220, height: 300 }, visible: false },
1280: { cs: { display: "block", position: "static", flexShrink: "0", width: "285px", opacity: "1" }, bbox: { x: 0, y: 0, width: 285, height: 400 }, visible: true },
}, [{ text: "slide" } as IRChild]);
const track = xNode("n1", "ul", {
375: { cs: { display: "flex", position: "static" }, bbox: { x: 0, y: 0, width: 375, height: 300 } },
768: { cs: { display: "flex", position: "static" }, bbox: { x: 0, y: 0, width: 768, height: 300 } },
1280: { cs: { display: "flex", position: "static" }, bbox: { x: 0, y: 0, width: 1280, height: 400 } },
}, [slide]);
return xNode("n0", "body", {
375: { bbox: { x: 0, y: 0, width: 375, height: 300 } },
768: { bbox: { x: 0, y: 0, width: 768, height: 300 } },
1280: { bbox: { x: 0, y: 0, width: 1280, height: 400 } },
}, [track]);
}
it("emits a per-viewport width delta for the off-screen slide (not just a hide)", () => {
const rules = collectNodeRules(xIr(carousel()), new Map());
const nr = rules.get("n2");
assert.ok(nr, "slide rule must exist");
// The narrow band must carry the measured 220px width, overriding the baked 285px desktop base.
const narrowBand = nr!.bands.find((b) => /max-width/.test(b.media) && b.decls.has("width"));
assert.ok(narrowBand, `off-screen slide must get a per-viewport width delta, got bands: ${JSON.stringify(nr!.bands.map((b) => ({ m: b.media, w: b.decls.get("width") })))}`);
assert.equal(narrowBand!.decls.get("width"), "220px", "the band carries the measured narrow-vp width");
});
});
// T2 — an authored parent height whose only in-flow child is an UN-PROBED <picture>/object-fit:cover
// replaced element that FILLS the box (border-box == parent content box) must be treated as a
// height-deriving fill child (so the authored height is kept, not dropped as content-derived).
describe("collectNodeRules picture fill child keeps authored height (T2)", () => {
function heroFixture() {
const explicitH = (): RawSizing => ({ wAuto: false, wFill: true, hAuto: false, hFill: false });
// <picture> is a replaced tag the probe skips → no sizing; its border-box == the wrapper content
// box at every vp. Height varies across vps so the flow path would otherwise try to drop it.
const picture = xNode("n2", "picture", {
375: { cs: { display: "block", position: "static" }, bbox: { x: 0, y: 0, width: 375, height: 560 } },
768: { cs: { display: "block", position: "static" }, bbox: { x: 0, y: 0, width: 768, height: 500 } },
1280: { cs: { display: "block", position: "static" }, bbox: { x: 0, y: 0, width: 1280, height: 480 } },
});
const wrapper = xNode("n1", "div", {
375: { cs: { display: "block", position: "static", height: "560px" }, bbox: { x: 0, y: 0, width: 375, height: 560 }, sizing: explicitH() },
768: { cs: { display: "block", position: "static", height: "500px" }, bbox: { x: 0, y: 0, width: 768, height: 500 }, sizing: explicitH() },
1280: { cs: { display: "block", position: "static", height: "480px" }, bbox: { x: 0, y: 0, width: 1280, height: 480 }, sizing: explicitH() },
}, [picture]);
return xNode("n0", "body", {
375: { bbox: { x: 0, y: 0, width: 375, height: 560 } },
768: { bbox: { x: 0, y: 0, width: 768, height: 500 } },
1280: { bbox: { x: 0, y: 0, width: 1280, height: 480 } },
}, [wrapper]);
}
it("keeps the wrapper's authored height when a <picture> child fills it", () => {
const rules = collectNodeRules(xIr(heroFixture()), new Map());
const nr = rules.get("n1");
assert.ok(nr, "wrapper rule must exist");
const all = [nr!.base.get("height"), ...nr!.bands.map((b) => b.decls.get("height"))].filter(Boolean).join(";");
assert.ok(/560px|500px|480px/.test(all), `authored height must survive with a picture fill child, got: ${all}`);
});
});
// A `position:sticky` full-width banner wrapper: its box spans the viewport at EVERY captured width
// (375/768/1280), so it was authored fluid (width:100%/auto) — sticky only shifts the box's offset
// while scrolling, never its resolved width. Freezing it to the canonical 1280px means at a 375
// viewport its `justify-content:center` pushes the inner content off-screen. A viewport-tracking
// sticky wrapper must emit no frozen width (→ fluid `width:auto`, resolving to the identical px at
// every captured width, so gates 06 stay unmoved).
describe("generateCss sticky full-width wrapper is viewport-tracking (no px freeze)", () => {
// Sticky banner directly under <body>; spans the full viewport width at every width, centred inner.
function stickyBanner() {
const inner = xNode("n2", "span", {
375: { cs: { display: "inline-block" }, bbox: { x: 120, y: 8, width: 135, height: 20 } },
768: { cs: { display: "inline-block" }, bbox: { x: 316, y: 8, width: 135, height: 20 } },
1280: { cs: { display: "inline-block" }, bbox: { x: 572, y: 8, width: 135, height: 20 } },
}, [{ text: "Enrollment is open" } as IRChild]);
const banner = xNode("n1", "div", {
375: { cs: { display: "flex", position: "sticky", justifyContent: "center", top: "0px", width: "375px" }, bbox: { x: 0, y: 0, width: 375, height: 36 } },
768: { cs: { display: "flex", position: "sticky", justifyContent: "center", top: "0px", width: "768px" }, bbox: { x: 0, y: 0, width: 768, height: 36 } },
1280: { cs: { display: "flex", position: "sticky", justifyContent: "center", top: "0px", width: "1280px" }, bbox: { x: 0, y: 0, width: 1280, height: 36 } },
}, [inner]);
return xNode("n0", "body", {
375: { bbox: { x: 0, y: 0, width: 375, height: 800 } },
768: { bbox: { x: 0, y: 0, width: 768, height: 800 } },
1280: { bbox: { x: 0, y: 0, width: 1280, height: 800 } },
}, [banner]);
}
it("does not freeze the sticky banner width to the canonical px", () => {
const css = generateCss(xIr(stickyBanner()), new Map());
const all = allRulesX(css, "n1");
assert.ok(!/width:1280px/.test(all), `sticky full-width banner must not freeze to canonical px, got: ${all}`);
assert.ok(!/width:768px/.test(all) && !/width:375px/.test(all), `no per-band px freeze either, got: ${all}`);
});
it("does not inject auto margins (fix drops the width only, not centring)", () => {
// The d3ec154 guard: mx-auto only for true auto-margin centring. A sticky banner centres its
// INNER content via justify-content, so the wrapper itself must not gain auto side margins.
const css = generateCss(xIr(stickyBanner()), new Map());
const all = allRulesX(css, "n1");
assert.ok(!/margin-left:auto/.test(all) && !/margin-right:auto/.test(all), `must not add auto margins, got: ${all}`);
});
it("still freezes a genuinely fixed-width sticky element (not viewport-spanning)", () => {
// A sticky sidebar rail whose width is a real fixed px (constant 280px, never == the viewport)
// must keep its baked width — the fluid-full-bleed detector only fires when the box spans the
// viewport at every width.
const rail = xNode("n1", "div", {
375: { cs: { display: "block", position: "sticky", top: "0px", width: "280px" }, bbox: { x: 0, y: 0, width: 280, height: 400 } },
768: { cs: { display: "block", position: "sticky", top: "0px", width: "280px" }, bbox: { x: 0, y: 0, width: 280, height: 400 } },
1280: { cs: { display: "block", position: "sticky", top: "0px", width: "280px" }, bbox: { x: 0, y: 0, width: 280, height: 400 } },
});
const root = xNode("n0", "body", {
375: { bbox: { x: 0, y: 0, width: 375, height: 800 } },
768: { bbox: { x: 0, y: 0, width: 768, height: 800 } },
1280: { bbox: { x: 0, y: 0, width: 1280, height: 800 } },
}, [rail]);
const css = generateCss(xIr(root), new Map());
assert.ok(/width:280px/.test(allRulesX(css, "n1")), `a fixed-width sticky rail keeps its px, got: ${allRulesX(css, "n1")}`);
});
});