diff --git a/compiler/src/generate/app.ts b/compiler/src/generate/app.ts index e4c2361..13db98b 100644 --- a/compiler/src/generate/app.ts +++ b/compiler/src/generate/app.ts @@ -2496,6 +2496,36 @@ function computedTrackCount(value: string | undefined): number { return value.trim().split(/\s+/).filter(Boolean).length; } +// Resolved px widths of the explicit column tracks in a computed `grid-template-columns` value. The +// capture resolves the property to a used track list (`260px 1020px`), so each token is a definite +// px length. Non-px tokens (`auto`, `min-content`, a leftover `1fr`) make the track set unquantifiable +// and yield null — the plan then cannot claim the tracks are uniform. +function computedTrackWidths(value: string | undefined): number[] | null { + if (!value || value === "none") return null; + const toks = value.trim().split(/\s+/).filter(Boolean); + const out: number[] = []; + for (const t of toks) { + const m = /^(-?\d*\.?\d+)px$/.exec(t); + if (!m) return null; + out.push(parseFloat(m[1]!)); + } + return out.length ? out : null; +} + +// A `grid-cols-N` plan rewrites the container to `repeat(N, minmax(0, 1fr))` — N EQUAL tracks. It is +// only a faithful replacement for the authored template when the computed tracks actually are ~equal +// width. An asymmetric template (`260px 1fr` → a 260/1020 sidebar layout) has the same track COUNT as +// the heuristic column count but a different geometry; collapsing it to equal halves destroys the +// authored layout, so such templates always keep their computed tracks. +function tracksNearEqual(widths: number[]): boolean { + if (widths.length <= 1) return true; + const max = Math.max(...widths); + const min = Math.min(...widths); + if (!(max > 0)) return false; + // Tolerance: a couple of px (gap/rounding) or 2% of the widest track, whichever is larger. + return max - min <= Math.max(1.5, 0.02 * max); +} + // Track span of a grid item from its resolved `grid-column-start`/`grid-column-end`. A spanning // item (`span 2`, or an explicit line pair `1 / 3`) means row-length item-counting under-reports // the real track count, so the column-count heuristic is not trustworthy for this container. @@ -2536,12 +2566,20 @@ export function recipeResponsiveClassCleaner(ir: IR, recipes: RecipeReport | und if (!parent) return false; const itemCids = (c.repeatedItems ?? []).map((i) => i.cid); for (const vp of regimeVps) { - const tracks = computedTrackCount(parent.computedByVp[vp]?.["gridTemplateColumns"]); + const gtc = parent.computedByVp[vp]?.["gridTemplateColumns"]; + const tracks = computedTrackCount(gtc); // Only trust the heuristic where the computed grid actually is a track grid at this viewport. if (tracks === 0) continue; const regime = c.responsiveRegimes.find((r) => r.viewport === vp); const heuristicCols = regime?.columns ?? 0; if (heuristicCols > 0 && heuristicCols !== tracks) return false; + // A `grid-cols-N` plan means N EQUAL tracks. If the computed tracks are asymmetric (a sidebar + // template like `260px 1020px`), the count agrees but the geometry does not — collapsing to + // equal columns would destroy the authored layout. Keep the authored template in that case. + if (tracks >= 2) { + const widths = computedTrackWidths(gtc); + if (!widths || !tracksNearEqual(widths)) return false; + } for (const cid of itemCids) { if (computedColumnSpan(nodeByCid.get(cid)?.computedByVp[vp]) > 1) return false; } diff --git a/compiler/src/generate/tailwind.ts b/compiler/src/generate/tailwind.ts index a300bed..46af588 100644 --- a/compiler/src/generate/tailwind.ts +++ b/compiler/src/generate/tailwind.ts @@ -747,6 +747,25 @@ function sourceVariantActive(variant: string, vp: number): boolean { return true; } +// Tailwind cascade specificity of an ACTIVE variant at a viewport: the effective min-width its media +// query opens at, so a more-specific (higher) breakpoint wins over a lower one. Tailwind emits its +// utilities sorted by breakpoint (min-width ascending), NOT by class-attribute order, so when both +// `md:` and `lg:` apply at 1280 the `lg:` rule wins regardless of which appears first in the class +// string. `min-[Npx]:` embeds N; a bare min breakpoint uses its threshold; `max-*` variants are +// upper-bounded windows that a plain min variant out-specifies, so they score below any min. The +// unprefixed base scores 0. Only meaningful for variants already known active at `vp`. +function sourceVariantSpecificity(variant: string, vp: number): number { + if (!variant) return 0; + let score = 0; + for (const part of variant.split(":").filter(Boolean)) { + const minArb = /^min-\[(\d+)px\]$/.exec(part); + if (minArb) { score = Math.max(score, +minArb[1]!); continue; } + if (part in SOURCE_BP) { score = Math.max(score, SOURCE_BP[part]!); continue; } + // max-* windows are less specific than any min breakpoint; leave score as-is (a min wins). + } + return score; +} + function sourceArbitraryInner(suffix: string): string | null { return suffix.startsWith("[") && suffix.endsWith("]") ? suffix.slice(1, -1) : null; } @@ -813,6 +832,67 @@ function fixedLengthUtilAsPx(util: string, node: IRNode, vp: number): string { return `${prefix}${sm[1]}-[${px}px]`; } +// Tailwind v4's `max-w-*` / `w-*` / `h-*` / `max-h-*` NAMED size scale (the `--container-*` theme +// values), in px at a 16px root. A source built on an OLDER Tailwind authored these names against a +// DIFFERENT scale (e.g. v0–v2 `max-w-md` = 640px vs v4's 448px), so re-emitting the name verbatim +// silently re-sizes the box. The source-intent pass validates the modern px below and falls back to +// the captured computed px when they disagree. +const CONTAINER_NAMED_PX: Record = { + "3xs": 256, "2xs": 288, xs: 320, sm: 384, md: 448, lg: 512, xl: 576, + "2xl": 672, "3xl": 768, "4xl": 896, "5xl": 1024, "6xl": 1152, "7xl": 1280, +}; + +// The px a `w-`/`h-`/`max-w-`/`max-h-` NAMED or numeric-scale suffix resolves to under THIS clone's +// Tailwind v4 (16px root). Covers the `--container-*` names (`md`→448) and the numeric spacing scale +// (`24`→96px, `2.5`→10px). Returns null for relative/keyword/arbitrary suffixes (`full`, `1/2`, +// `screen`, `[…]`, `prose`) — those re-derive from context and are not px-fixed, so they need no +// value validation and are re-emitted verbatim. +function namedLengthPx(suffix: string): number | null { + if (suffix in CONTAINER_NAMED_PX) return CONTAINER_NAMED_PX[suffix]!; + if (suffix === "px") return 1; + if (/^\d+(?:\.\d+)?$/.test(suffix)) return parseFloat(suffix) * 4; // spacing scale: n × 0.25rem + return null; +} + +// A source utility on a length axis whose suffix is a NAMED/numeric-scale token (px-fixed under +// v4) rather than a relative/keyword/arbitrary one. Only these need modern-value validation; e.g. +// `max-w-md`, `w-24`, `h-px` — but not `max-w-full`, `w-1/2`, `h-[4rem]`. +function namedScaleCore(core: string): { axis: "w" | "h" | "max-w" | "max-h"; px: number } | null { + const m = /^(max-w|max-h|w|h)-(.+)$/.exec(core); + if (!m) return null; + const px = namedLengthPx(m[2]!); + return px === null ? null : { axis: m[1] as "w" | "h" | "max-w" | "max-h", px }; +} + +// Captured computed px on the axis at a viewport, when definite (`…px`, not `auto`/`none`). The +// property a length axis constrains: `max-width` / `max-height` / `width` / `height`. +function computedAxisPx(node: IRNode, axis: SourceAxis, vp: number): number | null { + const cs = node.computedByVp[vp]; + if (!cs) return null; + const raw = axis === "max-w" ? cs.maxWidth : axis === "max-h" ? cs.maxHeight + : axis === "w" ? cs.width : axis === "h" ? cs.height : undefined; + if (!raw || raw === "auto" || raw === "none" || !/px$/.test(raw)) return null; + return pf(raw); +} + +// Re-emit a source length utility for one viewport. When the suffix is a NAMED/numeric-scale token +// (px-fixed under v4) and its modern px does NOT match the captured computed px (within tolerance), +// the authored name meant a different length on the source's older Tailwind — emit the captured px +// as an arbitrary value instead of the mis-resolving name. Fluid/keyword/fixed-arbitrary suffixes +// (and the already-handled fixed w/h path) pass through unchanged. +function namedLengthUtilChecked(util: string, node: IRNode, axis: SourceAxis, vp: number): string { + const m = VARIANT_PREFIX.exec(util); + const prefix = m ? m[1]! : ""; + const core = m ? m[2]! : util; + const named = namedScaleCore(core); + if (!named) return util; + const computed = computedAxisPx(node, axis, vp); + if (computed === null) return util; // no definite computed px to check against — keep authored name + if (Math.abs(named.px - computed) <= Math.max(1.5, 0.02 * computed)) return util; // modern value agrees + const rem = pxToRem(computed); + return `${prefix}${named.axis}-[${rem ?? computed + "px"}]`; +} + function sourceAspectUtility(core: string): string | null { let m = /^aspect-(\d+)\/(\d+)-box$/.exec(core); if (m) return m[1] === m[2] ? "aspect-square" : `aspect-[${m[1]}/${m[2]}]`; @@ -1042,6 +1122,13 @@ function sourceIntentVarCss(node: IRNode, axis: SourceAxis, values: Map>(); + // Per (axis, vp): the cascade specificity of the currently-chosen active token, so a later token in + // the class string can only override an earlier one when it is at least as specific. Tailwind sorts + // its emitted rules by breakpoint (min-width), so when both `md:` and `lg:` apply at a wide viewport + // the `lg:` value wins — regardless of the class-attribute order. Picking the LAST active token + // instead (class-string order) silently takes `md:grid-cols-2` at 1280 for `lg:grid-cols-3 + // md:grid-cols-2`, dropping the desktop column count. + const specAt = new Map>(); for (const tok of parseSourceClass(node.srcClass)) { const parsed = sourceAxisForCore(tok.core); if (!parsed) continue; @@ -1049,7 +1136,12 @@ function sourceIntentUtilities(node: IRNode, parent: IRNode | undefined, viewpor if (!sourceVariantActive(tok.variant, vp)) continue; let m = perAxis.get(parsed.axis); if (!m) { m = new Map(); perAxis.set(parsed.axis, m); } - m.set(vp, parsed.utility); + let s = specAt.get(parsed.axis); + if (!s) { s = new Map(); specAt.set(parsed.axis, s); } + const spec = sourceVariantSpecificity(tok.variant, vp); + // `>=` so a later token at EQUAL specificity still wins (matches source order for same-breakpoint + // duplicates), but a lower breakpoint can never displace a higher one already chosen. + if (!m.has(vp) || spec >= (s.get(vp) ?? -1)) { m.set(vp, parsed.utility); s.set(vp, spec); } } } const axes = new Set(); @@ -1081,10 +1173,16 @@ function sourceIntentUtilities(node: IRNode, parent: IRNode | undefined, viewpor } if (!sourceAxisCompatible(node, parent, axis, byVp, viewports)) continue; // A fixed-length w/h axis re-emits the CAPTURED computed px per band (root-font-size independent), - // not the source rem token; every other axis keeps its authored utility verbatim. - const asEmit = (vp: number): string => - (axis === "w" || axis === "h") && isFixedLengthUtil(byVp.get(vp)!) - ? fixedLengthUtilAsPx(byVp.get(vp)!, node, vp) : byVp.get(vp)!; + // not the source rem token. A NAMED/numeric-scale length token (`max-w-md`, `w-24`) is re-emitted + // only when its modern Tailwind-v4 px matches the captured computed px; otherwise the authored + // name meant a different length on the source's older Tailwind and the captured px is emitted as an + // arbitrary value. Every other axis keeps its authored utility verbatim. + const asEmit = (vp: number): string => { + const u = byVp.get(vp)!; + if ((axis === "w" || axis === "h") && isFixedLengthUtil(u)) return fixedLengthUtilAsPx(u, node, vp); + if (axis === "w" || axis === "h" || axis === "max-w" || axis === "max-h") return namedLengthUtilChecked(u, node, axis, vp); + return u; + }; const base = asEmit(canonical); axes.add(axis); if (axis === "aspect") axes.add("grid-rows"); diff --git a/compiler/src/validate/gates.ts b/compiler/src/validate/gates.ts index 533cb13..d191378 100644 --- a/compiler/src/validate/gates.ts +++ b/compiler/src/validate/gates.ts @@ -86,6 +86,32 @@ function hasVisibleElementChild(node: IRNode, vp: number): boolean { return false; } +/** A source text node that the capture painted VISIBLE but the clone rendered HIDDEN at the same + * viewport is a high-signal regression: the words are in the markup yet fall in an invisible gen + * subtree (off-viewport-shifted, banded-hidden, or width-frozen off-screen). Cheap to detect — + * the gen node exists (matched by cid) but reports `visible === false`. Counts DISTINCT source + * cids over the run (a node hidden at several viewports counts once) so the number reads as + * "how many text nodes went dark", not "hidden-node×viewport". Non-blocking: reported as a metric + * only. Pure + input-driven → deterministic. */ +export function countVisibleInCaptureHiddenInClone( + ir: IR, + genSnaps: Record, + viewports: number[], +): number { + const hiddenCids = new Set(); + for (const vp of viewports) { + if (!genSnaps[vp]) continue; + const gen = indexByCid(genSnaps[vp]!); + for (const s of collectSrcNodes(ir, vp)) { + if (!s.visible) continue; + if (normText(s.directText).length === 0) continue; + const g = gen.get(s.node.id); + if (g && !g.visible) hiddenCids.add(s.node.id); + } + } + return hiddenCids.size; +} + // ---------- Pollution gate (stage 2): is the captured page degenerate? ---------- // A clone can pass every structural gate while faithfully reproducing the WRONG // page: an egress/bot wall, a near-empty shell, or a cookie/consent modal that was @@ -278,6 +304,12 @@ export function gate3Dom(ir: IR, genSnaps: Record, viewpor } } + // Non-blocking, high-signal diagnostic: source text the capture painted VISIBLE that the clone + // renders HIDDEN at the same viewport (off-screen-shifted / banded / width-frozen subtree). Does + // NOT gate pass — text presence already covers markup fidelity — but surfaces the "went dark" + // count so a banner/feed row vanishing is legible in the report instead of hiding inside a 99.x. + const textHiddenInClone = countVisibleInCaptureHiddenInClone(ir, genSnaps, viewports); + const matchPct = totalVisible ? matched / totalVisible : 1; const textPct = textTotal ? textPresent / textTotal : 1; const linkPct = linksTotal ? linksOk / linksTotal : 1; @@ -296,6 +328,7 @@ export function gate3Dom(ir: IR, genSnaps: Record, viewpor nodeMatchPct: round4(matchPct), textPresentPct: round4(textPct), linkPct: round4(linkPct), mediaPct: round4(mediaPct), totalVisible, matched, textTotal, textPresent, linksTotal, mediaTotal, inventedText, + textHiddenInClone, }, issues, }; diff --git a/compiler/test/bandingFixes.test.ts b/compiler/test/bandingFixes.test.ts index 8689db5..4a452fa 100644 --- a/compiler/test/bandingFixes.test.ts +++ b/compiler/test/bandingFixes.test.ts @@ -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 = {}; + const bboxByVp: Record = {}; + const visibleByVp: Record = {}; + 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 = { + 375: "343px", 768: "348px 348px", + 1280: "346.66px 346.66px 346.66px", 1920: "346.66px 346.66px 346.66px", + }; + const gridW: Record = { 375: 343, 768: 720, 1280: 1064, 1920: 1064 }; + const mk = (id: string, byVp: Record, w: Record, kids: IRChild[] = [], sc?: string): IRNode => { + const computedByVp: Record = {}; + const bboxByVp: Record = {}; + const visibleByVp: Record = {}; + 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}"`); + }); +}); diff --git a/compiler/test/gates.test.ts b/compiler/test/gates.test.ts index 5dd9258..ef70c2e 100644 --- a/compiler/test/gates.test.ts +++ b/compiler/test/gates.test.ts @@ -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): IRNode => { + const vps = Object.keys(vis).map(Number); + const rec = (v: T): Record => 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), + 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); + }); +}); diff --git a/compiler/test/recipeGridGeometry.test.ts b/compiler/test/recipeGridGeometry.test.ts index 9fa45e2..30e935c 100644 --- a/compiler/test/recipeGridGeometry.test.ts +++ b/compiler/test/recipeGridGeometry.test.ts @@ -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", {