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>
This commit is contained in:
Samraaj Bath
2026-07-03 23:09:16 -07:00
co-authored by Claude Fable 5
parent 77be868ee3
commit d3ec154bae
19 changed files with 2345 additions and 386 deletions
+194
View File
@@ -1,6 +1,7 @@
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];
@@ -290,3 +291,196 @@ describe("generateCss scroll-timeline animation suppression", () => {
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")}`);
});
});
+146
View File
@@ -0,0 +1,146 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { deflateRawSync } from "node:zlib";
import { isZipArchive, extractDotLottieJson, readZipEntry } from "../src/capture/dotlottie.js";
import { extFromUrl } from "../src/capture/capture.js";
/**
* Build a minimal, spec-correct ZIP archive in-memory from named entries. Supports both
* stored (method 0) and deflated (method 8) compression so the extractor is exercised on both.
* No CRC validation is needed for our reader, so CRC fields are left 0.
*/
function buildZip(entries: Array<{ name: string; data: Buffer; deflate?: boolean }>): Buffer {
const locals: Buffer[] = [];
const centrals: Buffer[] = [];
let offset = 0;
for (const e of entries) {
const nameBuf = Buffer.from(e.name, "utf8");
const stored = e.deflate ? deflateRawSync(e.data) : e.data;
const method = e.deflate ? 8 : 0;
const local = Buffer.alloc(30 + nameBuf.length);
local.writeUInt32LE(0x04034b50, 0); // local file header sig
local.writeUInt16LE(20, 4); // version needed
local.writeUInt16LE(0, 6); // flags
local.writeUInt16LE(method, 8);
local.writeUInt16LE(0, 10); // time
local.writeUInt16LE(0, 12); // date
local.writeUInt32LE(0, 14); // crc
local.writeUInt32LE(stored.length, 18); // comp size
local.writeUInt32LE(e.data.length, 22); // uncomp size
local.writeUInt16LE(nameBuf.length, 26);
local.writeUInt16LE(0, 28); // extra len
nameBuf.copy(local, 30);
const localFull = Buffer.concat([local, stored]);
locals.push(localFull);
const central = Buffer.alloc(46 + nameBuf.length);
central.writeUInt32LE(0x02014b50, 0); // central dir sig
central.writeUInt16LE(20, 4); // version made by
central.writeUInt16LE(20, 6); // version needed
central.writeUInt16LE(0, 8); // flags
central.writeUInt16LE(method, 10);
central.writeUInt16LE(0, 12); // time
central.writeUInt16LE(0, 14); // date
central.writeUInt32LE(0, 16); // crc
central.writeUInt32LE(stored.length, 20); // comp size
central.writeUInt32LE(e.data.length, 24); // uncomp size
central.writeUInt16LE(nameBuf.length, 28);
central.writeUInt16LE(0, 30); // extra
central.writeUInt16LE(0, 32); // comment
central.writeUInt16LE(0, 34); // disk
central.writeUInt16LE(0, 36); // internal attrs
central.writeUInt32LE(0, 38); // external attrs
central.writeUInt32LE(offset, 42); // local header offset
nameBuf.copy(central, 46);
centrals.push(central);
offset += localFull.length;
}
const centralStart = offset;
const centralDir = Buffer.concat(centrals);
const eocd = Buffer.alloc(22);
eocd.writeUInt32LE(0x06054b50, 0);
eocd.writeUInt16LE(0, 4); // disk
eocd.writeUInt16LE(0, 6); // cd start disk
eocd.writeUInt16LE(entries.length, 8);
eocd.writeUInt16LE(entries.length, 10);
eocd.writeUInt32LE(centralDir.length, 12);
eocd.writeUInt32LE(centralStart, 16);
eocd.writeUInt16LE(0, 20); // comment len
return Buffer.concat([...locals, centralDir, eocd]);
}
const ANIM_1 = { v: "5.7.4", nm: "one", layers: [{ ind: 1 }] };
const ANIM_2 = { v: "5.7.4", nm: "two", layers: [{ ind: 2 }] };
const MANIFEST = { version: "1.0", animations: [{ id: "animation_default" }, { id: "animation_2" }] };
describe("dotLottie ZIP extraction", () => {
it("detects ZIP archives by the PK local-header magic", () => {
assert.equal(isZipArchive(Buffer.from([0x50, 0x4b, 0x03, 0x04, 0, 0])), true);
assert.equal(isZipArchive(Buffer.from('{"v":"5.7"}', "utf8")), false);
assert.equal(isZipArchive(Buffer.alloc(2)), false);
});
it("extracts the manifest's default animation JSON from a stored dotLottie ZIP", () => {
const zip = buildZip([
{ name: "manifest.json", data: Buffer.from(JSON.stringify(MANIFEST), "utf8") },
{ name: "animations/animation_default.json", data: Buffer.from(JSON.stringify(ANIM_1), "utf8") },
{ name: "animations/animation_2.json", data: Buffer.from(JSON.stringify(ANIM_2), "utf8") },
]);
const out = extractDotLottieJson(zip);
assert.ok(out, "expected an extracted animation buffer");
assert.deepEqual(JSON.parse(out!.toString("utf8")), ANIM_1);
});
it("extracts a DEFLATE-compressed animation entry (the common real-world case)", () => {
const zip = buildZip([
{ name: "manifest.json", data: Buffer.from(JSON.stringify(MANIFEST), "utf8"), deflate: true },
{ name: "animations/animation_default.json", data: Buffer.from(JSON.stringify(ANIM_1), "utf8"), deflate: true },
]);
const out = extractDotLottieJson(zip);
assert.ok(out);
assert.deepEqual(JSON.parse(out!.toString("utf8")), ANIM_1);
});
it("falls back to the name-sorted first animation when the manifest is absent", () => {
const zip = buildZip([
{ name: "animations/b.json", data: Buffer.from(JSON.stringify(ANIM_2), "utf8") },
{ name: "animations/a.json", data: Buffer.from(JSON.stringify(ANIM_1), "utf8") },
]);
const out = extractDotLottieJson(zip);
assert.ok(out);
assert.deepEqual(JSON.parse(out!.toString("utf8")), ANIM_1); // "a.json" sorts first
});
it("returns null for non-ZIP bytes and for a ZIP with no animations", () => {
assert.equal(extractDotLottieJson(Buffer.from('{"v":"5.7"}', "utf8")), null);
const noAnims = buildZip([{ name: "manifest.json", data: Buffer.from("{}", "utf8") }]);
assert.equal(extractDotLottieJson(noAnims), null);
});
it("reads a named entry directly", () => {
const zip = buildZip([{ name: "manifest.json", data: Buffer.from(JSON.stringify(MANIFEST), "utf8"), deflate: true }]);
const m = readZipEntry(zip, "manifest.json");
assert.ok(m);
assert.deepEqual(JSON.parse(m!.toString("utf8")), MANIFEST);
assert.equal(readZipEntry(zip, "missing.json"), null);
});
});
describe("asset extension preservation (extFromUrl)", () => {
it("preserves real long extensions instead of truncating to 5 chars", () => {
assert.equal(extFromUrl("https://x/anim.lottie"), "lottie");
assert.equal(extFromUrl("https://x/site.webmanifest"), "webmanifest");
assert.equal(extFromUrl("https://x/pic.png?v=2"), "png");
assert.equal(extFromUrl("https://x/font.woff2"), "woff2");
});
it("rejects absurdly long or non-alphanumeric trailing segments", () => {
assert.equal(extFromUrl("https://x/name.thisisnotanextension"), "");
assert.equal(extFromUrl("https://x/dir.with.dots/file"), ""); // dot is in a path segment, not the file
});
});
+77
View File
@@ -0,0 +1,77 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { DITTO_LOTTIE_TSX } from "../src/generate/lottie.js";
import { isIdentityTransform, canonicalizeTransforms } from "../src/normalize/ir.js";
import type { StyleMap } from "../src/normalize/ir.js";
/**
* The DittoLottie runtime must NOT erase the captured placeholder frame before the animation
* has successfully loaded — a failed load would otherwise blank the container (erasing hero /
* logo / footer media). These are string assertions on the emitted 'use client' template.
*/
describe("DittoLottie placeholder retention", () => {
it("does not clear the container's innerHTML before mounting", () => {
assert.ok(
!/el\.innerHTML\s*=\s*""/.test(DITTO_LOTTIE_TSX),
"template must not eagerly clear the placeholder via el.innerHTML = \"\"",
);
});
it("mounts the live animation into a separate overlay child, not the container itself", () => {
assert.match(DITTO_LOTTIE_TSX, /createElement\(["']div["']\)/);
assert.match(DITTO_LOTTIE_TSX, /container:\s*mount/);
});
it("only removes the placeholder after a successful load event (DOMLoaded)", () => {
// The reveal/swap must be gated behind lottie's ready event, and the placeholder removal
// must live inside that gated path (removing original children once mount is ready).
assert.match(DITTO_LOTTIE_TSX, /addEventListener\(\s*["']DOMLoaded["']/);
assert.match(DITTO_LOTTIE_TSX, /removeChild/);
// The removal must reference the ready swap, not run unconditionally at mount time.
const revealIdx = DITTO_LOTTIE_TSX.indexOf("DOMLoaded");
const clearIdx = DITTO_LOTTIE_TSX.indexOf("removeChild");
assert.ok(revealIdx >= 0 && clearIdx >= 0, "both the ready gate and the placeholder removal must be present");
});
it("handles a failed load without leaving a broken mount stacked over the placeholder", () => {
assert.match(DITTO_LOTTIE_TSX, /data_failed/);
});
});
describe("identity-transform canonicalization (isIdentityTransform)", () => {
it("treats none and identity matrices as identity", () => {
assert.equal(isIdentityTransform("none"), true);
assert.equal(isIdentityTransform(undefined), true);
assert.equal(isIdentityTransform("matrix(1, 0, 0, 1, 0, 0)"), true);
assert.equal(isIdentityTransform("matrix(1,0,0,1,0,0)"), true);
assert.equal(
isIdentityTransform("matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)"),
true,
);
});
it("does NOT treat a real (non-identity) transform as identity", () => {
assert.equal(isIdentityTransform("matrix(1, 0, 0, 1, 0, 67.75)"), false);
assert.equal(isIdentityTransform("translateY(67.75px)"), false);
assert.equal(isIdentityTransform("matrix(0.5, 0, 0, 0.5, 0, 0)"), false);
assert.equal(isIdentityTransform("matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 10, 0, 1)"), false);
});
it("rewrites identity values to `none` while leaving a real transform observable at other widths", () => {
// The contamination scenario: the base viewport (1280) baked a scroll-linked translateY,
// while the other widths report identity (none / identity matrix). After canonicalization
// the identity widths read literal `none`, so the generator's per-band delta emits the
// explicit reset and the base transform can't cascade across bands.
const computedByVp: Record<number, StyleMap> = {
375: { transform: "none" } as StyleMap,
768: { transform: "matrix(1, 0, 0, 1, 0, 0)" } as StyleMap,
1280: { transform: "matrix(1, 0, 0, 1, 0, 67.75)" } as StyleMap, // contaminated base
1920: { transform: "none" } as StyleMap,
};
canonicalizeTransforms(computedByVp);
assert.equal(computedByVp[375]!.transform, "none");
assert.equal(computedByVp[768]!.transform, "none"); // identity matrix normalized to none
assert.equal(computedByVp[1280]!.transform, "matrix(1, 0, 0, 1, 0, 67.75)"); // real transform preserved
assert.equal(computedByVp[1920]!.transform, "none");
});
});
+104
View File
@@ -0,0 +1,104 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import {
medianVelocityPxPerSec,
classifyVelocitySamples,
hasRepeatedChildren,
} from "../src/capture/motion.js";
// These cover the pure discriminators extracted from detectMarquees' in-browser sampling.
// The regression they defend: on a scroll-LINKED-easing page, a static logo row is still
// lerping toward its scroll-target right after scrollIntoView, reading as a constant
// velocity over ONE short window → four phantom marquees at identical -34px/s. The layered
// tests below make that classification fail.
describe("medianVelocityPxPerSec", () => {
it("converts a steady per-120ms delta to px/s and ignores the wrap-reset outlier", () => {
// steady -4px per 120ms ≈ -33px/s; one big +200 wrap jump is an outlier the median drops.
const deltas = [-4, -4, 200, -4, -4];
assert.equal(medianVelocityPxPerSec(deltas, 120), Math.round((-4 / 120) * 1000));
});
it("returns 0 for empty deltas or a non-positive cadence", () => {
assert.equal(medianVelocityPxPerSec([], 120), 0);
assert.equal(medianVelocityPxPerSec([-4, -4], 0), 0);
});
});
describe("classifyVelocitySamples — sustained constant velocity (discriminator 2)", () => {
const MS = 120;
it("PASSES a real marquee: velocity holds across both windows", () => {
const w1 = [-4, -4, -4, -4, -4];
const w2 = [-4, -4, -4, -4, -4];
const r = classifyVelocitySamples(w1, w2, MS);
assert.equal(r.isMarquee, true);
assert.equal(r.pxPerSec, medianVelocityPxPerSec(w1, MS));
});
it("REJECTS a scroll-settle lerp: velocity decays sharply in window 2 (the false positive)", () => {
// window1 still lerping fast toward scroll-target; window2 nearly settled.
const w1 = [-4, -4, -4, -4, -4]; // ≈ -33px/s (the phantom "-34px/s")
const w2 = [-0.4, -0.4, -0.4, -0.4, -0.4]; // decayed to ~-3px/s
assert.equal(classifyVelocitySamples(w1, w2, MS).isMarquee, false);
});
it("REJECTS when window 2 has fully settled (velocity ~0)", () => {
const w1 = [-4, -4, -4, -4, -4];
const w2 = [0, 0, 0, 0, 0];
assert.equal(classifyVelocitySamples(w1, w2, MS).isMarquee, false);
});
it("REJECTS when the direction reverses between windows", () => {
const w1 = [-4, -4, -4, -4, -4];
const w2 = [4, 4, 4, 4, 4];
assert.equal(classifyVelocitySamples(w1, w2, MS).isMarquee, false);
});
it("REJECTS a static row: no motion in either window", () => {
assert.equal(classifyVelocitySamples([0, 0, 0], [0, 0, 0], MS).isMarquee, false);
});
it("PASSES a slightly slower-but-still-moving second window (within tolerance)", () => {
// small natural jitter (10% slower) must not disqualify a genuine ticker.
const w1 = [-4, -4, -4, -4, -4];
const w2 = [-3.6, -3.6, -3.6, -3.6, -3.6];
assert.equal(classifyVelocitySamples(w1, w2, MS).isMarquee, true);
});
});
describe("hasRepeatedChildren — genuine duplication (discriminator 4)", () => {
it("PASSES two consecutive children with an identical outerHTML hash (a cloned copy)", () => {
const hashes = [111, 111, 222]; // first two are a literal duplicate
const widths = [80, 80, 120];
assert.equal(hasRepeatedChildren(hashes, widths), true);
});
it("PASSES a duplicated content block via the repeated width sequence [A B A B]", () => {
// hashes differ (cloned then attribute-tweaked) but geometry repeats: [100,60,100,60].
const hashes = [1, 2, 3, 4];
const widths = [100, 60, 100, 60];
assert.equal(hasRepeatedChildren(hashes, widths), true);
});
it("REJECTS a row of distinct logos: no consecutive repetition (the false-positive shape)", () => {
// four DIFFERENT logos — the exact static-logo-row case that produced phantom marquees.
const hashes = [10, 20, 30, 40];
const widths = [90, 110, 75, 130];
assert.equal(hasRepeatedChildren(hashes, widths), false);
});
it("REJECTS fewer than two children", () => {
assert.equal(hasRepeatedChildren([5], [50]), false);
assert.equal(hasRepeatedChildren([], []), false);
});
it("does NOT treat a run of zero-width nodes as repetition (hash 0 / width 0 guards)", () => {
assert.equal(hasRepeatedChildren([0, 0], [0, 0]), false);
assert.equal(hasRepeatedChildren([1, 2, 3, 4], [0, 0, 0, 0]), false);
});
it("REJECTS an odd-length width sequence that can't split into halves", () => {
const hashes = [1, 2, 3];
const widths = [100, 60, 100];
assert.equal(hasRepeatedChildren(hashes, widths), false);
});
});
+256
View File
@@ -0,0 +1,256 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
inlineBlobBytes,
filePayload,
whitespaceLiterals,
subpixelArbitraries,
customPropTokens,
probeArtifacts,
uncleanedListeners,
tagHistogram,
ariaHiddenFocusables,
duplicateHelpers,
svgPathDuplication,
nearDuplicateComponents,
componentNames,
toLetter,
scoreApp,
type SrcFile,
} from "../src/runner/qualityScore.js";
const srcFile = (rel: string, text: string): SrcFile => ({
path: rel, rel, ext: rel.slice(rel.lastIndexOf(".")), text, lines: text.split("\n").length,
});
// ---------------------------------------------------------------------------
// Metric extractors
// ---------------------------------------------------------------------------
describe("inlineBlobBytes", () => {
it("counts base64 data-URI payload bytes", () => {
const b64 = "A".repeat(500);
const text = `const img = "data:image/png;base64,${b64}";`;
assert.equal(inlineBlobBytes(text), 500);
});
it("counts a long markup string handed in as a prop", () => {
const html = "<div>" + "<span>x</span>".repeat(250) + "</div>";
const text = `<Frame html={${JSON.stringify(html)}} />`;
assert.ok(inlineBlobBytes(text) > 1000, "flags HTML-as-string blob");
});
it("ignores ordinary short strings", () => {
assert.equal(inlineBlobBytes(`const s = "hello world";`), 0);
});
});
describe("filePayload", () => {
it("reports a giant single line", () => {
const giant = "x".repeat(200_000);
const p = filePayload(srcFile("a.tsx", `const a = 1;\nconst b = "${giant}";\n`));
assert.ok(p.maxLine >= 200_000, "detects the giant line");
assert.ok(p.bytes >= 200_000, "counts the bytes");
});
it("a normal formatted file has a small max line", () => {
const p = filePayload(srcFile("a.tsx", "const a = 1;\nconst b = 2;\nexport default a;\n"));
assert.ok(p.maxLine < 40, "small max line");
});
});
describe("whitespaceLiterals", () => {
it("counts {\" \"} capture-whitespace literals", () => {
const text = `<p>Hi{" "}there{" "}world{' '}!</p>`;
assert.equal(whitespaceLiterals(text), 3);
});
it("does not flag meaningful expression literals", () => {
assert.equal(whitespaceLiterals(`<p>{name}{count}</p>`), 0);
});
});
describe("subpixelArbitraries", () => {
it("flags non-integer px/rem arbitraries but not whole ones", () => {
// 713.938px (frozen) + 12.5rem (=200px, whole) → only the first counts.
const text = `<div className="w-[713.938px] h-[12.5rem] p-[16px]" />`;
assert.equal(subpixelArbitraries(text), 1);
});
});
describe("customPropTokens", () => {
it("splits opaque --clr-N / hash tokens from named ones", () => {
const css = `:root{ --clr-7:#fff; --c12:#000; --a1b2c3:#111; --brand-primary:#f00; --space-4:1rem; }`;
const t = customPropTokens(css);
assert.equal(t.total, 5);
assert.equal(t.opaque, 3, "clr-7, c12, hash a1b2c3 are opaque; brand-primary/space-4 are named");
});
});
describe("probeArtifacts", () => {
it("flags off-screen capture-probe scaffolding", () => {
const text = `<span data-probe="1">m</span><i style={{clip: 'rect(0 0 0 0)'}} />`;
assert.ok(probeArtifacts(text) >= 2);
});
});
describe("uncleanedListeners", () => {
it("counts addEventListener with no cleanup", () => {
const text = `el.addEventListener("scroll", fn);\nwin.addEventListener("resize", fn);`;
assert.equal(uncleanedListeners(text), 2);
});
it("does not flag when a matching removeEventListener / teardown exists", () => {
const text = `useEffect(() => { el.addEventListener("scroll", fn); return () => el.removeEventListener("scroll", fn); });`;
assert.equal(uncleanedListeners(text), 0);
});
});
describe("tagHistogram", () => {
it("counts opening element tags by name", () => {
const h = tagHistogram(`<div><div/><span>x</span><button>b</button></div>`);
assert.equal(h["div"], 2);
assert.equal(h["span"], 1);
assert.equal(h["button"], 1);
});
});
describe("ariaHiddenFocusables", () => {
it("flags aria-hidden on a focusable element", () => {
const text = `<button aria-hidden="true">x</button><a aria-hidden={true} href="#">y</a>`;
assert.equal(ariaHiddenFocusables(text), 2);
});
it("ignores aria-hidden on a decorative div", () => {
assert.equal(ariaHiddenFocusables(`<div aria-hidden="true" />`), 0);
});
});
describe("duplicateHelpers", () => {
it("counts copy-paste helper bodies (e.g. a repeated cn())", () => {
const body = `{ return classes.filter(Boolean).join(" ").replace(/\\s+/g, " ").trim(); }`;
const text = `function cn(...classes) ${body}\nfunction cx(...classes) ${body}\nfunction merge(...classes) ${body}`;
const d = duplicateHelpers(text);
assert.equal(d.defs, 3);
assert.equal(d.dups, 2, "two of the three are identical duplicates");
});
it("does not flag distinct helper bodies", () => {
const text = `function a(x) { return x + 1111111111; }\nfunction b(x) { return x - 2222222222; }`;
assert.equal(duplicateHelpers(text).dups, 0);
});
});
describe("svgPathDuplication", () => {
it("counts repeated inline <path d=...> strings", () => {
const d = "M10 10 L20 20 L30 30 Z aaaaaaaaaaaaaaaa";
const text = `<path d="${d}"/><path d="${d}"/><path d="M1 1 L2 2 different pathhhhhhhh"/>`;
const r = svgPathDuplication(text);
assert.equal(r.total, 3);
assert.equal(r.repeats, 1);
});
});
describe("nearDuplicateComponents", () => {
it("counts pairs sharing a tag signature", () => {
assert.equal(nearDuplicateComponents(["div:2,span:1", "div:2,span:1", "nav:1"]), 1);
assert.equal(nearDuplicateComponents(["a", "a", "a"]), 2);
});
});
describe("componentNames", () => {
it("extracts exported + declared component symbols", () => {
const text = `export default function HeroSection(){}\nexport function Footer(){}\nconst Navbar = () => {};`;
const names = componentNames(text);
assert.ok(names.includes("HeroSection"));
assert.ok(names.includes("Footer"));
assert.ok(names.includes("Navbar"));
});
});
describe("toLetter", () => {
it("maps scores to the expected grade bands", () => {
assert.equal(toLetter(95), "A");
assert.equal(toLetter(82), "B-");
assert.equal(toLetter(75), "C");
assert.equal(toLetter(67), "D+");
assert.equal(toLetter(59), "F");
});
});
// ---------------------------------------------------------------------------
// scoreApp — end-to-end on synthetic app trees (generic fixture content)
// ---------------------------------------------------------------------------
function makeTree(files: Record<string, string>): string {
const root = mkdtempSync(join(tmpdir(), "qs-"));
for (const [rel, text] of Object.entries(files)) {
const p = join(root, rel);
mkdirSync(p.slice(0, p.lastIndexOf("/")), { recursive: true });
writeFileSync(p, text);
}
return root;
}
describe("scoreApp — hard cap on catastrophic payload", () => {
it("caps a tree containing a multi-megabyte source file into D-range", () => {
const giant = "x".repeat(1_200_000); // >1MB single file
const root = makeTree({
"src/app/page.tsx": `export default function Page(){ return <main><h1>Hi</h1><section><p>ok</p></section></main>; }`,
"src/app/svgs/blob.tsx": `export const blob = "${giant}";`,
});
try {
const rep = scoreApp(root);
assert.ok(rep.caps.length > 0, "a catastrophe cap is recorded");
assert.ok(rep.total <= 68, `grade is capped into D-range, got ${rep.total}`);
assert.ok(["D+", "D", "D-", "F"].includes(rep.grade), `grade ${rep.grade} is D-range or below`);
assert.ok(rep.categories.payload!.score < rep.categories.payload!.max * 0.5, "payload dimension collapses");
} finally { rmSync(root, { recursive: true, force: true }); }
});
});
describe("scoreApp — semantics", () => {
it("penalizes a page with no h1 vs one with a single h1", () => {
const withH1 = makeTree({
"src/app/page.tsx": `export default function Page(){ return <main><h1>Title</h1><section><p>a</p></section><nav><a href="#">x</a></nav></main>; }`,
});
const noH1 = makeTree({
"src/app/page.tsx": `export default function Page(){ return <div><div><div><div><span>a</span></div></div></div></div>; }`,
});
try {
const a = scoreApp(withH1);
const b = scoreApp(noH1);
assert.ok(a.categories.semantics!.score > b.categories.semantics!.score, "h1 + semantic tags score higher");
assert.equal(b.categories.semantics!.metrics.h1, 0);
} finally {
rmSync(withH1, { recursive: true, force: true });
rmSync(noH1, { recursive: true, force: true });
}
});
});
describe("scoreApp — decomposition", () => {
it("rates a decomposed tree above a single-file monolith of the same markup", () => {
const bodyTags = "<div><p>x</p><a href='#'>l</a></div>".repeat(40);
const monolith = makeTree({
"src/app/page.tsx": `export default function Page(){ return <main><h1>H</h1>${bodyTags}</main>; }`,
});
const decomposed = makeTree({
"src/app/page.tsx": `import Hero from "./sections/hero";\nimport Feature from "./sections/feature";\nexport default function Page(){ return <main><h1>H</h1><Hero/><Feature/></main>; }`,
"src/app/sections/hero.tsx": `export function Hero(){ return <section>${"<div><p>x</p></div>".repeat(20)}</section>; }`,
"src/app/sections/feature.tsx": `export function Feature(){ return <section>${"<div><a href='#'>l</a></div>".repeat(20)}</section>; }`,
});
try {
const m = scoreApp(monolith);
const d = scoreApp(decomposed);
assert.ok(d.categories.decomposition!.score > m.categories.decomposition!.score, "decomposed scores higher on decomposition");
assert.ok(d.total > m.total, "and grades higher overall");
} finally {
rmSync(monolith, { recursive: true, force: true });
rmSync(decomposed, { recursive: true, force: true });
}
});
});
+157
View File
@@ -0,0 +1,157 @@
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 { RecipeReport, RecipeCandidate, RecipeResponsiveRegime } from "../src/infer/recipes.js";
import { recipeResponsiveClassCleaner } from "../src/generate/app.js";
// The recipe pass infers a container's column count by grouping item bounding boxes into rows and
// taking the widest row. When an item spans multiple tracks that under-reports the real track count,
// so the synthesized column plan must NOT override the authored/computed grid geometry that the
// Tailwind emitter already baked into the className. These fixtures build a 3-track grid whose main
// card spans 2 columns (heuristic reports 2 columns) and assert the emitted classes keep 3 tracks
// and the span.
const VPS = [768, 1280];
function computed(over: StyleMap = {}): StyleMap {
return { display: "block", position: "static", visibility: "visible", whiteSpace: "normal", ...over };
}
function node(id: string, tag: string, byVp: Record<number, StyleMap>, children: IRChild[] = []): IRNode {
const computedByVp: Record<number, StyleMap> = {};
const bboxByVp: Record<number, BBox> = {};
const visibleByVp: Record<number, boolean> = {};
for (const vp of VPS) {
computedByVp[vp] = { ...computed(), ...(byVp[vp] ?? {}) };
bboxByVp[vp] = { x: 0, y: 0, width: vp, height: 100 };
visibleByVp[vp] = true;
}
return { id, tag, attrs: {}, visibleByVp, bboxByVp, computedByVp, children };
}
function irWith(root: IRNode): IR {
return {
doc: {
sourceUrl: "https://example.test/",
title: "Fixture",
lang: "en",
charset: "UTF-8",
metaViewport: "width=device-width, initial-scale=1",
viewports: VPS,
sampleViewports: VPS,
canonicalViewport: 1280,
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,
};
}
function regime(viewport: number, columns: number, visibleItems: number): RecipeResponsiveRegime {
return { viewport, layout: "grid", rootBox: { x: 0, y: 0, width: viewport, height: 400 }, visibleItems, columns, rows: 1 };
}
function candidate(over: Partial<RecipeCandidate>): RecipeCandidate {
return {
id: "r0",
kind: "product-grid",
confidence: 0.89,
risk: "low",
rootCid: "n0",
rootTag: "section",
itemParentCid: "n1",
componentName: "ProductGridSection",
itemCount: 2,
repeatedItems: [],
responsiveRegimes: [regime(768, 2, 2), regime(1280, 2, 2)],
sourceHints: [],
signals: [],
emissionStatus: "report-only",
fallbackReason: "",
...over,
};
}
function report(candidates: RecipeCandidate[]): RecipeReport {
return {
version: 1,
sourceUrl: "https://example.test/",
canonicalViewport: 1280,
viewports: VPS,
sampledViewports: VPS,
summary: { totalCandidates: candidates.length, highConfidence: candidates.length, byKind: {}, templateReadyKinds: [] },
candidates,
};
}
// A 3-track grid: the main card spans 2 tracks (`grid-column: 1 / 3`) and a photo occupies track 3.
// The item-count heuristic groups the two items into one row → columns = 2, which disagrees with the
// computed 3 tracks.
function spanningGridIr(): IR {
const threeTracks = "284px 284px 284px";
const card = node("n2", "article", {
768: { gridColumnStart: "1", gridColumnEnd: "3" },
1280: { gridColumnStart: "1", gridColumnEnd: "3" },
});
const photo = node("n3", "img", {
768: { gridColumnStart: "3", gridColumnEnd: "4" },
1280: { gridColumnStart: "3", gridColumnEnd: "4" },
});
const parent = node("n1", "div", {
768: { display: "grid", gridTemplateColumns: threeTracks },
1280: { display: "grid", gridTemplateColumns: threeTracks },
}, [card, photo]);
return irWith(node("n0", "section", {}, [parent]));
}
describe("recipe grid geometry: computed tracks/spans are ground truth", () => {
it("does not override a 3-track grid with a heuristic 2-column plan (span-2 item present)", () => {
const ir = spanningGridIr();
const c = candidate({ itemParentCid: "n1", repeatedItems: [
{ cid: "n2", tag: "article", textSample: "", mediaCount: 1, headingCount: 1, bbox: { x: 0, y: 0, width: 568, height: 300 } },
{ cid: "n3", tag: "img", textSample: "", mediaCount: 1, headingCount: 0, bbox: { x: 584, y: 0, width: 284, height: 300 } },
] });
const clean = recipeResponsiveClassCleaner(ir, report([c]), { tailwind: true });
// The Tailwind emitter has already put the authored 3-track grid on the container className.
const containerIn = "grid grid-cols-3 gap-4";
const containerOut = clean("n1", containerIn)!.split(/\s+/);
assert.ok(containerOut.includes("grid-cols-3"), "authored 3-track grid-cols-3 survives");
assert.ok(!containerOut.some((t) => /(?:^|:)grid-cols-2$/.test(t)), "no synthesized grid-cols-2 override");
// No responsive column-plan tokens are appended (the plan was rejected as untrustworthy).
assert.ok(!containerOut.some((t) => /^(?:md|lg|2xl):grid-cols-/.test(t)), "no responsive grid-cols plan appended");
// The span-2 item keeps its authored column span (emitter tokens pass through untouched).
const itemOut = clean("n2", "col-start-1 col-end-3 flex flex-col")!;
assert.equal(itemOut, "col-start-1 col-end-3 flex flex-col", "span-2 item className is unchanged");
});
it("still re-flows a genuinely uniform grid whose computed tracks match the heuristic", () => {
// 2-track grid at 768, 3-track at 1280, no spanning items → heuristic agrees with computed.
const item = (id: string): IRNode => node(id, "article", {
768: { gridColumnStart: "auto", gridColumnEnd: "auto" },
1280: { gridColumnStart: "auto", gridColumnEnd: "auto" },
});
const parent = node("n1", "div", {
768: { display: "grid", gridTemplateColumns: "300px 300px" },
1280: { display: "grid", gridTemplateColumns: "284px 284px 284px" },
}, [item("n2"), item("n3"), item("n4")]);
const ir = irWith(node("n0", "section", {}, [parent]));
const c = candidate({
itemParentCid: "n1",
itemCount: 3,
responsiveRegimes: [regime(768, 2, 3), regime(1280, 3, 3)],
repeatedItems: [
{ cid: "n2", tag: "article", textSample: "", mediaCount: 1, headingCount: 1, bbox: { x: 0, y: 0, width: 300, height: 300 } },
{ cid: "n3", tag: "article", textSample: "", mediaCount: 1, headingCount: 1, bbox: { x: 316, y: 0, width: 300, height: 300 } },
{ cid: "n4", tag: "article", textSample: "", mediaCount: 1, headingCount: 1, bbox: { x: 0, y: 316, width: 300, height: 300 } },
],
});
const clean = recipeResponsiveClassCleaner(ir, report([c]), { tailwind: true });
const out = clean("n1", "grid grid-cols-2 gap-4")!.split(/\s+/);
// Geometry agreed → the responsive plan is applied: base 2 columns, lg:3 at the wider viewport.
assert.ok(out.includes("grid-cols-2"), "base column count applied");
assert.ok(out.includes("lg:grid-cols-3"), "responsive column bump applied");
});
});
+31 -1
View File
@@ -1,6 +1,6 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { declToUtil } from "../src/generate/tailwind.js";
import { declToUtil, snapBase } from "../src/generate/tailwind.js";
// 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
@@ -41,3 +41,33 @@ describe("declToUtil zero values", () => {
assert.equal(declToUtil("letter-spacing", "-0.5px"), "tracking-[-0.5px]");
});
});
// BUG B — the spacing-scale snap must only fire when a value lands ESSENTIALLY ON a step (≤0.25px).
// The scale is 2px-granular (`p-0.5`=2px, `p-1.5`=6px), so 3.5px is BETWEEN steps — snapping it up to
// p-1 (4px) adds +0.5px per side, which accumulates across a fixed-width flex row until items overflow
// and wrap. On-step values (2px→p-0.5, 4px→p-1) still snap.
describe("snapBase spacing-scale snapping", () => {
it("keeps a between-steps value arbitrary (3.5px does NOT snap to p-1)", () => {
assert.equal(snapBase("p-[3.5px]"), "p-[3.5px]");
});
it("does not snap the same sub-step delta on other spacing props", () => {
assert.equal(snapBase("pl-[3.5px]"), "pl-[3.5px]");
assert.equal(snapBase("gap-[3.5px]"), "gap-[3.5px]");
assert.equal(snapBase("mt-[13.5px]"), "mt-[13.5px]"); // between p-3 (12px) and p-3.5 (14px)
});
it("still snaps a value that sits on a 0.5-step (2px → p-0.5, 6px → p-1.5)", () => {
assert.equal(snapBase("p-[2px]"), "p-0.5");
assert.equal(snapBase("p-[6px]"), "p-1.5");
});
it("still snaps a near-exact on-step value within the tight budget (3.98px → p-1)", () => {
assert.equal(snapBase("p-[3.98px]"), "p-1");
});
it("leaves an already-clean scale utility unchanged and snaps 16px → mx-4", () => {
assert.equal(snapBase("p-1"), "p-1");
assert.equal(snapBase("mx-[16px]"), "mx-4");
});
});
+106
View File
@@ -211,3 +211,109 @@ describe("walker font-metric probe tagging (fix 4)", () => {
assert.ok(!sr.probe, "sr-only accessible text is not a probe");
});
});
describe("walker sizing probe: circular authored-height guard", () => {
let browser: Browser;
let page: Page;
before(async () => {
browser = await chromium.launch();
page = await browser.newPage({ viewport: { width: 1280, height: 720 } });
});
after(async () => {
await browser.close();
});
const capture = async (html: string) => {
await page.setContent(html);
await page.evaluate("globalThis.__name = globalThis.__name || ((fn) => fn);");
return page.evaluate(collectPage);
};
it("keeps an explicit 100vh height (circular hero/fill-child pair)", async () => {
// A hero authored `height:100vh` with a `height:100%` fill child: setting the hero to
// `height:auto` still reproduces its box because the child pins it back, so the raw probe
// would read hAuto:true and the authored 100vh would be dropped — hero collapses to 0.
const snap = await capture(`
<style>
.hero { height: 100vh; display: flex; }
.fill { height: 100%; width: 100%; }
</style>
<section class="hero"><div class="fill"><p>content</p></div></section>`);
const hero = findByClass(snap.root, "hero")!;
assert.ok(hero.sizing, "hero was probed");
assert.equal(hero.sizing!.hAuto, false, "explicit 100vh is not content-sized");
assert.equal(hero.sizing!.hFill, false, "explicit 100vh is authored, not a parent fill");
// Box actually equals the viewport height (720), proving the circular reproduction.
assert.ok(Math.abs(hero.bbox.height - 720) <= 1, "hero rendered at 100vh");
});
it("keeps an explicit px height whose fill child reproduces it", async () => {
const snap = await capture(`
<style>
.section { height: 400px; display: flex; }
.fill { height: 100%; width: 100%; }
</style>
<div class="section"><div class="fill"><p>content</p></div></div>`);
const section = findByClass(snap.root, "section")!;
assert.ok(section.sizing, "section was probed");
assert.equal(section.sizing!.hAuto, false, "explicit 400px is not content-sized");
assert.equal(section.sizing!.hFill, false, "explicit 400px is authored, not a fill");
assert.ok(Math.abs(section.bbox.height - 400) <= 1, "section rendered at 400px");
});
it("keeps an explicit height authored via inline style", async () => {
const snap = await capture(`
<style>.fill { height: 100%; width: 100%; }</style>
<div class="box" style="height: 300px; display: flex;">
<div class="fill"><p>content</p></div>
</div>`);
const box = findByClass(snap.root, "box")!;
assert.ok(box.sizing, "box was probed");
assert.equal(box.sizing!.hAuto, false, "inline explicit height is kept");
assert.equal(box.sizing!.hFill, false, "inline explicit height is not a fill");
});
it("resolves the mutual parent/child pair without disturbing the fill child", async () => {
// The child is a GENUINE fill (height:100%) and must keep hFill:true / hAuto:false so the
// generator emits h-full for it; only the parent's circular verdict is corrected.
const snap = await capture(`
<style>
.outer { height: 500px; display: flex; }
.inner { height: 100%; width: 100%; }
</style>
<div class="outer"><div class="inner"><p>content</p></div></div>`);
const outer = findByClass(snap.root, "outer")!;
const inner = findByClass(snap.root, "inner")!;
assert.equal(outer.sizing!.hAuto, false, "parent explicit height kept");
assert.equal(outer.sizing!.hFill, false, "parent is authored, not a fill");
// The child authors only `height:100%` (a fill), so the explicit-height guard must NOT fire on
// it — hFill stays true so the generator can still emit h-full for the genuine fill child.
assert.equal(inner.sizing!.hFill, true, "child still fills the definite parent");
});
it("still detects a genuinely content-sized (auto) height as hAuto", async () => {
// No authored height anywhere: the box is content-sized and must stay droppable.
const snap = await capture(`
<style>.wrap { display: block; }</style>
<div class="wrap"><p>just some flowing text content</p></div>`);
const wrap = findByClass(snap.root, "wrap")!;
assert.ok(wrap.sizing, "wrap was probed");
assert.equal(wrap.sizing!.hAuto, true, "content-sized height is still auto");
});
it("does not treat a percentage or zero authored height as explicit", async () => {
// height:100% is the FILL case (handled by hFill), and height:0 is not definite; neither
// should trip the explicit-height override.
const snap = await capture(`
<style>
.pct-parent { height: 300px; }
.pct { height: 100%; }
</style>
<div class="pct-parent"><div class="pct"><p>x</p></div></div>`);
const pct = findByClass(snap.root, "pct")!;
assert.ok(pct.sizing, "pct was probed");
// A true fill child: hFill true, hAuto false — untouched by the explicit-height guard.
assert.equal(pct.sizing!.hFill, true, "percentage height stays a fill");
assert.equal(pct.sizing!.hAuto, false, "percentage fill is not content-sized");
});
});