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
+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.
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[] = [];
for (const vp of viewports) {
const cs = node.computedByVp[vp]; const nb = node.bboxByVp[vp];
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
// 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;
@@ -387,6 +393,7 @@ function planWidth(node: IRNode, parentNode: IRNode | undefined, viewports: numb
samples.push({
w, bw: nb.width, cw, ml: pf(cs.marginLeft), mr: pf(cs.marginRight), maxW,
gapL: nb.x - cl, gapR: cr - (nb.x + nb.width), borderBox: (cs.boxSizing || "border-box") !== "content-box",
visible: !!node.visibleByVp[vp] && nb.width > 0,
});
}
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 zeroMargins = samples.every((s) => Math.abs(s.ml) <= 1.5 && Math.abs(s.mr) <= 1.5);
// (a) Centred max-width container: border-box, a single px max-width cap, and every sample's
// border box equals min(containerWidth, cap) — filling when narrower than the cap, centred
// (symmetric positive gaps) when wider. Reproduces `max-width:W; margin:0 auto` exactly.
if (samples.every((s) => s.maxW != null && s.borderBox)) {
const caps = samples.map((s) => s.maxW!);
let ok = span(caps) <= Math.max(2, 0.01 * Math.max(...caps));
// (a) Centred max-width container: border-box, a px max-width cap AT EVERY sample, and every
// sample's border box equals min(containerWidth, cap) — filling when narrower than the cap, centred
// (symmetric positive gaps) when wider. Reproduces `margin:0 auto` centring with a per-viewport cap.
// The cap need NOT be a single constant: a fluid `max-width: min(Wpx, 100vw 2·gutter)` resolves to
// a DIFFERENT px per width (e.g. 311/673/1145/1272), so requiring a constant cap wrongly rejected the
// 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;
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 (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;
+28 -1
View File
@@ -262,6 +262,13 @@ export function declToUtil(prop: string, value: string): string {
if (value === "0" || value === "0px" || value === "0rem") {
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))}]`;
}
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) };
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)
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)
@@ -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}`); }
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}`); }
// 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[] = [];
for (const b of bases) { if (drop.has(b)) continue; out.push(replace.get(b) ?? b); }
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);
if (pm) {
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);
return frac ? `${pm[1]}-${frac[1]}` : base;
}