Fix flex-basis 0% collapse, fluid max-width centering, letter-spacing snap, video frame normalization

- Keep basis/h/min-h [0%] literal (0% content-sizes on an indefinite axis;
  0px is definite zero) and fold grow + zero-basis into flex-1 - a
  flex:1 1 0% child no longer collapses to height 0 in auto-height columns
- Width centering accepts per-viewport caps (max-width: min(Npx, fluid))
  with symmetric gaps -> mx-auto + banded max-w instead of literal banded
  margins that drift off-centre between captured widths; non-painted
  viewports no longer poison the inference
- letter-spacing keeps sub-0.1px precision (no zero snap); style gate
  normalizes Chromium's letter-spacing "normal" <-> 0px serialization
- Videos pause + seek to frame 0 before every source AND clone screenshot,
  removing playback-time nondeterminism from perceptual evidence

278 tests pass (19 new), typecheck clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Samraaj Bath
2026-07-04 00:59:48 -07:00
co-authored by Claude Fable 5
parent 15d1e7a9e3
commit b445b62a69
8 changed files with 246 additions and 13 deletions
+41 -1
View File
@@ -646,6 +646,40 @@ async function captureScreenshot(
} }
} }
/**
* Pause every <video> and seek it to time 0, then wait for that seek to actually PAINT, so a
* screenshot taken right after is deterministic. A video's playback time is runtime state: a one-shot
* animation ends on its last frame, an autoplaying loop sits at an arbitrary offset, and the time a
* given viewport's shot happens to catch is nondeterministic. Without normalization the SOURCE and the
* (frame-0, non-playing) CLONE can show different frames of the same element — a phantom perceptual
* diff that no CSS change can close. Seeking BOTH sides to frame 0 before EVERY viewport screenshot
* removes it. `seeked` fires once the new frame is decoded; we still yield two rAFs so the compositor
* has painted it before the screenshot reads pixels. Bounded so a stalled/unseekable video can't hang.
*/
export async function normalizeVideoTime(page: import("playwright").Page): Promise<void> {
try {
await page.evaluate(async () => {
const vids = Array.from(document.querySelectorAll("video"));
const raf = () => new Promise<void>((r) => requestAnimationFrame(() => r()));
const waits: Promise<void>[] = [];
for (const v of vids) {
try { v.pause(); } catch { /* ignore */ }
// Seek to 0 only when not already there (a fresh seek to the current time may not fire `seeked`).
if (Math.abs(v.currentTime) < 1e-3) continue;
waits.push(new Promise<void>((resolve) => {
let settled = false;
const done = () => { if (settled) return; settled = true; v.removeEventListener("seeked", done); resolve(); };
v.addEventListener("seeked", done, { once: true });
try { v.currentTime = 0; } catch { done(); }
setTimeout(done, 400); // bound: a stalled/unseekable video resolves anyway
}));
}
await Promise.all(waits);
await raf(); await raf(); // let the decoded frame composite before the screenshot reads pixels
});
} catch { /* a page with no videos / an eval hiccup must never block the screenshot */ }
}
export async function captureSite(opts: { export async function captureSite(opts: {
url: string; url: string;
outDir: string; // source/ directory outDir: string; // source/ directory
@@ -1219,7 +1253,13 @@ export async function captureSite(opts: {
// Persist DOM snapshot, and (unless skipped for a production clone) the full-page screenshot. // Persist DOM snapshot, and (unless skipped for a production clone) the full-page screenshot.
writeJSONCompact(join(captureDir, `dom-${vw}.json`), snapshot); writeJSONCompact(join(captureDir, `dom-${vw}.json`), snapshot);
if (opts.screenshots !== false) await captureScreenshot(page, join(screenshotsDir, `${vw}.png`), vw, log); if (opts.screenshots !== false) {
// Normalize every video to frame 0 (paused) at THIS viewport before the shot — the clone is
// always at frame 0, so pinning the source there too makes the two channels comparable
// regardless of the playback time the viewport happened to catch.
await normalizeVideoTime(page);
await captureScreenshot(page, join(screenshotsDir, `${vw}.png`), vw, log);
}
// Stage 4: drive recognized affordances at the canonical viewport (opt-in). // Stage 4: drive recognized affordances at the canonical viewport (opt-in).
if (opts.interactions && vw === canonical) { if (opts.interactions && vw === canonical) {
+27 -8
View File
@@ -362,11 +362,17 @@ function planWidth(node: IRNode, parentNode: IRNode | undefined, viewports: numb
// Replaced/custom elements size to intrinsic dimensions under auto/%, not the container. // Replaced/custom elements size to intrinsic dimensions under auto/%, not the container.
if (REPLACED.has(node.tag) || node.tag.includes("-")) return none; if (REPLACED.has(node.tag) || node.tag.includes("-")) return none;
type Sample = { w: number; bw: number; cw: number; ml: number; mr: number; maxW: number | null; gapL: number; gapR: number; borderBox: boolean }; type Sample = { w: number; bw: number; cw: number; ml: number; mr: number; maxW: number | null; gapL: number; gapR: number; borderBox: boolean; visible: boolean };
const samples: Sample[] = []; const samples: Sample[] = [];
for (const vp of viewports) { for (const vp of viewports) {
const cs = node.computedByVp[vp]; const nb = node.bboxByVp[vp]; const cs = node.computedByVp[vp]; const nb = node.bboxByVp[vp];
if (!cs || !nb) continue; if (!cs || !nb) continue;
// A viewport where the node (or its containing block) is display:none / zero-box contributes no
// geometry: its computed box is 0×0, margins report the unresolved `auto`, and the containing width
// is 0. Skip it rather than bailing the whole inference — a container hidden at some breakpoints can
// still be provably centred at the widths where it IS painted (judged over the visible samples below).
const paintedHere = !!node.visibleByVp[vp] && nb.width > 0;
if (!paintedHere) continue;
// Block-LEVEL, in-flow only: margin-auto centring and auto/100% fill apply to these; an // Block-LEVEL, in-flow only: margin-auto centring and auto/100% fill apply to these; an
// inline / inline-block / flex-item box is positioned differently, so bail conservatively. // inline / inline-block / flex-item box is positioned differently, so bail conservatively.
if (!/^(block|flow-root|list-item|flex|grid)$/.test(cs.display || "")) return none; if (!/^(block|flow-root|list-item|flex|grid)$/.test(cs.display || "")) return none;
@@ -387,6 +393,7 @@ function planWidth(node: IRNode, parentNode: IRNode | undefined, viewports: numb
samples.push({ samples.push({
w, bw: nb.width, cw, ml: pf(cs.marginLeft), mr: pf(cs.marginRight), maxW, w, bw: nb.width, cw, ml: pf(cs.marginLeft), mr: pf(cs.marginRight), maxW,
gapL: nb.x - cl, gapR: cr - (nb.x + nb.width), borderBox: (cs.boxSizing || "border-box") !== "content-box", gapL: nb.x - cl, gapR: cr - (nb.x + nb.width), borderBox: (cs.boxSizing || "border-box") !== "content-box",
visible: !!node.visibleByVp[vp] && nb.width > 0,
}); });
} }
if (samples.length < 2) return none; if (samples.length < 2) return none;
@@ -395,14 +402,26 @@ function planWidth(node: IRNode, parentNode: IRNode | undefined, viewports: numb
const span = (xs: number[]): number => Math.max(...xs) - Math.min(...xs); const span = (xs: number[]): number => Math.max(...xs) - Math.min(...xs);
const zeroMargins = samples.every((s) => Math.abs(s.ml) <= 1.5 && Math.abs(s.mr) <= 1.5); const zeroMargins = samples.every((s) => Math.abs(s.ml) <= 1.5 && Math.abs(s.mr) <= 1.5);
// (a) Centred max-width container: border-box, a single px max-width cap, and every sample's // (a) Centred max-width container: border-box, a px max-width cap AT EVERY sample, and every
// border box equals min(containerWidth, cap) — filling when narrower than the cap, centred // sample's border box equals min(containerWidth, cap) — filling when narrower than the cap, centred
// (symmetric positive gaps) when wider. Reproduces `max-width:W; margin:0 auto` exactly. // (symmetric positive gaps) when wider. Reproduces `margin:0 auto` centring with a per-viewport cap.
if (samples.every((s) => s.maxW != null && s.borderBox)) { // The cap need NOT be a single constant: a fluid `max-width: min(Wpx, 100vw 2·gutter)` resolves to
const caps = samples.map((s) => s.maxW!); // a DIFFERENT px per width (e.g. 311/673/1145/1272), so requiring a constant cap wrongly rejected the
let ok = span(caps) <= Math.max(2, 0.01 * Math.max(...caps)); // commonest centred container and left it PLAN_FIXED with literal per-band `mx-*` (off-centre at any
// non-captured width). We accept per-viewport caps and let the normal per-band `max-width` emission
// carry them; `mx-auto` (from centerAlways) then centres at EVERY width. Fidelity is preserved
// because each sample's `min(cw, maxW_vp)` reproduces the captured box exactly at that width. The
// `sawCenter` requirement below is the load-bearing guard: a box that merely FILLS (never shows
// symmetric free-space gaps) is NOT proven centred and stays PLAN_FIXED with its literal margins.
// A display:none / zero-box sample has no geometry to prove centring against (its computed box is
// 0×0 and its margins report the unresolved `auto`). Judge case (a) only over the VISIBLE samples —
// a container centred wherever it is actually painted is centred everywhere via `mx-auto`, even if it
// is hidden at some breakpoints. (≥2 visible samples still required so the inference has evidence.)
const vis = samples.filter((s) => s.visible);
if (vis.length >= 2 && vis.every((s) => s.maxW != null && s.borderBox)) {
let ok = true;
let sawCenter = false; let sawCenter = false;
if (ok) for (const s of samples) { for (const s of vis) {
if (!close(s.bw, Math.min(s.cw, s.maxW!), 1.5, 0.01)) { ok = false; break; } if (!close(s.bw, Math.min(s.cw, s.maxW!), 1.5, 0.01)) { ok = false; break; }
if (s.cw > s.maxW! + 4) { // room to centre — must actually be centred, not left-aligned if (s.cw > s.maxW! + 4) { // room to centre — must actually be centred, not left-aligned
if (s.gapL > 1 && s.gapR > 1 && Math.abs(s.gapL - s.gapR) <= 1.5) sawCenter = true; if (s.gapL > 1 && s.gapR > 1 && Math.abs(s.gapL - s.gapR) <= 1.5) sawCenter = true;
+28 -1
View File
@@ -262,6 +262,13 @@ export function declToUtil(prop: string, value: string): string {
if (value === "0" || value === "0px" || value === "0rem") { if (value === "0" || value === "0px" || value === "0rem") {
return ZERO_NAMED.has(prop) ? `${ARB[prop]}-0` : `${ARB[prop]}-[0px]`; return ZERO_NAMED.has(prop) ? `${ARB[prop]}-0` : `${ARB[prop]}-[0px]`;
} }
// `snapLen`'s 0.1px integer snap is calibrated for BOX lengths (killing measurement jitter like
// `204.9994px`→`205px`). letter-spacing is authored at a far finer scale — a real `-0.08px`/
// `-0.0375px` tracking is WITHIN 0.1px of zero, so snapLen would collapse it to `0px`. Chromium
// then serializes computed `letter-spacing:0` as the keyword `normal`, which the style gate's
// numeric compare can't parse → a false exact-string mismatch. Skip the integer snap for tracking;
// `snapBase` rounds it to 2 decimals (`tracking-[-0.08px]`), the right precision for this axis.
if (prop === "letter-spacing") return `${ARB[prop]}-[${arb(value)}]`;
return `${ARB[prop]}-[${arb(snapLen(value))}]`; return `${ARB[prop]}-[${arb(snapLen(value))}]`;
} }
if (/^border-(top|right|bottom|left)-width$/.test(prop)) { if (/^border-(top|right|bottom|left)-width$/.test(prop)) {
@@ -372,7 +379,7 @@ function parseSide(b: string): SidePart | null {
for (const h of COLLAPSE_HEADS) if (body.startsWith(h + "-")) return { neg, head: h, suf: body.slice(h.length + 1) }; for (const h of COLLAPSE_HEADS) if (body.startsWith(h + "-")) return { neg, head: h, suf: body.slice(h.length + 1) };
return null; return null;
} }
function collapseBases(bases: string[]): string[] { export function collapseBases(bases: string[]): string[] {
const byHead = new Map<string, SidePart>(); // head → its parsed part (sides are unique within a group) const byHead = new Map<string, SidePart>(); // head → its parsed part (sides are unique within a group)
for (const b of bases) { const p = parseSide(b); if (p) byHead.set(p.head, p); } for (const b of bases) { const p = parseSide(b); if (p) byHead.set(p.head, p); }
const origOf = new Map<string, string>(); // head → original base string (to drop/replace by identity) const origOf = new Map<string, string>(); // head → original base string (to drop/replace by identity)
@@ -429,6 +436,18 @@ function collapseBases(bases: string[]): string[] {
if (wv !== null) { const sfx = wv ? `-${wv}` : ""; replace.set(`border-t${sfx}`, `border${sfx}`); for (const s of ["r", "b", "l"]) drop.add(`border-${s}${sfx}`); } if (wv !== null) { const sfx = wv ? `-${wv}` : ""; replace.set(`border-t${sfx}`, `border${sfx}`); for (const s of ["r", "b", "l"]) drop.add(`border-${s}${sfx}`); }
const cv = allEqSide(bcSuf); const cv = allEqSide(bcSuf);
if (cv !== null) { replace.set(`border-t-${cv}`, `border-${cv}`); for (const s of ["r", "b", "l"]) drop.add(`border-${s}-${cv}`); } if (cv !== null) { replace.set(`border-t-${cv}`, `border-${cv}`); for (const s of ["r", "b", "l"]) drop.add(`border-${s}-${cv}`); }
// flex:1 1 0% → the idiomatic `flex-1` (Tailwind `flex-1` compiles to `flex: 1 1 0%`, EXACTLY these
// longhands). Fold when this band sets flex-grow:1 (`grow-[1]`) AND a zero flex-basis (`basis-[0%]`,
// or its already-shortened `basis-0` — reached from a band delta). flex-shrink is the CSS default 1
// (elided) unless an explicit `shrink-0` is present, which would break the equivalence — so require
// its absence. Emitting `flex-1` rather than `grow basis-[0%]` also sidesteps the `basis-[0%]`→`basis
// -0` prettify hazard (0% content-sizes against an indefinite main axis; 0px is a definite zero).
const hasGrow1 = bases.includes("grow-[1]");
const zeroBasis = bases.includes("basis-[0%]") ? "basis-[0%]" : bases.includes("basis-0") ? "basis-0" : null;
if (hasGrow1 && zeroBasis && !bases.includes("shrink-0")) {
replace.set("grow-[1]", "flex-1");
drop.add(zeroBasis);
}
const out: string[] = []; const out: string[] = [];
for (const b of bases) { if (drop.has(b)) continue; out.push(replace.get(b) ?? b); } for (const b of bases) { if (drop.has(b)) continue; out.push(replace.get(b) ?? b); }
return out; return out;
@@ -509,6 +528,14 @@ export function prettifyBase(base: string): string {
const pm = /^(w|h|min-w|min-h|basis|inset|inset-x|inset-y|top|right|bottom|left)-\[(\d*\.?\d+)%\]$/.exec(base); const pm = /^(w|h|min-w|min-h|basis|inset|inset-x|inset-y|top|right|bottom|left)-\[(\d*\.?\d+)%\]$/.exec(base);
if (pm) { if (pm) {
const v = parseFloat(pm[2]!); const v = parseFloat(pm[2]!);
// A percentage on a MAIN-SIZE axis whose containing block may be indefinite is NOT equal to the
// same length in px. `flex-basis:0%` against an auto-sized (indefinite) flex container falls back
// to CONTENT sizing per the flexbox spec, whereas `flex-basis:0` (definite zero) gives a zero base
// size — collapsing a `flex:1 1 0%` item to 0 in an auto-height column. `height`/`min-height` in %
// resolve to `auto` when the containing block's height is indefinite, the same hazard. So never
// rewrite `[0%]` to the definite `-0` for these prefixes; keep the literal `basis-[0%]`/`h-[0%]`.
// (Width/inset percentages resolve against the always-definite containing-block WIDTH — safe.)
if (v === 0 && (pm[1] === "basis" || pm[1] === "h" || pm[1] === "min-h")) return base;
const frac = FRACTIONS.find(([p]) => Math.abs(p - v) < 0.4); const frac = FRACTIONS.find(([p]) => Math.abs(p - v) < 0.4);
return frac ? `${pm[1]}-${frac[1]}` : base; return frac ? `${pm[1]}-${frac[1]}` : base;
} }
+17 -1
View File
@@ -346,7 +346,10 @@ export function gate4Style(ir: IR, genSnaps: Record<number, PageSnapshot>, viewp
// equal, only numerically far apart. Treat any two such large radii as equivalent so the // equal, only numerically far apart. Treat any two such large radii as equivalent so the
// idiomatic `rounded-full` doesn't trip the exact-px compare; real radii stay ±2px. // idiomatic `rounded-full` doesn't trip the exact-px compare; real radii stay ±2px.
if (p === "borderTopLeftRadius" && pxNum(s.computed[p] ?? "") >= PILL_PX && pxNum(g.computed[p] ?? "") >= PILL_PX) continue; if (p === "borderTopLeftRadius" && pxNum(s.computed[p] ?? "") >= PILL_PX && pxNum(g.computed[p] ?? "") >= PILL_PX) continue;
if (!cmpNum(s.computed[p], g.computed[p], 2, 0)) { nodeOk = false; fails.push(p); } const eq = p === "letterSpacing"
? letterSpacingEquivalent(s.computed[p], g.computed[p])
: cmpNum(s.computed[p], g.computed[p], 2, 0);
if (!eq) { nodeOk = false; fails.push(p); }
} }
for (const p of PX4_PROPS) { for (const p of PX4_PROPS) {
// `gap: normal` is the initial value and resolves to 0 for flex/grid, so it // `gap: normal` is the initial value and resolves to 0 for flex/grid, so it
@@ -405,6 +408,19 @@ function cmpNum(a: string | undefined, b: string | undefined, abs: number, pct:
return withinAbs(na, nb, abs) || (pct > 0 && withinPct(na, nb, pct)); return withinAbs(na, nb, abs) || (pct > 0 && withinPct(na, nb, pct));
} }
/** Compare two computed `letter-spacing` values within the ±2px style tolerance, treating the keyword
* `normal` as `0px`. `letter-spacing: normal` is the initial value and adds no extra spacing (it
* computes to 0), so `normal` `0px`; crucially, Chromium serializes a computed `letter-spacing: 0`
* BACK as the keyword `normal`, so a genuinely-zero (or sub-0.1px, snapped-to-zero) tracking shows up
* as `normal` on one side and `0px` on the other a spelling difference that `cmpNum` alone reads as
* a NaN exact-string mismatch. Normalizing the keyword before the numeric compare removes that false
* failure while a real tracking delta (> 2px) still fails. Mirrors the `gap: normal → 0px` handling. */
export function letterSpacingEquivalent(a: string | undefined, b: string | undefined): boolean {
if (a === undefined) return true;
const norm = (v: string | undefined): string => (v ?? "").replace(/\bnormal\b/g, "0px");
return cmpNum(norm(a), norm(b), 2, 0);
}
// ---------- Gate 5: layout / section equivalence ---------- // ---------- Gate 5: layout / section equivalence ----------
export function gate5Layout(ir: IR, genSnaps: Record<number, PageSnapshot>, sections: Section[], viewports: number[], reflow = false): GateResult { export function gate5Layout(ir: IR, genSnaps: Record<number, PageSnapshot>, sections: Section[], viewports: number[], reflow = false): GateResult {
const issues: string[] = []; const issues: string[] = [];
+6 -1
View File
@@ -5,7 +5,7 @@ import { join, extname, normalize } from "node:path";
import { spawnSync } from "node:child_process"; import { spawnSync } from "node:child_process";
import { chromium } from "playwright"; import { chromium } from "playwright";
import { collectPage, type PageSnapshot } from "../capture/walker.js"; import { collectPage, type PageSnapshot } from "../capture/walker.js";
import { captureFullPageViaCDP } from "../capture/capture.js"; import { captureFullPageViaCDP, normalizeVideoTime } from "../capture/capture.js";
import { ensureDir, writeJSONCompact } from "../util/fsx.js"; import { ensureDir, writeJSONCompact } from "../util/fsx.js";
const ESBUILD_SHIM = const ESBUILD_SHIM =
@@ -262,6 +262,11 @@ export async function renderApp(opts: {
// matters less here, but symmetric capture is the requirement. // matters less here, but symmetric capture is the requirement.
try { try {
const shotPath = join(opts.renderedDir, "screenshots", `${vw}.png`); const shotPath = join(opts.renderedDir, "screenshots", `${vw}.png`);
// Pin every clone-side <video> to frame 0 (paused) before the shot — the SOURCE channel is
// now normalized to frame 0 at every viewport too, so both sides show the same frame and a
// video's playback time can't manufacture a perceptual diff. (The static clone rarely plays,
// but a replayed/autoplaying video would otherwise drift; symmetric normalization is the rule.)
await normalizeVideoTime(page);
try { try {
await captureFullPageViaCDP(page, shotPath); await captureFullPageViaCDP(page, shotPath);
} catch { } catch {
+26
View File
@@ -388,6 +388,32 @@ describe("generateCss literal-margin vs auto-centring", () => {
const all = allRulesX(css, "n1"); const all = allRulesX(css, "n1");
assert.ok(/margin-left:auto/.test(all), `a constrained centred block should still auto-centre, got: ${all}`); 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}`);
});
}); });
// BUG C — a single-line text leaf whose unwrapped width nearly fills its column at every width gets // BUG C — a single-line text leaf whose unwrapped width nearly fills its column at every width gets
+38
View File
@@ -0,0 +1,38 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { letterSpacingEquivalent } from "../src/validate/gates.js";
// Chromium serializes a computed `letter-spacing: 0` back as the keyword `normal`. The emitter, after
// snapping a sub-0.1px authored tracking to 0, ships `letter-spacing: 0px` — which the CLONE then
// reports as `normal` too, but a source that authored an explicit near-zero px can land on either
// spelling. Gate 4 must treat `normal` and `0px` as equal, or every such node fails on spelling alone.
describe("gate4 letterSpacing normal ↔ 0px normalization", () => {
it("treats `normal` and `0px` as equivalent", () => {
assert.ok(letterSpacingEquivalent("normal", "0px"));
assert.ok(letterSpacingEquivalent("0px", "normal"));
});
it("treats `normal` and `normal` as equivalent", () => {
assert.ok(letterSpacingEquivalent("normal", "normal"));
});
it("treats a sub-2px authored tracking vs `normal` as equivalent (within ±2px)", () => {
// source computed `-0.08px`, clone serialized `normal` — a 0.08px delta, well within tolerance.
assert.ok(letterSpacingEquivalent("-0.08px", "normal"));
assert.ok(letterSpacingEquivalent("normal", "-0.0375px"));
});
it("still equates two close real px values (0.24px vs 0.2px)", () => {
assert.ok(letterSpacingEquivalent("-0.24px", "-0.2px"));
});
it("still FAILS a genuine tracking difference beyond ±2px", () => {
assert.ok(!letterSpacingEquivalent("4px", "normal"));
assert.ok(!letterSpacingEquivalent("normal", "-3px"));
assert.ok(!letterSpacingEquivalent("6px", "2px"));
});
it("passes when the source did not constrain the property (undefined)", () => {
assert.ok(letterSpacingEquivalent(undefined, "0px"));
});
});
+63 -1
View File
@@ -1,6 +1,6 @@
import { describe, it } from "node:test"; import { describe, it } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { declToUtil, snapBase } from "../src/generate/tailwind.js"; import { declToUtil, snapBase, prettifyBase, collapseBases } from "../src/generate/tailwind.js";
// Zero-value gating. A named `-0` step only exists for props on Tailwind's spacing (or numeric) // Zero-value gating. A named `-0` step only exists for props on Tailwind's spacing (or numeric)
// scales; for the rest the class compiles to NOTHING — a silent no-op that ships the wrong style // scales; for the rest the class compiles to NOTHING — a silent no-op that ships the wrong style
@@ -71,3 +71,65 @@ describe("snapBase spacing-scale snapping", () => {
assert.equal(snapBase("mx-[16px]"), "mx-4"); assert.equal(snapBase("mx-[16px]"), "mx-4");
}); });
}); });
// A percentage 0 on a MAIN-SIZE axis (flex-basis) or a %-of-indefinite-height axis (height/min-height)
// is NOT the definite zero `-0`: `flex-basis:0%` content-sizes against an auto-sized flex container,
// whereas `flex-basis:0` gives a zero base size (collapsing a `flex:1 1 0%` item in an auto-height
// column). prettifyBase must keep those literal. Width/inset 0% resolve against the definite
// containing-block width, so their `-0` rewrite stays.
describe("prettifyBase 0% on indefinite-axis prefixes", () => {
it("keeps basis-[0%] literal (0% ≠ definite 0 for flex-basis)", () => {
assert.equal(prettifyBase("basis-[0%]"), "basis-[0%]");
});
it("keeps h-[0%] and min-h-[0%] literal (%-of-indefinite-height → auto)", () => {
assert.equal(prettifyBase("h-[0%]"), "h-[0%]");
assert.equal(prettifyBase("min-h-[0%]"), "min-h-[0%]");
});
it("still rewrites width/inset 0% to the definite -0 (definite containing-block width)", () => {
assert.equal(prettifyBase("w-[0%]"), "w-0");
assert.equal(prettifyBase("min-w-[0%]"), "min-w-0");
assert.equal(prettifyBase("left-[0%]"), "left-0");
assert.equal(prettifyBase("inset-x-[0%]"), "inset-x-0");
});
it("still rewrites non-zero fractions on every prefix (basis-[33.3333%] → basis-1/3)", () => {
assert.equal(prettifyBase("basis-[33.3333%]"), "basis-1/3");
assert.equal(prettifyBase("h-[50%]"), "h-1/2");
assert.equal(prettifyBase("min-h-[100%]"), "min-h-full");
});
});
// flex:1 1 0% is Tailwind's `flex-1` — fold the grow-[1] + zero-basis pair so the emitted class both
// reads idiomatically AND resolves to the exact flex longhands (avoiding the basis-[0%] hazard).
describe("collapseBases flex-1 folding", () => {
it("folds grow-[1] + basis-[0%] → flex-1 (shrink defaults to 1, elided)", () => {
assert.deepEqual(collapseBases(["grow-[1]", "basis-[0%]"]), ["flex-1"]);
});
it("folds grow-[1] + basis-0 (already-shortened band delta) → flex-1", () => {
assert.deepEqual(collapseBases(["grow-[1]", "basis-0"]), ["flex-1"]);
});
it("does NOT fold when shrink-0 is present (flex:1 0 0% ≠ flex-1)", () => {
assert.deepEqual(collapseBases(["grow-[1]", "shrink-0", "basis-[0%]"]).sort(), ["basis-[0%]", "grow-[1]", "shrink-0"]);
});
it("does NOT fold a non-zero basis (grow-[1] + basis-[50%] left as-is)", () => {
assert.deepEqual(collapseBases(["grow-[1]", "basis-[50%]"]).sort(), ["basis-[50%]", "grow-[1]"]);
});
});
// letter-spacing is authored at a finer scale than box lengths; a real -0.08px tracking is within
// snapLen's 0.1px integer-snap window and would collapse to 0px → Chromium serializes it as `normal`
// → a false style-gate mismatch. Tracking must skip the integer snap (snapBase keeps 2 decimals).
describe("declToUtil letter-spacing sub-0.1px preservation", () => {
it("keeps a real -0.08px tracking (does NOT snap to tracking-[0px])", () => {
assert.equal(declToUtil("letter-spacing", "-0.08px"), "tracking-[-0.08px]");
});
it("keeps -0.0375px through declToUtil, then snapBase rounds to 2 decimals (not to zero)", () => {
assert.equal(declToUtil("letter-spacing", "-0.0375px"), "tracking-[-0.0375px]");
assert.equal(snapBase("tracking-[-0.0375px]"), "tracking-[-0.04px]");
});
it("still keeps a genuine zero as tracking-[0px]", () => {
assert.equal(declToUtil("letter-spacing", "0px"), "tracking-[0px]");
});
it("still integer-snaps a BOX length near an integer (204.9994px → 205px)", () => {
assert.equal(declToUtil("width", "204.9994px"), "w-[205px]");
});
});