Grid plan equality guard, named-token px validation, breakpoint-ordered source variants, hidden-text diagnostic
- A grid-cols-N recipe plan may only override the authored template when computed tracks are near-equal - asymmetric sidebar templates (260px 1fr) keep their authored geometry - Source-intent named tokens (max-w-md etc.) re-emit only when their Tailwind-4 value matches the captured computed px; mismatches (older token scales) emit the captured value instead - Per-axis source-variant selection keeps the highest-min-width active variant instead of last-in-class-order, matching real Tailwind cascade (lg:grid-cols-3 no longer loses to a later md:grid-cols-2 at desktop) - latent since the initial commit - New non-blocking textHiddenInClone metric in the dom gate: counts capture-visible text that renders hidden in the clone, so silent disappearances surface in reports 435 tests pass (10 new), typecheck clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
f16c3aa4e8
commit
5b60ded62d
@@ -136,3 +136,97 @@ describe("buildTailwind recovers authored banded fixed height as computed px (FI
|
||||
assert.ok(/h-\[60px\]|h-\[3\.75rem\]/.test(cls), `mobile height (60px/3.75rem@16root) must appear, got class: "${cls}"`);
|
||||
});
|
||||
});
|
||||
|
||||
// FIX 2 (source-intent named-token validation) — a source built on an OLDER Tailwind authored
|
||||
// `max-w-md`, whose scale resolved `md` to 640px; the clone's Tailwind v4 resolves `max-w-md` to
|
||||
// 448px. Re-emitting the name verbatim silently re-sizes the box (640→448). The source-intent pass
|
||||
// must validate the modern named-token px against the captured computed px and, on mismatch, emit
|
||||
// the captured px as an arbitrary value instead of the mis-resolving name.
|
||||
describe("buildTailwind validates named length tokens against captured px (FIX 2)", () => {
|
||||
// A max-width node whose computed maxWidth is `capPx` at every viewport, carrying `srcClass`.
|
||||
function maxWNode(capPx: number, srcClass: string): IRNode {
|
||||
const computedByVp: Record<number, StyleMap> = {};
|
||||
const bboxByVp: Record<number, BBox> = {};
|
||||
const visibleByVp: Record<number, boolean> = {};
|
||||
for (const vp of VPS) {
|
||||
computedByVp[vp] = computed({ maxWidth: `${capPx}px` });
|
||||
bboxByVp[vp] = { x: 0, y: 0, width: Math.min(vp, capPx), height: 100 };
|
||||
visibleByVp[vp] = true;
|
||||
}
|
||||
const n: IRNode = { id: "n1", tag: "div", attrs: {}, visibleByVp, bboxByVp, computedByVp, children: [{ text: "col" } as IRChild] };
|
||||
n.srcClass = srcClass;
|
||||
return n;
|
||||
}
|
||||
|
||||
it("re-emits max-w-md as the captured px when the modern token value disagrees (640 ≠ 448)", () => {
|
||||
// Modern max-w-md = 28rem = 448px, but the source computed a 640px cap → arbitrary px, not the name.
|
||||
const el = maxWNode(640, "max-w-md");
|
||||
const root = pvNode("n0", "body", { 375: {}, 1280: {} }, [el]);
|
||||
const tw = buildTailwind(irWith(root), new Map());
|
||||
const cls = tw.classOf.get("n1") || "";
|
||||
assert.ok(!/\bmax-w-md\b/.test(cls), `mis-resolving max-w-md name must not survive, got class: "${cls}"`);
|
||||
assert.ok(/max-w-\[640px\]|max-w-\[40rem\]/.test(cls), `captured 640px cap must be emitted arbitrarily, got class: "${cls}"`);
|
||||
});
|
||||
|
||||
it("keeps max-w-lg verbatim when the modern token value matches the captured px (512 == 512)", () => {
|
||||
// Modern max-w-lg = 32rem = 512px, and the source computed a 512px cap → the authored name is faithful.
|
||||
const el = maxWNode(512, "max-w-lg");
|
||||
const root = pvNode("n0", "body", { 375: {}, 1280: {} }, [el]);
|
||||
const tw = buildTailwind(irWith(root), new Map());
|
||||
const cls = tw.classOf.get("n1") || "";
|
||||
assert.ok(/\bmax-w-lg\b/.test(cls), `matching max-w-lg name must survive, got class: "${cls}"`);
|
||||
assert.ok(!/max-w-\[/.test(cls), `no arbitrary max-w should be emitted when the name matches, got class: "${cls}"`);
|
||||
});
|
||||
});
|
||||
|
||||
// FIX 3 (source-intent breakpoint specificity) — an authored gallery grid
|
||||
// `grid-cols-1 md:grid-cols-2 lg:grid-cols-3` (both `md:` and `lg:` are min-width variants, so BOTH
|
||||
// are active at 1280). Tailwind emits its rules sorted by breakpoint, so `lg:` wins there — the base
|
||||
// (canonical=1280) is grid-cols-3. Choosing the LAST active token in class-attribute order instead
|
||||
// (`… lg:grid-cols-3 md:grid-cols-2`) wrongly takes md's grid-cols-2 as base and drops the desktop
|
||||
// grid-cols-3 band entirely. The pass must pick the highest-min-width active variant per viewport.
|
||||
describe("buildTailwind source-intent picks the highest active breakpoint per viewport (FIX 3)", () => {
|
||||
const GVPS = [375, 768, 1280, 1920];
|
||||
function gridIr(srcClass: string): IR {
|
||||
const gtc: Record<number, string> = {
|
||||
375: "343px", 768: "348px 348px",
|
||||
1280: "346.66px 346.66px 346.66px", 1920: "346.66px 346.66px 346.66px",
|
||||
};
|
||||
const gridW: Record<number, number> = { 375: 343, 768: 720, 1280: 1064, 1920: 1064 };
|
||||
const mk = (id: string, byVp: Record<number, StyleMap>, w: Record<number, number>, kids: IRChild[] = [], sc?: string): IRNode => {
|
||||
const computedByVp: Record<number, StyleMap> = {};
|
||||
const bboxByVp: Record<number, BBox> = {};
|
||||
const visibleByVp: Record<number, boolean> = {};
|
||||
for (const vp of GVPS) {
|
||||
computedByVp[vp] = computed(byVp[vp]);
|
||||
bboxByVp[vp] = { x: 0, y: 0, width: w[vp]!, height: 100 };
|
||||
visibleByVp[vp] = true;
|
||||
}
|
||||
const n: IRNode = { id, tag: "div", attrs: {}, visibleByVp, bboxByVp, computedByVp, children: kids };
|
||||
if (sc) n.srcClass = sc;
|
||||
return n;
|
||||
};
|
||||
const items = [0, 1, 2].map((i) => mk(`c${i}`, Object.fromEntries(GVPS.map((vp) => [vp, { display: "block" }])), Object.fromEntries(GVPS.map((vp) => [vp, 340])), [{ text: "x" } as IRChild]));
|
||||
const grid = mk("n123", Object.fromEntries(GVPS.map((vp) => [vp, { display: "grid", gridTemplateColumns: gtc[vp], columnGap: "24px", rowGap: "24px", gap: "24px" }])), gridW, items, srcClass);
|
||||
const root = mk("n0", Object.fromEntries(GVPS.map((vp) => [vp, {}])), Object.fromEntries(GVPS.map((vp) => [vp, vp])), [grid]);
|
||||
return {
|
||||
doc: {
|
||||
sourceUrl: "https://example.test/grid", title: "Grid", lang: "en", charset: "UTF-8",
|
||||
metaViewport: "width=device-width, initial-scale=1", viewports: GVPS, sampleViewports: GVPS, canonicalViewport: 1280,
|
||||
perViewport: Object.fromEntries(GVPS.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: 5, keyframes: [],
|
||||
},
|
||||
root,
|
||||
};
|
||||
}
|
||||
|
||||
it("emits base grid-cols-3 (lg wins at 1280) even when md: follows lg: in the class string", () => {
|
||||
const tw = buildTailwind(gridIr("grid grid-cols-1 gap-6 lg:grid-cols-3 md:grid-cols-2"), new Map());
|
||||
const cls = tw.classOf.get("n123") || "";
|
||||
const toks = cls.split(/\s+/);
|
||||
assert.ok(toks.includes("grid-cols-3"), `desktop base must be grid-cols-3 (lg wins at 1280), got class: "${cls}"`);
|
||||
assert.ok(!toks.some((t) => /(?:^|:)grid-cols-2$/.test(t) && !t.includes("md:max-lg:")), `md's grid-cols-2 must not become the base, got class: "${cls}"`);
|
||||
assert.ok(toks.includes("md:max-lg:grid-cols-2"), `tablet band must be grid-cols-2, got class: "${cls}"`);
|
||||
assert.ok(toks.includes("max-md:grid-cols-1"), `mobile band must be grid-cols-1, got class: "${cls}"`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { letterSpacingEquivalent, normHref } from "../src/validate/gates.js";
|
||||
import { letterSpacingEquivalent, normHref, countVisibleInCaptureHiddenInClone } from "../src/validate/gates.js";
|
||||
import type { IR, IRNode } from "../src/normalize/ir.js";
|
||||
import type { PageSnapshot } from "../src/capture/walker.js";
|
||||
|
||||
// FIX 5 — the link gate must not fail a javascript: source href against the clone's sanitized value.
|
||||
// Generation emits an inert `#` for a `javascript:*` href (React blocks the literal), so normHref
|
||||
@@ -54,3 +56,79 @@ describe("gate4 letterSpacing normal ↔ 0px normalization", () => {
|
||||
assert.ok(letterSpacingEquivalent(undefined, "0px"));
|
||||
});
|
||||
});
|
||||
|
||||
// A capture-visible text node that the clone renders hidden (off-screen-shifted / banded /
|
||||
// width-frozen subtree) is the emil-banner / maxbo-feed regression class. The diagnostic counts
|
||||
// DISTINCT source cids whose gen counterpart exists but reports visible:false. Non-blocking; it
|
||||
// only has to be a faithful, deterministic count.
|
||||
describe("countVisibleInCaptureHiddenInClone diagnostic", () => {
|
||||
// Minimal IR text leaf: `text` at every listed vp, visible per `vis`.
|
||||
const leaf = (id: string, text: string, vis: Record<number, boolean>): IRNode => {
|
||||
const vps = Object.keys(vis).map(Number);
|
||||
const rec = <T,>(v: T): Record<number, T> => Object.fromEntries(vps.map((vp) => [vp, v]));
|
||||
return {
|
||||
id, tag: "a", attrs: {},
|
||||
visibleByVp: vis,
|
||||
bboxByVp: rec({ x: 0, y: 0, width: 100, height: 16 }),
|
||||
computedByVp: rec({} as Record<string, string>),
|
||||
children: [{ text }],
|
||||
} as unknown as IRNode;
|
||||
};
|
||||
const makeIR = (leaves: IRNode[]): IR => ({
|
||||
doc: {} as IR["doc"],
|
||||
root: { id: "n0", tag: "body", attrs: {}, visibleByVp: {}, bboxByVp: {}, computedByVp: {}, children: leaves } as unknown as IRNode,
|
||||
});
|
||||
// Minimal clone snapshot: one node per (cid, visible) with matching direct text.
|
||||
const snap = (nodes: Array<{ cid: string; text: string; visible: boolean }>): PageSnapshot => ({
|
||||
root: {
|
||||
tag: "body", attrs: { "data-cid": "n0" }, computed: {}, bbox: { x: 0, y: 0, width: 100, height: 100 }, visible: true,
|
||||
children: nodes.map((n) => ({
|
||||
tag: "a", attrs: { "data-cid": n.cid }, computed: {}, bbox: { x: 0, y: 0, width: 100, height: 16 },
|
||||
visible: n.visible, children: [{ text: n.text }],
|
||||
})),
|
||||
},
|
||||
} as unknown as PageSnapshot);
|
||||
|
||||
it("counts a source-visible text node the clone renders hidden", () => {
|
||||
const ir = makeIR([leaf("n4", "Enrollment open!", { 375: true })]);
|
||||
const gen = { 375: snap([{ cid: "n4", text: "Enrollment open!", visible: false }]) };
|
||||
assert.equal(countVisibleInCaptureHiddenInClone(ir, gen, [375]), 1);
|
||||
});
|
||||
|
||||
it("does NOT count a node visible in both source and clone", () => {
|
||||
const ir = makeIR([leaf("n4", "Enrollment open!", { 375: true })]);
|
||||
const gen = { 375: snap([{ cid: "n4", text: "Enrollment open!", visible: true }]) };
|
||||
assert.equal(countVisibleInCaptureHiddenInClone(ir, gen, [375]), 0);
|
||||
});
|
||||
|
||||
it("does NOT count a node the SOURCE itself hid (faithful hide)", () => {
|
||||
// maxbo n192 @375: source already off-screen (visible:false) — the clone hiding it is correct.
|
||||
const ir = makeIR([leaf("n192", "Avatar: Fire and Ash", { 375: false, 768: true })]);
|
||||
const gen = {
|
||||
375: snap([{ cid: "n192", text: "Avatar: Fire and Ash", visible: false }]),
|
||||
768: snap([{ cid: "n192", text: "Avatar: Fire and Ash", visible: true }]),
|
||||
};
|
||||
assert.equal(countVisibleInCaptureHiddenInClone(ir, gen, [375, 768]), 0);
|
||||
});
|
||||
|
||||
it("counts a node once even when hidden at several viewports (distinct cids)", () => {
|
||||
const ir = makeIR([leaf("n4", "Enrollment open!", { 375: true, 768: true })]);
|
||||
const gen = {
|
||||
375: snap([{ cid: "n4", text: "Enrollment open!", visible: false }]),
|
||||
768: snap([{ cid: "n4", text: "Enrollment open!", visible: false }]),
|
||||
};
|
||||
assert.equal(countVisibleInCaptureHiddenInClone(ir, gen, [375, 768]), 1);
|
||||
});
|
||||
|
||||
it("ignores whitespace-only text nodes", () => {
|
||||
const ir = makeIR([leaf("n5", " ", { 375: true })]);
|
||||
const gen = { 375: snap([{ cid: "n5", text: " ", visible: false }]) };
|
||||
assert.equal(countVisibleInCaptureHiddenInClone(ir, gen, [375]), 0);
|
||||
});
|
||||
|
||||
it("does not count when the clone has no node for the cid (a different miss)", () => {
|
||||
const ir = makeIR([leaf("n4", "Enrollment open!", { 375: true })]);
|
||||
const gen = { 375: snap([]) };
|
||||
assert.equal(countVisibleInCaptureHiddenInClone(ir, gen, [375]), 0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -127,6 +127,42 @@ describe("recipe grid geometry: computed tracks/spans are ground truth", () => {
|
||||
assert.equal(itemOut, "col-start-1 col-end-3 flex flex-col", "span-2 item className is unchanged");
|
||||
});
|
||||
|
||||
it("does not override an ASYMMETRIC 2-track sidebar grid with a grid-cols-2 plan", () => {
|
||||
// A sidebar layout: `grid-template-columns: 260px 1020px` (authored `260px 1fr`). The item-count
|
||||
// heuristic sees 2 items in one row → 2 columns, which agrees with the 2 computed tracks by COUNT.
|
||||
// But `grid-cols-2` = two EQUAL 640px tracks, which destroys the 260/1020 geometry. The plan must be
|
||||
// rejected and the authored template kept.
|
||||
const sidebar = node("n2", "aside", {
|
||||
768: { gridColumnStart: "auto", gridColumnEnd: "auto" },
|
||||
1280: { gridColumnStart: "auto", gridColumnEnd: "auto" },
|
||||
});
|
||||
const main = node("n3", "div", {
|
||||
768: { gridColumnStart: "auto", gridColumnEnd: "auto" },
|
||||
1280: { gridColumnStart: "auto", gridColumnEnd: "auto" },
|
||||
});
|
||||
const parent = node("n1", "div", {
|
||||
768: { display: "grid", gridTemplateColumns: "220px 500px" },
|
||||
1280: { display: "grid", gridTemplateColumns: "260px 1020px" },
|
||||
}, [sidebar, main]);
|
||||
const ir = irWith(node("n0", "section", {}, [parent]));
|
||||
const c = candidate({
|
||||
itemParentCid: "n1",
|
||||
itemCount: 2,
|
||||
responsiveRegimes: [regime(768, 2, 2), regime(1280, 2, 2)],
|
||||
repeatedItems: [
|
||||
{ cid: "n2", tag: "aside", textSample: "", mediaCount: 0, headingCount: 0, bbox: { x: 0, y: 0, width: 260, height: 300 } },
|
||||
{ cid: "n3", tag: "div", textSample: "", mediaCount: 0, headingCount: 1, bbox: { x: 276, y: 0, width: 1020, height: 300 } },
|
||||
],
|
||||
});
|
||||
const clean = recipeResponsiveClassCleaner(ir, report([c]), { tailwind: true });
|
||||
// The emitter baked the authored asymmetric template as an arbitrary grid-template-columns utility.
|
||||
const containerIn = "grid grid-cols-[260px_1020px] gap-6";
|
||||
const out = clean("n1", containerIn)!.split(/\s+/);
|
||||
assert.ok(out.includes("grid-cols-[260px_1020px]"), `authored asymmetric template must survive, got: ${out.join(" ")}`);
|
||||
assert.ok(!out.some((t) => /(?:^|:)grid-cols-2$/.test(t)), `no equal-halves grid-cols-2 override, got: ${out.join(" ")}`);
|
||||
assert.ok(!out.some((t) => /^(?:md|lg|2xl):grid-cols-/.test(t)), `no responsive grid-cols plan appended, got: ${out.join(" ")}`);
|
||||
});
|
||||
|
||||
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", {
|
||||
|
||||
Reference in New Issue
Block a user