Files
ditto.site/compiler/test/css.test.ts
T
Samraaj BathandClaude Fable 5 d3ec154bae Capture & emission fidelity wave: dotLottie support, scroll-state isolation, authored-geometry trust, honest quality scoring
Capture:
- Preserve real asset extensions (.lottie no longer truncated); extract the
  default animation JSON from dotLottie ZIP containers at materialization
  (minimal built-in ZIP reader, no new deps)
- Reset scroll + settle before every viewport snapshot so scroll-linked
  styles can't bake into captured computed values
- Marquee detection now requires content overflow (>1.35x), sustained
  velocity across separated windows, scroll independence (jiggle test), and
  genuine content repetition — kills scroll-settle-lerp false positives
- Sizing probe trusts authored explicit heights (px/vh/calc) over circular
  parent/child reflow verdicts, fixing dropped 100vh heroes

Normalize/generate:
- Canonicalize identity transforms to "none" per viewport; emit explicit
  transform resets across bands when any band has a real transform
- DittoLottie keeps the captured placeholder until DOMLoaded; failed loads
  no longer blank the container
- mx-auto only for true auto-margin centering; literal per-band margins are
  preserved (fixes full-bleed regressions)
- Spacing-scale snap tightened to 0.25px (3.5px stays p-[3.5px]); emit
  whitespace-nowrap for wrap-vulnerable single-line text leaves
- Grid recipes no longer override captured track counts or column spans;
  responsive re-flow plans apply only when geometry agrees

Quality scoring:
- Rewritten 6-dimension rubric (payload, decomposition, duplication,
  semantics, hygiene, runtime) with catastrophe caps and per-dimension
  reporting; thresholds are documented calibration guides

218 tests pass (74 new), typecheck clean.

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

487 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { 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 } 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] ?? "";
}
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}`);
});
});
// 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}`);
});
});
// 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")}`);
});
});
// 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")}`);
});
});