Merge pull request #16 from ion-design/more-fidelity-upgrades

Fidelity campaign: capture integrity, emission geometry, honest validation
This commit is contained in:
Samraaj Bath
2026-07-04 13:05:53 -07:00
committed by GitHub
55 changed files with 10187 additions and 899 deletions
+633 -57
View File
@@ -14,6 +14,7 @@ import {
import { discoverBreakpoints } from "./breakpoints.js"; import { discoverBreakpoints } from "./breakpoints.js";
import { writeJSON, writeJSONCompact, writeBytes, ensureDir } from "../util/fsx.js"; import { writeJSON, writeJSONCompact, writeBytes, ensureDir } from "../util/fsx.js";
import { sha1_12, round } from "../util/canonical.js"; import { sha1_12, round } from "../util/canonical.js";
import { isZipArchive, extractDotLottieJson } from "./dotlottie.js";
export const REQUIRED_VIEWPORTS = [375, 768, 1280, 1920] as const; export const REQUIRED_VIEWPORTS = [375, 768, 1280, 1920] as const;
// The dense width set captured for SIZE INFERENCE: a node sampled at 9 widths reveals its sizing // The dense width set captured for SIZE INFERENCE: a node sampled at 9 widths reveals its sizing
@@ -115,13 +116,19 @@ export function isRetryableAssetFailure(type: string, status: number | null): bo
return status >= 500 || status === 429; return status >= 500 || status === 429;
} }
function extFromUrl(url: string): string { // Bound on a preserved file extension. Real extensions are short, but a hard 5-char cap
// silently truncates legitimate ones (`.lottie` → `.lotti`, `.webmanifest` → `.webma`),
// which then mis-materializes the asset. Keep the guard generous enough for the longest
// real extensions and reject anything absurdly long (a dotted path segment, not an ext).
const MAX_EXT_LEN = 12;
export function extFromUrl(url: string): string {
try { try {
const p = new URL(url).pathname; const p = new URL(url).pathname;
const dot = p.lastIndexOf("."); const dot = p.lastIndexOf(".");
if (dot >= 0 && dot > p.lastIndexOf("/")) { if (dot >= 0 && dot > p.lastIndexOf("/")) {
const ext = p.slice(dot + 1).toLowerCase().slice(0, 5); const ext = p.slice(dot + 1).toLowerCase();
if (/^[a-z0-9]+$/.test(ext)) return ext; if (ext.length <= MAX_EXT_LEN && /^[a-z0-9]+$/.test(ext)) return ext;
} }
} catch { /* ignore */ } } catch { /* ignore */ }
return ""; return "";
@@ -175,6 +182,83 @@ export function looksLikeVideoFile(bytes: Buffer): boolean {
return false; return false;
} }
/** Container-magic check for accepted font bytes, mirroring `looksLikeVideoFile`. A router that
* answers 200+HTML for an unknown `/media/*` path (common on SPA hosts) otherwise gets stored as
* a `.woff2`; the browser rejects it as a font and the whole page falls through to a system
* fallback. Accept only the real font-container signatures — woff2 (`wOF2`), woff (`wOFF`),
* OpenType/CFF (`OTTO`), TrueType (`\x00\x01\x00\x00` or `true`/`ttcf`), and EOT (the version
* header at bytes 8..11 is `\x01\x00\x01\x00`/`\x02\x00\x01\x00`, so key EOT off its unique
* 0x504C signature at offset 34) — and explicitly reject an HTML/text body. */
export function looksLikeFontFile(bytes: Buffer): boolean {
if (bytes.length < 4) return false;
const b0 = bytes[0]!, b1 = bytes[1]!, b2 = bytes[2]!, b3 = bytes[3]!;
// A leading `<` (`<!DOCTYPE`, `<html`, `<?xml`, `<svg`) is never a binary font container.
if (b0 === 0x3c) return false;
const tag = bytes.subarray(0, 4).toString("latin1");
if (tag === "wOF2" || tag === "wOFF") return true; // woff2 / woff
if (tag === "OTTO" || tag === "true" || tag === "ttcf") return true; // CFF OpenType / TrueType / TrueType collection
if (b0 === 0x00 && b1 === 0x01 && b2 === 0x00 && b3 === 0x00) return true; // TrueType sfnt (\x00\x01\x00\x00)
// EOT: a little-endian byte count precedes a version/flags header; its stable marker is the
// 0x504C ("LP") magic at byte offset 34.
if (bytes.length >= 36 && bytes[34] === 0x4c && bytes[35] === 0x50) return true;
return false;
}
/**
* A `<source>` candidate for the HTML media resource-selection algorithm. `media`/`type` are the
* raw attribute strings (null/undefined when absent, matching a missing attribute).
*/
export interface VideoSourceCandidate {
media?: string | null;
type?: string | null;
}
/**
* Pure re-implementation of the source-selection step of the HTML media resource-selection
* algorithm: return the index of the FIRST `<source>` (document order) that is eligible NOW, or -1
* when none is. A source is eligible when its `media` query matches (a missing/empty media matches
* unconditionally) AND the UA can play its `type` (a missing/empty type is not a disqualifier —
* `canPlayType` is only consulted when a type is present).
*
* The predicates are injected so this is testable in Node (the in-page caller passes the real
* `matchMedia`/`canPlayType`). Kept deterministic: no state, first-match wins in document order.
*/
export function selectVideoSourceIndex(
sources: VideoSourceCandidate[],
mediaMatches: (media: string) => boolean,
canPlay: (type: string) => boolean,
): number {
for (let i = 0; i < sources.length; i++) {
const s = sources[i]!;
const media = (s.media ?? "").trim();
if (media && !mediaMatches(media)) continue;
const type = (s.type ?? "").trim();
if (type && !canPlay(type)) continue;
return i;
}
return -1;
}
/**
* Pure decision for the per-video seek in normalizeVideoTime, extracted so the reloaded-set /
* seek-target branch is unit-testable in Node (the in-page loop applies the same rule to real
* elements). Returns the seek to perform after the re-selection reload pass, or null to fast-skip.
*
* - A `reloaded` video sits at t=0 with the HTML element's show-poster flag freshly set. Seeking to
* 0 would be a no-op that fires no `seeked` and leaves the poster showing, so force a genuine seek
* to a tiny epsilon to clear the flag and paint the new source's frame 0.
* - A non-reloaded video already at t≈0 needs nothing (skip). Otherwise seek it back to 0.
*/
export function planVideoSeek(
reloaded: boolean,
currentTime: number,
): { target: number } | null {
const EPSILON = 1e-4;
if (reloaded) return { target: EPSILON };
if (Math.abs(currentTime) < 1e-3) return null;
return { target: 0 };
}
async function autoScroll(page: import("playwright").Page, vpHeight: number): Promise<void> { async function autoScroll(page: import("playwright").Page, vpHeight: number): Promise<void> {
// Scroll through the page to trigger lazy-loaded images/backgrounds, then return // Scroll through the page to trigger lazy-loaded images/backgrounds, then return
// to the top so document-coordinate bboxes are measured from a settled layout. // to the top so document-coordinate bboxes are measured from a settled layout.
@@ -209,6 +293,41 @@ async function settle(page: import("playwright").Page, maxMs = 2500): Promise<vo
await page.waitForTimeout(250); await page.waitForTimeout(250);
} }
/**
* Stage 2 — scroll-state reset immediately before a per-viewport snapshot. The motion /
* dwell-scroll / carousel / element-screenshot passes above all leave the page scrolled or
* mid-transition; scroll-linked styles (Webflow scroll-state transforms, position:sticky
* offsets) then get baked into the captured computed styles for THAT viewport only, so a
* scroll-driven translateY leaks into one band and cascades. Reset window + inner scrollers
* to the top, wait for scroll-linked effects to settle across a few rAF ticks plus a short
* quiescence window, THEN let the caller snapshot. Bounded and deterministic.
*/
async function settleScrollTopBeforeSnapshot(page: import("playwright").Page): Promise<void> {
try {
await Promise.race([
page.evaluate(async () => {
const raf = () => new Promise<void>((r) => requestAnimationFrame(() => r()));
const resetAll = () => {
window.scrollTo(0, 0);
for (const el of Array.from(document.querySelectorAll("*"))) {
if (el.scrollLeft) el.scrollLeft = 0;
if (el.scrollTop) el.scrollTop = 0;
}
};
resetAll();
// Let scroll-linked effects (scroll-state classes, sticky offsets, JS scroll handlers)
// recompute at scroll 0 over several frames, re-asserting the top position each tick in
// case a handler nudged it, then hold briefly for quiescence.
for (let i = 0; i < 6; i++) { await raf(); resetAll(); }
await new Promise<void>((r) => setTimeout(r, 120));
resetAll();
await raf();
}),
new Promise<void>((r) => setTimeout(r, 4000)),
]);
} catch { /* ignore — a best-effort reset never blocks the snapshot */ }
}
export type DismissResult = { dismissed: string[]; overlaysRemaining: number; removed: number; blocking: boolean }; export type DismissResult = { dismissed: string[]; overlaysRemaining: number; removed: number; blocking: boolean };
/** /**
@@ -219,11 +338,29 @@ export type DismissResult = { dismissed: string[]; overlaysRemaining: number; re
* Removal of a stuck overlay happens later (finalizeOverlays) AFTER a settle, so a * Removal of a stuck overlay happens later (finalizeOverlays) AFTER a settle, so a
* just-clicked dialog has time to close and unlock scrolling before we judge it. * just-clicked dialog has time to close and unlock scrolling before we judge it.
*/ */
async function clickDismiss(page: import("playwright").Page): Promise<string[]> { /**
try { * In-page dismissal click pass (exported for fixture tests). Runs in ONE document — the main
return await Promise.race([ * page OR a cross-origin frame (Attentive/Recart/Klaviyo creatives host their Decline/× inside
page.evaluate(() => { * an iframe, so the caller runs this in every `page.frames()`). Traverses open shadow roots
* (Recart mounts its popup in a shadow tree) when scanning both containers and buttons.
*/
export function clickDismissInPage(): string[] {
const dismissed: string[] = []; const dismissed: string[] = [];
// Deep collector across open shadow roots.
const deepEls = (root: ParentNode, sel: string): Element[] => {
const out: Element[] = [];
const walk = (r: ParentNode): void => {
let hits: Element[] = [];
try { hits = Array.from(r.querySelectorAll(sel)); } catch { /* invalid selector */ }
out.push(...hits);
for (const el of Array.from(r.querySelectorAll("*"))) {
const sr = (el as HTMLElement).shadowRoot;
if (sr) walk(sr);
}
};
walk(root);
return out;
};
const vis = (el: Element): boolean => { const vis = (el: Element): boolean => {
const cs = getComputedStyle(el); const cs = getComputedStyle(el);
if (cs.display === "none" || cs.visibility === "hidden" || parseFloat(cs.opacity || "1") === 0) return false; if (cs.display === "none" || cs.visibility === "hidden" || parseFloat(cs.opacity || "1") === 0) return false;
@@ -243,40 +380,60 @@ async function clickDismiss(page: import("playwright").Page): Promise<string[]>
".cc-allow", ".cookie-consent-accept", "#hs-eu-confirmation-button", "#gdpr-consent-tool-wrapper button", ".cc-allow", ".cookie-consent-accept", "#hs-eu-confirmation-button", "#gdpr-consent-tool-wrapper button",
]; ];
for (const sel of KNOWN) { for (const sel of KNOWN) {
try { for (const el of deepEls(document, sel)) {
for (const el of Array.from(document.querySelectorAll(sel))) {
if (vis(el)) { click(el); dismissed.push(sel); break; } if (vis(el)) { click(el); dismissed.push(sel); break; }
} }
} catch { /* invalid selector in this browser */ }
} }
// 2) Scoped text-button pass: only inside overlay-ish containers (dialogs, // 2) Scoped text-button pass: only inside overlay-ish containers (dialogs, or id/class naming
// or id/class naming cookie/consent/modal/popup/newsletter) so we never // cookie/consent/modal/popup/newsletter/overlay/ccpa/privacy) so we never click an ordinary
// click an ordinary page button. // page button. ACCEPT now also covers email-capture DECLINE affordances ("decline",
// "no thanks", "not now", …) — a popup's own opt-out is the surest deterministic close.
const ACCEPT = new Set([ const ACCEPT = new Set([
"accept", "accept all", "accept all cookies", "accept cookies", "accept & close", "accept", "accept all", "accept all cookies", "accept cookies", "accept & close",
"i accept", "i agree", "agree", "agree and continue", "allow all", "allow cookies", "i accept", "i agree", "agree", "agree and continue", "allow all", "allow cookies",
"allow all cookies", "got it", "ok", "okay", "continue", "no thanks", "no, thanks", "allow all cookies", "got it", "ok", "okay", "continue", "no thanks", "no, thanks",
"dismiss", "close", "got it!", "understood", "yes, i agree", "dismiss", "close", "got it!", "understood", "yes, i agree",
"decline", "no, thank you", "not now", "maybe later", "no thank you", "skip", "reject all",
]); ]);
const containerSel = const containerSel =
"[role='dialog'],[aria-modal='true'],[id*='cookie' i],[class*='cookie' i],[id*='consent' i]," + "[role='dialog'],[aria-modal='true'],[id*='cookie' i],[class*='cookie' i],[id*='consent' i]," +
"[class*='consent' i],[class*='gdpr' i],[id*='gdpr' i],[class*='modal' i],[class*='popup' i]," + "[class*='consent' i],[class*='gdpr' i],[id*='gdpr' i],[class*='modal' i],[class*='popup' i]," +
"[class*='newsletter' i],[class*='interstitial' i]"; "[class*='newsletter' i],[class*='interstitial' i],[class*='overlay' i],[id*='overlay' i]," +
let containers: Element[] = []; "[class*='ccpa' i],[id*='ccpa' i],[class*='privacy' i],[id*='pop' i]";
try { containers = Array.from(document.querySelectorAll(containerSel)); } catch { /* ignore */ } const containers = deepEls(document, containerSel);
for (const c of containers) { for (const c of containers) {
if (!vis(c)) continue; if (!vis(c)) continue;
const btns = Array.from(c.querySelectorAll("button,[role='button'],a,input[type='button'],input[type='submit']")); // Text/value-matched accept/decline buttons.
const btns = deepEls(c, "button,[role='button'],a,input[type='button'],input[type='submit']");
let clicked = false;
for (const b of btns) { for (const b of btns) {
const t = (b.textContent || (b as HTMLInputElement).value || "").replace(/\s+/g, " ").trim().toLowerCase(); const t = (b.textContent || (b as HTMLInputElement).value || "").replace(/\s+/g, " ").trim().toLowerCase();
if (t && ACCEPT.has(t) && vis(b)) { click(b); dismissed.push("text:" + t); break; } if (t && ACCEPT.has(t) && vis(b)) { click(b); dismissed.push("text:" + t); clicked = true; break; }
}
if (clicked) continue;
// aria-label / title close affordance (icon-only ×, no text content to match).
const closers = deepEls(c, "[aria-label*='close' i],[aria-label*='dismiss' i],[title*='close' i],button.close,.close-button,[class*='close' i][role='button']");
for (const x of closers) {
if (vis(x)) { click(x); dismissed.push("close:" + ((x.getAttribute("aria-label") || x.getAttribute("title") || "x")).slice(0, 24)); break; }
} }
} }
return dismissed; return dismissed;
}), }
async function clickDismiss(page: import("playwright").Page): Promise<string[]> {
// Run the click pass in the main document AND every frame — cross-origin popup creatives
// (Attentive/Recart/…) host their Decline/close controls inside an iframe that the top
// document cannot reach, but Playwright can evaluate inside each frame from Node.
const runOne = (frame: import("playwright").Frame): Promise<string[]> =>
Promise.race([
frame.evaluate(clickDismissInPage).catch(() => [] as string[]),
new Promise<string[]>((res) => setTimeout(() => res([]), 6000)), new Promise<string[]>((res) => setTimeout(() => res([]), 6000)),
]); ]);
try {
const frames = page.frames();
const results = await Promise.all(frames.map((f) => runOne(f).catch(() => [] as string[])));
return results.flat();
} catch { } catch {
return []; return [];
} }
@@ -292,57 +449,168 @@ async function clickDismiss(page: import("playwright").Page): Promise<string[]>
* sticky chrome is never stripped. Reports `blocking` = a scroll-locking overlay we * sticky chrome is never stripped. Reports `blocking` = a scroll-locking overlay we
* could not clear (the pollution gate keys off this, not mere overlay presence). * could not clear (the pollution gate keys off this, not mere overlay presence).
*/ */
async function finalizeOverlays(page: import("playwright").Page): Promise<{ overlaysRemaining: number; removed: number; blocking: boolean; removedLabels: string[] }> { export type FinalizeOverlaysResult = { overlaysRemaining: number; removed: number; blocking: boolean; removedLabels: string[] };
try {
return await Promise.race([ /**
page.evaluate(() => { * In-page overlay finalizer (exported so fixture tests can page.evaluate it directly).
* Detects any full-viewport, fixed/sticky BLOCKING layer still present and — when the page
* is scroll-locked (a state legit pages never enter) — removes it. Key robustness rules:
*
* (a) EFFECTIVE z-index: an element with `z-index:auto` inherits its stacking position from
* the nearest positioned/opacity/transform ancestor that establishes a stacking context.
* A vendor popup iframe is z:auto but sits inside a z=INT_MAX wrapper, so per-element
* `parseInt("auto")` under-reads it — resolve z through the ancestor chain instead.
* (b) LOCK relaxes the z gate: on a scroll-locked page, ANY fixed/sticky full-viewport layer
* is blocking regardless of z (catches a z:auto creative and a z=50 CCPA dialog). Off a
* locked page we keep the z>=100 floor to avoid stripping legit fixed content.
* (c) OVERLAY UNIT: a 0-size (height:0) max-z wrapper whose descendant is a full-viewport
* fixed iframe is ONE overlay — the wrapper carries the z, the iframe carries the pixels.
* Detecting either means removing BOTH (climb from a removable iframe to its high-z
* wrapper ancestor; and treat a max-z 0-size wrapper as an overlay via its full-viewport
* descendant). Neither alone passes the area+z gate, so they must be grouped.
* (d) LOUD FAILURE: if the lock persists after removal (or was locked with nothing detected),
* report blocking=true so the pollution gate fails rather than shipping a polluted capture.
*/
export function finalizeOverlaysInPage(): FinalizeOverlaysResult {
const vw = window.innerWidth, vh = window.innerHeight; const vw = window.innerWidth, vh = window.innerHeight;
const bigOverlays = (): HTMLElement[] => { // Deep element walk that descends into OPEN shadow roots. A shadow host reports 0 light-DOM
const out: HTMLElement[] = []; // children, so a popup mounted inside a shadow tree (Recart's `#recart-popup-root`) is invisible
for (const el of Array.from(document.body.querySelectorAll("*"))) { // to a plain `querySelectorAll("*")` — its full-viewport fixed layer would never be detected and
const cs = getComputedStyle(el); // would paint into every screenshot. Traverse `element.shadowRoot` (open only; closed roots are
if (cs.position !== "fixed" && cs.position !== "sticky") continue; // unreachable — the host itself is still caught by the geometry gate below and removed whole).
if (cs.display === "none" || cs.visibility === "hidden" || parseFloat(cs.opacity || "1") === 0) continue; const deepEls = (root: ParentNode): Element[] => {
const r = (el as HTMLElement).getBoundingClientRect(); const out: Element[] = [];
const z = parseInt(cs.zIndex || "0", 10) || 0; const push = (r: ParentNode): void => {
const area = (r.width * r.height) / (vw * vh); for (const el of Array.from(r.querySelectorAll("*"))) {
if (area >= 0.5 && z >= 100 && r.width >= vw * 0.7 && r.height >= vh * 0.5) out.push(el as HTMLElement); out.push(el);
const sr = (el as HTMLElement).shadowRoot;
if (sr) push(sr);
} }
return out.filter((el) => !out.some((o) => o !== el && o.contains(el))); };
push(root);
return out;
}; };
const isLocked = (): boolean => { const isLocked = (): boolean => {
const b = document.body, h = document.documentElement; const b = document.body, h = document.documentElement;
return getComputedStyle(b).overflow === "hidden" || getComputedStyle(h).overflow === "hidden" || const bs = getComputedStyle(b), hs = getComputedStyle(h);
getComputedStyle(b).position === "fixed"; return bs.overflow === "hidden" || hs.overflow === "hidden" ||
bs.overflowY === "hidden" || hs.overflowY === "hidden" ||
bs.position === "fixed" || bs.position === "absolute";
}; };
// A scroll-locked page behind a full-viewport overlay IS a blocking modal by // Effective z-index: climb to the nearest ancestor that both is positioned/creates a stacking
// definition (legit pages don't scroll-lock). So remove ANY such overlay that // context AND reports a numeric z-index. `z-index:auto` on a positioned child paints in its
// isn't page chrome — many modals/drawers carry no consent/modal keyword and // parent stacking context, so the parent's z is the layer's true stacking rank.
// an icon-only close, so a keyword/aria allowlist misses them (ruggable's const effectiveZ = (el: Element): number => {
// z-[1001] drawer). PROTECTED guards real chrome (header/nav/footer) only. let cur: Element | null = el;
let hops = 0;
while (cur && hops < 20) {
const cs = getComputedStyle(cur);
const zi = parseInt(cs.zIndex || "", 10);
const positioned = cs.position !== "static";
if (Number.isFinite(zi) && positioned) return zi;
cur = cur.parentElement;
hops++;
}
return 0;
};
const rectOf = (el: Element) => (el as HTMLElement).getBoundingClientRect();
const fullViewport = (r: DOMRect): boolean => r.width >= vw * 0.7 && r.height >= vh * 0.5 && (r.width * r.height) / (vw * vh) >= 0.5;
const painting = (cs: CSSStyleDeclaration): boolean =>
cs.display !== "none" && cs.visibility !== "hidden" && parseFloat(cs.opacity || "1") !== 0;
const locked = isLocked();
// Candidate blocking layers. A layer qualifies when it is fixed/sticky, painting, and either
// (1) itself full-viewport, or (2) a 0-size/high-z wrapper whose descendant is full-viewport
// (the overlay-unit case — the wrapper holds the z, an inner iframe holds the pixels).
// Full-viewport descendant test that pierces shadow roots (Recart mounts the covering layer
// inside the host's shadow tree, so a light-DOM `querySelectorAll` would find nothing).
const hasFullDescendant = (el: Element): boolean => {
const scope: ParentNode = (el as HTMLElement).shadowRoot ?? el;
return deepEls(scope).some((d) => { const dcs = getComputedStyle(d); return painting(dcs) && (dcs.position === "fixed" || dcs.position === "sticky" || dcs.position === "absolute") && fullViewport(rectOf(d)); });
};
const bigOverlays = (): HTMLElement[] => {
const out: HTMLElement[] = [];
for (const el of deepEls(document.body)) {
const cs = getComputedStyle(el);
const isShadowHost = !!(el as HTMLElement).shadowRoot;
// A shadow host is usually position:static with a fixed layer INSIDE its root; still test it
// (via its shadow descendants) so the whole host can be removed as one overlay unit.
if (cs.position !== "fixed" && cs.position !== "sticky" && !isShadowHost) continue;
if (!painting(cs)) continue;
const r = rectOf(el);
const z = effectiveZ(el);
// Off a locked page keep the z>=100 floor; on a locked page any full-viewport fixed
// layer is blocking (rule b). A max-z wrapper is a blocking layer even at 0 size when it
// wraps a full-viewport descendant (rule c) — legit chrome never reaches the INT_MAX band.
const positioned = cs.position === "fixed" || cs.position === "sticky";
const selfFull = positioned && fullViewport(r);
const wrapsFull = (isShadowHost || r.width < 4 || r.height < 4) && (isShadowHost || z >= 100) && hasFullDescendant(el);
if (!selfFull && !wrapsFull) continue;
const zPass = locked || z >= 100 || (wrapsFull && isShadowHost);
if (zPass) out.push(el as HTMLElement);
}
// Keep outermost only (a wrapper subsumes its inner full-viewport iframe — the overlay unit).
// `Node.contains` does NOT cross shadow boundaries, so also treat a candidate that lives inside
// another candidate's shadow tree as contained — otherwise a shadow host AND its inner layer
// both survive and we double-remove (or leave the inner layer behind).
const shadowContains = (host: Element, node: Element): boolean => {
let cur: Node | null = node;
while (cur) {
const rootNode = cur.getRootNode();
if (rootNode instanceof ShadowRoot) {
if (rootNode.host === host || host.contains(rootNode.host)) return true;
cur = rootNode.host;
} else break;
}
return false;
};
return out.filter((el) => !out.some((o) => o !== el && (o.contains(el) || shadowContains(o, el))));
};
// A scroll-locked page behind a full-viewport overlay IS a blocking modal by definition
// (legit pages don't scroll-lock). Remove ANY such overlay that isn't page chrome — many
// modals/drawers carry no consent/modal keyword and an icon-only close, so a keyword/aria
// allowlist misses them. PROTECTED guards real chrome (header/nav/footer) only.
const PROTECTED = /header|navbar|nav-|site-nav|topbar|masthead|footer/i; const PROTECTED = /header|navbar|nav-|site-nav|topbar|masthead|footer/i;
const sig = (el: HTMLElement): string => `${el.id} ${el.className}`.toString(); const sig = (el: HTMLElement): string => `${el.id} ${el.className}`.toString();
const removedLabels: string[] = []; const removedLabels: string[] = [];
let removed = 0; let removed = 0;
let remaining = bigOverlays(); let remaining = bigOverlays();
if (remaining.length && isLocked()) { if (remaining.length && locked) {
for (const el of remaining) { for (const el of remaining) {
const s = sig(el); const s = sig(el);
const z = parseInt(getComputedStyle(el).zIndex || "0", 10) || 0; const z = effectiveZ(el);
// Scroll-locked + full-viewport ⇒ blocking modal; remove unless it's page // Scroll-locked + full-viewport ⇒ blocking modal; remove unless it's page chrome. Always
// chrome. Always remove iframes (cross-origin close, unclickable) and the // remove iframes (cross-origin close, unclickable), max-z wrappers, and the overlay unit:
// max-z-index popup trick. // when the layer IS or CONTAINS a full-viewport fixed iframe, remove the whole subtree
// (rule c — the 0-size wrapper + its iframe come out together as one node).
const isShadowHost = !!(el as HTMLElement).shadowRoot;
const containsIframe = el.tagName === "IFRAME" || !!el.querySelector("iframe") ||
(isShadowHost && !!(el as HTMLElement).shadowRoot!.querySelector("iframe"));
const removable = !PROTECTED.test(s) || el.getAttribute("aria-modal") === "true" || const removable = !PROTECTED.test(s) || el.getAttribute("aria-modal") === "true" ||
el.tagName === "IFRAME" || z >= 2_000_000_000; containsIframe || isShadowHost || z >= 100;
if (removable) { el.remove(); removed++; removedLabels.push((el.id || el.className || el.tagName).toString().slice(0, 40)); } if (removable) { el.remove(); removed++; removedLabels.push((el.id || el.className || el.tagName).toString().slice(0, 40)); }
} }
if (removed) { document.body.style.overflow = "visible"; document.documentElement.style.overflow = "visible"; document.body.style.position = "static"; } if (removed) {
document.body.style.overflow = "visible"; document.documentElement.style.overflow = "visible";
document.body.style.overflowY = "visible"; document.documentElement.style.overflowY = "visible";
document.body.style.position = "static";
}
remaining = bigOverlays(); remaining = bigOverlays();
} }
return { overlaysRemaining: remaining.length, removed, blocking: remaining.length > 0 && isLocked(), removedLabels }; // Loud failure (rule d): a persisting lock is blocking whether or not a specific overlay was
}), // still detectable — a scroll-locked capture is polluted, so surface it to the pollution gate.
new Promise<{ overlaysRemaining: number; removed: number; blocking: boolean; removedLabels: string[] }>((res) => setTimeout(() => res({ overlaysRemaining: 0, removed: 0, blocking: false, removedLabels: [] }), 6000)), const stillLocked = isLocked();
return { overlaysRemaining: remaining.length, removed, blocking: stillLocked && (remaining.length > 0 || locked), removedLabels };
}
async function finalizeOverlays(page: import("playwright").Page): Promise<FinalizeOverlaysResult> {
try {
return await Promise.race([
page.evaluate(finalizeOverlaysInPage),
new Promise<FinalizeOverlaysResult>((res) => setTimeout(() => res({ overlaysRemaining: 0, removed: 0, blocking: false, removedLabels: [] }), 6000)),
]); ]);
} catch { } catch {
return { overlaysRemaining: 0, removed: 0, blocking: false, removedLabels: [] }; return { overlaysRemaining: 0, removed: 0, blocking: false, removedLabels: [] };
@@ -475,10 +743,102 @@ async function captureVideoStills(page: import("playwright").Page): Promise<Vide
} }
/** /**
* Full-page screenshot with robustness for heavy/animated pages. The default 30s * Stage 2 — canvas raster fallback. A visible <canvas> (animated background, chart,
* timeout is exceeded by tall SaaS pages (Playwright also waits for web fonts); * WebGL scene) is a runtime-drawn surface the clone cannot reproduce as DOM, so it
* use a long timeout, freeze animations (also improves determinism), and retry. * would render as an empty box. Rasterize each meaningful canvas to a PNG still under
* As a last resort take a viewport-only shot so the file exists (the capture gate * a synthetic URL (the normal asset pipeline rewrites it to a local file); the node is
* then marked with a `src` attr so generation emits the still as an <img> filling the
* canvas's box. `toDataURL` is exact but THROWS for tainted canvases and for WebGL
* contexts without preserveDrawingBuffer — those return a shot plan for the node-side
* element-screenshot fallback (composited pixels, works regardless). A WebGL canvas may
* also toDataURL to a blank image when the drawing buffer was already presented; that
* blank is accepted as-is (detecting blankness would be heuristic, not deterministic).
* The in-page part is exported for the fixture tests (page.evaluate'd directly).
*/
export type CanvasStillPlan = { stills: Array<{ url: string; dataUrl: string; sel: string }>; shots: Array<{ url: string; sel: string }> };
export function captureCanvasStillsInPage(): CanvasStillPlan {
const stills: CanvasStillPlan["stills"] = [];
const shots: CanvasStillPlan["shots"] = [];
const hash = (s: string): string => { let h = 5381; for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) >>> 0; return h.toString(36); };
const canvases = Array.from(document.querySelectorAll("canvas"));
let i = 0;
for (const c of canvases) {
const r = c.getBoundingClientRect();
if (r.width < 48 || r.height < 48) continue; // not a meaningful painted surface
const cs = getComputedStyle(c);
if (cs.display === "none" || cs.visibility === "hidden" || parseFloat(cs.opacity || "1") < 0.05) continue;
const idx = i++;
c.setAttribute("data-clone-canvas", String(idx));
const sel = `canvas[data-clone-canvas="${idx}"]`;
const url = `https://clone-canvas.local/${idx}-${hash(c.id + "|" + c.width + "|" + c.height + "|" + idx)}.png`;
let ok = false;
try {
const dataUrl = c.toDataURL("image/png"); // throws if tainted / WebGL buffer unreadable
if (dataUrl.startsWith("data:image/png")) { stills.push({ url, dataUrl, sel }); ok = true; }
} catch { /* fall through to the element-screenshot plan */ }
if (!ok) shots.push({ url, sel });
}
return { stills, shots };
}
async function captureCanvasStills(page: import("playwright").Page): Promise<CanvasStillPlan> {
try {
return await Promise.race([
page.evaluate(captureCanvasStillsInPage),
new Promise<CanvasStillPlan>((res) => setTimeout(() => res({ stills: [], shots: [] }), 12_000)),
]);
} catch {
return { stills: [], shots: [] };
}
}
// Chromium refuses/truncates screenshots past a texture-size cap; keep clip dimensions
// under a conservative bound so captureFullPageViaCDP fails cleanly (→ Playwright fallback)
// instead of returning a truncated image on pathologically tall pages.
const CDP_MAX_SHOT_DIMENSION = 16_384;
/**
* Full-page screenshot via CDP `Page.captureScreenshot` with `captureBeyondViewport:true`.
* Unlike Playwright's `fullPage:true` (which scroll-stitches — scrolling the page to render
* each band, FIRING scroll events that drive scroll-linked animations, e.g. IX2 grow-on-scroll
* or WAAPI scroll-timelines), CDP renders the whole page in ONE shot WITHOUT scrolling and
* never fires scroll events. So the resulting still is the genuine at-rest (unscrolled) page,
* matching the DOM snapshot the walk grades. Writes a PNG to `path`. Throws on any failure so
* the caller can fall back to the Playwright path.
*/
export async function captureFullPageViaCDP(page: import("playwright").Page, path: string): Promise<void> {
const client = await page.context().newCDPSession(page);
try {
const metrics = await client.send("Page.getLayoutMetrics") as {
cssContentSize?: { width: number; height: number };
contentSize?: { width: number; height: number };
};
const size = metrics.cssContentSize ?? metrics.contentSize;
if (!size || !(size.width > 0) || !(size.height > 0)) throw new Error("cdp: empty content size");
const width = Math.ceil(size.width);
const height = Math.ceil(size.height);
if (width > CDP_MAX_SHOT_DIMENSION || height > CDP_MAX_SHOT_DIMENSION) {
throw new Error(`cdp: content ${width}x${height} exceeds max shot dimension`);
}
const shot = await client.send("Page.captureScreenshot", {
format: "png",
captureBeyondViewport: true,
clip: { x: 0, y: 0, width, height, scale: 1 },
}) as { data: string };
if (!shot?.data) throw new Error("cdp: no screenshot data");
writeBytes(path, Buffer.from(shot.data, "base64"));
} finally {
await client.detach().catch(() => { /* ignore */ });
}
}
/**
* Full-page screenshot with robustness for heavy/animated pages. Prefers CDP capture
* (captureFullPageViaCDP — no scroll-stitch, so scroll-linked animations aren't scrubbed
* and the still is the true at-rest page). On ANY CDP failure, falls back to Playwright's
* `fullPage:true` (which scroll-stitches but freezes animations); the default 30s timeout
* is exceeded by tall pages (Playwright also waits for web fonts), so use a long timeout and
* retry. As a last resort take a viewport-only shot so the file exists (the capture gate
* checks presence; a partial image still beats none). * checks presence; a partial image still beats none).
*/ */
async function captureScreenshot( async function captureScreenshot(
@@ -487,6 +847,13 @@ async function captureScreenshot(
vw: number, vw: number,
log: (e: Record<string, unknown>) => void, log: (e: Record<string, unknown>) => void,
): Promise<void> { ): Promise<void> {
try {
await captureFullPageViaCDP(page, path);
log({ event: "screenshot_cdp", viewport: vw });
return;
} catch (eCdp) {
log({ event: "screenshot_cdp_fallback", viewport: vw, error: String(eCdp).slice(0, 200) });
}
for (let attempt = 0; attempt < 2; attempt++) { for (let attempt = 0; attempt < 2; attempt++) {
try { try {
await page.screenshot({ path, fullPage: true, timeout: 90_000, animations: "disabled" }); await page.screenshot({ path, fullPage: true, timeout: 90_000, animations: "disabled" });
@@ -505,6 +872,131 @@ async function captureScreenshot(
} }
} }
/**
* Pause every <video> and seek it to time 0, then wait for that seek to actually PAINT, so a
* screenshot taken right after is deterministic. A video's playback time is runtime state: a one-shot
* animation ends on its last frame, an autoplaying loop sits at an arbitrary offset, and the time a
* given viewport's shot happens to catch is nondeterministic. Without normalization the SOURCE and the
* (frame-0, non-playing) CLONE can show different frames of the same element — a phantom perceptual
* diff that no CSS change can close. Seeking BOTH sides to frame 0 before EVERY viewport screenshot
* removes it. `seeked` fires once the new frame is decoded; we still yield two rAFs so the compositor
* has painted it before the screenshot reads pixels. Bounded so a stalled/unseekable video can't hang.
*
* FIRST it re-runs `<source media>` resource selection for THIS viewport. The capture pipeline loads
* the page once at the canonical width and reaches every other viewport by resizing — but the HTML
* media resource-selection algorithm evaluates `<source media>` only at load, so an aspect/orientation
* -gated hero video stays frozen on whatever variant matched at load width across all resized shots.
* The validator fresh-loads per viewport and correctly re-selects, so the two channels disagree on
* which variant is on screen (a large phantom diff on full-bleed portrait/landscape hero videos).
* Re-selecting here keeps the channels symmetric; it is a no-op on the fresh-loading validator side
* and — the common case — a no-op whenever the current selection is still the right one, so the fast
* path never touches `video.load()`.
*/
export async function normalizeVideoTime(
page: import("playwright").Page,
log?: (event: Record<string, unknown>) => void,
): Promise<void> {
try {
const reselections = await page.evaluate(async () => {
const vids = Array.from(document.querySelectorAll("video"));
const raf = () => new Promise<void>((r) => requestAnimationFrame(() => r()));
// Re-select the <source> the resource-selection algorithm would pick at the CURRENT viewport,
// mirroring selectVideoSourceIndex (Node-side, unit-tested): first source in document order
// whose media matches (missing media matches unconditionally) and whose type is playable
// (missing type is never disqualifying). Only reload when the winner differs from what is
// currently loaded — the fast path leaves untouched videos alone.
//
// Track which videos we reloaded. A reload resets the element's *show-poster flag* to true and
// sets currentTime to 0; the follow-up pause() cancels the pending autoplay, so the one thing
// that would have cleared that flag (playback beginning) never happens. Left uncleared, the
// element renders its `poster` image — which for an aspect-gated hero is a frame of the WRONG
// variant — instead of the freshly-selected source's frame 0. A real seek is the other
// flag-clearing path, but the fast-skip below bails at t=0. So force a genuine seek (to a tiny
// epsilon, not to the already-current 0) on exactly the reloaded videos to clear the flag and
// present the new source's decoded frame. Non-reloaded videos keep the existing fast skip.
const reloaded = new WeakSet<HTMLVideoElement>();
const reselections: { from: string; to: string; readyState: number }[] = [];
const reloadWaits: Promise<void>[] = [];
for (const v of vids) {
try {
const sources = Array.from(v.querySelectorAll("source"));
if (sources.length === 0) continue; // src attribute (no <source> children): nothing to re-select
let winner: HTMLSourceElement | null = null;
for (const s of sources) {
const media = (s.getAttribute("media") ?? "").trim();
if (media && !window.matchMedia(media).matches) continue;
const type = (s.getAttribute("type") ?? "").trim();
if (type && !v.canPlayType(type)) continue;
winner = s;
break;
}
if (!winner || !winner.src) continue;
// Compare resolved URLs; currentSrc is absolute, winner.src is already resolved.
if (v.currentSrc === winner.src) continue; // no change — fast path, do not reload
const from = v.currentSrc;
const to = winner.src;
v.load();
reloaded.add(v);
reloadWaits.push(new Promise<void>((resolve) => {
let settled = false;
const done = () => {
if (settled) return;
settled = true;
v.removeEventListener("loadeddata", done);
v.removeEventListener("seeked", done);
reselections.push({ from, to, readyState: v.readyState });
resolve();
};
if (v.readyState >= 2 /* HAVE_CURRENT_DATA */) { done(); return; }
// A post-reload `seeked` implies a decoded frame is ready too, so treat it as an
// additional readiness signal: on slow CDNs the epsilon seek (below) can land its
// `seeked` before `loadeddata`, which unblocks the FIRST viewport instead of waiting
// out the 4s bound and shooting the poster.
v.addEventListener("loadeddata", done, { once: true });
v.addEventListener("seeked", done, { once: true });
setTimeout(done, 4000); // bound: a stalled reload resolves anyway so the shot never hangs
}));
} catch { /* a malformed <source>/media query must not block the others */ }
}
const waits: Promise<void>[] = [];
const seekTo = (v: HTMLVideoElement, t: number) =>
new Promise<void>((resolve) => {
let settled = false;
const done = () => { if (settled) return; settled = true; v.removeEventListener("seeked", done); resolve(); };
v.addEventListener("seeked", done, { once: true });
try { v.currentTime = t; } catch { done(); }
setTimeout(done, 400); // bound: a stalled/unseekable video resolves anyway
});
for (const v of vids) {
try { v.pause(); } catch { /* ignore */ }
if (reloaded.has(v)) {
// A just-reloaded video sits at t=0 with the show-poster flag set. Seek to a tiny epsilon
// (not 0, which is already the current time and would fire no `seeked`) to force a genuine
// seek: it clears the flag and paints the new source's frame 0. Start it eagerly so its
// `seeked` can also satisfy the reload wait above on slow networks.
waits.push(seekTo(v, 1e-4));
continue;
}
// Seek to 0 only when not already there (a fresh seek to the current time may not fire `seeked`).
if (Math.abs(v.currentTime) < 1e-3) continue;
waits.push(seekTo(v, 0));
}
// Await the reload settles and the seeks together; the reload wait can be released by an
// epsilon seek's `seeked`, so both sets must be in flight before we block on either.
await Promise.all([...reloadWaits, ...waits]);
await raf(); await raf(); // let the decoded frame composite before the screenshot reads pixels
return reselections;
});
if (log && reselections && reselections.length) {
for (const r of reselections) {
log({ event: "video_source_reselected", from: r.from, to: r.to, readyState: r.readyState });
}
}
} catch { /* a page with no videos / an eval hiccup must never block the screenshot */ }
}
export async function captureSite(opts: { export async function captureSite(opts: {
url: string; url: string;
outDir: string; // source/ directory outDir: string; // source/ directory
@@ -577,9 +1069,24 @@ export async function captureSite(opts: {
// page stored under first-stored-wins ships a corrupt file). Left unstored, the // page stored under first-stored-wins ships a corrupt file). Left unstored, the
// asset surfaces in visual_assets_missing instead. // asset surfaces in visual_assets_missing instead.
if (type === "video" && !looksLikeVideoFile(bytes)) return; if (type === "video" && !looksLikeVideoFile(bytes)) return;
// Same guard for fonts: a 200+HTML body (SPA router answering an unknown /media/* path) must not
// be stored as a .woff2. Left unstored, the face surfaces as failed, so the font graph can fall
// back to a correctly-resolved duplicate url instead of shipping an impostor the browser rejects.
if (type === "font" && !looksLikeFontFile(bytes)) return;
const a = assetMap.get(url) ?? recordAsset(url, type, null, null, "network"); const a = assetMap.get(url) ?? recordAsset(url, type, null, null, "network");
if (a.storedAs) return; if (a.storedAs) return;
const ext = extFromUrl(url) || extFromContentType(a.contentType) || // A `.lottie` (dotLottie) asset is a ZIP archive, not bare lottie-web JSON. lottie-web's
// `path:` loader does a JSON.parse and throws on the ZIP bytes, blanking the container. So
// unwrap it here: extract the default animation JSON and store THAT, materializing every
// lottie source as plain JSON regardless of the container it arrived in.
let extOverride: string | null = null;
if (type === "lottie" && isZipArchive(bytes)) {
const json = extractDotLottieJson(bytes);
if (!json) return; // unreadable dotLottie — leave unstored rather than ship a broken ZIP
bytes = json;
extOverride = "json";
}
const ext = extOverride || extFromUrl(url) || extFromContentType(a.contentType) ||
(type === "css" ? "css" : type === "font" ? "woff2" : type === "svg" ? "svg" : (type === "css" ? "css" : type === "font" ? "woff2" : type === "svg" ? "svg" :
type === "video" ? "mp4" : type === "lottie" ? "json" : "png"); type === "video" ? "mp4" : type === "lottie" ? "json" : "png");
const name = `${sha1_12(url)}.${ext}`; const name = `${sha1_12(url)}.${ext}`;
@@ -870,6 +1377,47 @@ export async function captureSite(opts: {
await page.evaluate(() => window.scrollTo(0, 0)); await page.evaluate(() => window.scrollTo(0, 0));
await page.waitForTimeout(80); await page.waitForTimeout(80);
} }
// Stage 2: canvas raster fallback — same synthetic-URL mechanism as the video
// stills above (captureCanvasStills). Bytes ride the normal asset pipeline; the
// canvas node gets a `src` attr (whitelisted → survives the IR) that generation
// reads to emit the still as an <img> carrying the canvas's cid/box. The attr is
// stamped only AFTER bytes are stored, so a failed capture leaves the canvas
// rendering as an empty box, same as before.
const cplan = await captureCanvasStills(page);
const stampCanvasSrc = (sel: string, u: string): Promise<void> =>
page.evaluate(({ s, u: uu }) => { document.querySelector(s)?.setAttribute("src", uu); }, { s: sel, u }).catch(() => { /* ignore */ });
for (const s of cplan.stills) {
const comma = s.dataUrl.indexOf(",");
if (comma < 0) continue;
try {
const buf = Buffer.from(s.dataUrl.slice(comma + 1), "base64");
recordAsset(s.url, "image", "image/png", 200, "canvas-still");
storeBytes(s.url, "image", buf);
await stampCanvasSrc(s.sel, s.url);
log({ event: "canvas_still", viewport: vw, sel: s.sel });
} catch { /* ignore */ }
}
for (const s of cplan.shots) {
// Same reveal discipline as the video shots: a hidden canvas (entrance
// animation not yet fired) would time out locator.screenshot's visibility wait.
try {
await page.evaluate(forceRevealForShot, s.sel);
const buf = await page.locator(s.sel).first().screenshot({ type: "png", timeout: 5000, animations: "disabled" });
recordAsset(s.url, "image", "image/png", 200, "canvas-still");
storeBytes(s.url, "image", buf);
await stampCanvasSrc(s.sel, s.url);
log({ event: "canvas_still", viewport: vw, sel: s.sel });
} catch (e) {
log({ event: "canvas_still_error", viewport: vw, sel: s.sel, error: String(e).slice(0, 200) });
} finally {
await page.evaluate(restoreRevealForShot).catch(() => { /* ignore */ });
}
}
if (cplan.shots.length) {
await page.evaluate(() => window.scrollTo(0, 0));
await page.waitForTimeout(80);
}
} }
// Stage 4/5: stamp capture-ids before the canonical snapshot so the IR carries // Stage 4/5: stamp capture-ids before the canonical snapshot so the IR carries
@@ -973,6 +1521,21 @@ export async function captureSite(opts: {
const scrollAnimsCanceled = await neutralizeScrollTimelineAnimations(page); const scrollAnimsCanceled = await neutralizeScrollTimelineAnimations(page);
if (scrollAnimsCanceled) log({ event: "scroll_timeline_anims_canceled", viewport: vw, count: scrollAnimsCanceled }); if (scrollAnimsCanceled) log({ event: "scroll_timeline_anims_canceled", viewport: vw, count: scrollAnimsCanceled });
// Final scroll-state reset immediately before the walk: every preceding pass (dwell
// scroll, carousel settle, element screenshots) can leave the page scrolled, which bakes
// scroll-linked transforms/offsets into this viewport's computed styles. Scroll to top and
// let scroll-linked effects settle so the snapshot records the genuine at-rest values.
await settleScrollTopBeforeSnapshot(page);
// Late-mounting dialogs (a CCPA opt-out that opens between the canonical and last
// viewport; a Recart/Attentive promo that fires on a timer) mount AFTER the load /
// post-scroll passes and would otherwise pollute this viewport's DOM snapshot. Re-run
// the dismissal pass immediately before the walk. Cheap when nothing matches — the
// settle only runs when a control was actually clicked or an overlay removed.
await applyDismiss("pre-snapshot");
dismissUnion.overlaysRemaining = Math.max(dismissUnion.overlaysRemaining, overlaysRemaining);
dismissUnion.blocking = dismissUnion.blocking || blocking;
// Bound the in-page DOM walk: page.evaluate has no default timeout, so a // Bound the in-page DOM walk: page.evaluate has no default timeout, so a
// pathologically large/animated DOM (e.g. asana.com) could hang forever. // pathologically large/animated DOM (e.g. asana.com) could hang forever.
const snapshot: PageSnapshot = await Promise.race([ const snapshot: PageSnapshot = await Promise.race([
@@ -1020,7 +1583,20 @@ export async function captureSite(opts: {
// Persist DOM snapshot, and (unless skipped for a production clone) the full-page screenshot. // Persist DOM snapshot, and (unless skipped for a production clone) the full-page screenshot.
writeJSONCompact(join(captureDir, `dom-${vw}.json`), snapshot); writeJSONCompact(join(captureDir, `dom-${vw}.json`), snapshot);
if (opts.screenshots !== false) await captureScreenshot(page, join(screenshotsDir, `${vw}.png`), vw, log); if (opts.screenshots !== false) {
// A promo popup can mount in the window between the DOM walk and the screenshot (Recart
// fires into a shadow root on a short timer). The screenshot is the ground-truth channel
// the perceptual gate compares against, so re-run dismissal right before it — otherwise a
// late popup paints over every shot even though the DOM snapshot came out clean. Cheap
// when nothing matches.
await applyDismiss("pre-screenshot");
dismissUnion.blocking = dismissUnion.blocking || blocking;
// Normalize every video to frame 0 (paused) at THIS viewport before the shot — the clone is
// always at frame 0, so pinning the source there too makes the two channels comparable
// regardless of the playback time the viewport happened to catch.
await normalizeVideoTime(page, log);
await captureScreenshot(page, join(screenshotsDir, `${vw}.png`), vw, log);
}
// Stage 4: drive recognized affordances at the canonical viewport (opt-in). // Stage 4: drive recognized affordances at the canonical viewport (opt-in).
if (opts.interactions && vw === canonical) { if (opts.interactions && vw === canonical) {
+123
View File
@@ -0,0 +1,123 @@
import { inflateRawSync } from "node:zlib";
/**
* dotLottie (.lottie) extraction.
*
* A `.lottie` file is NOT bare lottie-web JSON — it is a ZIP archive (the "dotLottie"
* container) holding `manifest.json` plus one or more `animations/*.json` documents. Feeding
* the raw ZIP bytes to lottie-web via `path:` makes it try to JSON.parse a ZIP, which throws
* an InvalidStateError at runtime and leaves the container blank. So at materialization time
* we detect the ZIP, pick the default/first animation, and rewrite the stored asset as plain
* lottie JSON that lottie-web can parse directly.
*
* Node ships `zlib` (raw DEFLATE) but no ZIP reader, and pulling a zip dependency for this
* one shape is overkill — so this implements the minimal slice of the ZIP spec needed:
* walking the End-Of-Central-Directory + central directory to locate entries, then reading
* each local file header to decompress a stored (method 0) or deflated (method 8) entry.
* Deterministic: no timestamps, no randomness, entries resolved by name.
*/
/** ZIP local file header signature (`PK\x03\x04`). dotLottie archives always start with it. */
export function isZipArchive(bytes: Buffer): boolean {
return bytes.length >= 4 && bytes[0] === 0x50 && bytes[1] === 0x4b && bytes[2] === 0x03 && bytes[3] === 0x04;
}
const EOCD_SIG = 0x06054b50; // End Of Central Directory
const CEN_SIG = 0x02014b50; // Central directory file header
const LOC_SIG = 0x04034b50; // Local file header
type ZipEntry = { name: string; method: number; compSize: number; uncompSize: number; localOffset: number };
/** Locate + read the End-Of-Central-Directory record, then walk the central directory. */
function readCentralDirectory(buf: Buffer): ZipEntry[] {
// The EOCD lives at the tail; it is >=22 bytes and may carry a trailing comment, so scan
// backwards for its signature within the max comment window (64KiB) + record size.
const minEocd = 22;
if (buf.length < minEocd) return [];
let eocd = -1;
const scanFrom = Math.max(0, buf.length - (0xffff + minEocd));
for (let i = buf.length - minEocd; i >= scanFrom; i--) {
if (buf.readUInt32LE(i) === EOCD_SIG) { eocd = i; break; }
}
if (eocd < 0) return [];
const entryCount = buf.readUInt16LE(eocd + 10);
let ptr = buf.readUInt32LE(eocd + 16); // central directory offset
const entries: ZipEntry[] = [];
for (let i = 0; i < entryCount; i++) {
if (ptr + 46 > buf.length || buf.readUInt32LE(ptr) !== CEN_SIG) break;
const method = buf.readUInt16LE(ptr + 10);
const compSize = buf.readUInt32LE(ptr + 20);
const uncompSize = buf.readUInt32LE(ptr + 24);
const nameLen = buf.readUInt16LE(ptr + 28);
const extraLen = buf.readUInt16LE(ptr + 30);
const commentLen = buf.readUInt16LE(ptr + 32);
const localOffset = buf.readUInt32LE(ptr + 42);
const name = buf.toString("utf8", ptr + 46, ptr + 46 + nameLen);
entries.push({ name, method, compSize, uncompSize, localOffset });
ptr += 46 + nameLen + extraLen + commentLen;
}
return entries;
}
/** Decompress one central-directory entry by reading its local header for the data offset. */
function readEntry(buf: Buffer, e: ZipEntry): Buffer | null {
const p = e.localOffset;
if (p + 30 > buf.length || buf.readUInt32LE(p) !== LOC_SIG) return null;
// The central directory's name/extra lengths can differ from the local header's, so read the
// local header's own lengths to find where the file data begins.
const nameLen = buf.readUInt16LE(p + 26);
const extraLen = buf.readUInt16LE(p + 28);
const dataStart = p + 30 + nameLen + extraLen;
const dataEnd = dataStart + e.compSize;
if (dataEnd > buf.length) return null;
const comp = buf.subarray(dataStart, dataEnd);
if (e.method === 0) return Buffer.from(comp); // stored
if (e.method === 8) {
try { return inflateRawSync(comp); } catch { return null; }
}
return null; // unsupported compression method
}
/** Read a named entry (exact path match) from a ZIP buffer, or null if absent/unreadable. */
export function readZipEntry(buf: Buffer, name: string): Buffer | null {
const entry = readCentralDirectory(buf).find((e) => e.name === name);
return entry ? readEntry(buf, entry) : null;
}
/**
* Extract the lottie animation JSON from a dotLottie ZIP. Picks the manifest's first/default
* animation (falling back to the first `animations/*.json` entry), returning it as a Buffer of
* plain lottie JSON ready to hand to lottie-web via `path:`. Returns null when the bytes are
* not a dotLottie ZIP or no animation JSON is present.
*/
export function extractDotLottieJson(bytes: Buffer): Buffer | null {
if (!isZipArchive(bytes)) return null;
const entries = readCentralDirectory(bytes);
if (!entries.length) return null;
const animEntries = entries
.filter((e) => /^animations\/.+\.json$/i.test(e.name))
.sort((a, b) => a.name.localeCompare(b.name)); // deterministic ordering
if (!animEntries.length) return null;
// Prefer the animation the manifest names first (its default), when the manifest is readable.
let chosen = animEntries[0]!;
const manifestBuf = readZipEntry(bytes, "manifest.json");
if (manifestBuf) {
try {
const manifest = JSON.parse(manifestBuf.toString("utf8")) as { animations?: Array<{ id?: string }> };
const firstId = manifest.animations?.[0]?.id;
if (firstId) {
const match = animEntries.find((e) => e.name === `animations/${firstId}.json`);
if (match) chosen = match;
}
} catch { /* manifest unreadable — keep the name-sorted first animation */ }
}
const json = readEntry(bytes, chosen);
if (!json) return null;
// Validate it parses as JSON (lottie-web's `path:` loader does a bare JSON.parse).
try { JSON.parse(json.toString("utf8")); } catch { return null; }
return json;
}
+28 -2
View File
@@ -29,8 +29,14 @@ export const MIN_FRAME_DIM = 48;
export type FramePlan = "skip" | "still" | "graft"; export type FramePlan = "skip" | "still" | "graft";
// Ad/analytics/consent plumbing frames render nothing a visitor values — skip entirely. // Ad/analytics/consent plumbing frames render nothing a visitor values — skip entirely.
// The trailing group is email-capture / promo POPUP CREATIVE hosts: a vendor whose creative
// iframe IS a full-viewport interstitial ("Enjoy 15% off" over a dimmed backdrop). Grafting it
// pours the popup's copy into the DOM/text channel and paints the modal over the real page.
// CAUTION: these hosts serve OVERLAY creatives, distinct from the vendor's INLINE-form embed
// hosts (e.g. static-forms.klaviyo.com), which stay graftable — inline signup forms are a
// deliberately-grafted feature (see iframeGraft tests). Match creative/overlay hosts only.
const FRAME_SKIP_RE = const FRAME_SKIP_RE =
/(?:doubleclick\.net|googlesyndication\.com|googletagmanager\.com|google-analytics\.com|googleadservices\.com|adservice\.google|facebook\.com\/tr|connect\.facebook\.net|\brecaptcha\b|hcaptcha\.com|challenges\.cloudflare\.com|adsrvr\.org|amazon-adsystem\.com)/i; /(?:doubleclick\.net|googlesyndication\.com|googletagmanager\.com|google-analytics\.com|googleadservices\.com|adservice\.google|facebook\.com\/tr|connect\.facebook\.net|\brecaptcha\b|hcaptcha\.com|challenges\.cloudflare\.com|adsrvr\.org|amazon-adsystem\.com|creatives?\.attn\.tv|\.attentivemobile\.com|bounceexchange\.com|bouncex\.net|\.wunderkind\.co|wknd\.ai|cdn\.justuno\.com\/mkjs|privy\.com\/.*popup|widget\.privy\.com|\.recart\.com|recart\.io)/i;
// Media players are JS-built canvases/videos whose DOM graft is meaningless; an element // Media players are JS-built canvases/videos whose DOM graft is meaningless; an element
// screenshot (the poster frame + play chrome) is the faithful static paint. // screenshot (the poster frame + play chrome) is the faithful static paint.
const FRAME_STILL_RE = const FRAME_STILL_RE =
@@ -165,6 +171,21 @@ export function graftFrameIntoSnapshot(
x: Math.round((n.bbox.x + frame.contentX) * 100) / 100, x: Math.round((n.bbox.x + frame.contentX) * 100) / 100,
y: Math.round((n.bbox.y + frame.contentY) * 100) / 100, y: Math.round((n.bbox.y + frame.contentY) * 100) / 100,
}; };
// Rebase viewport-relative positioning into the frame's box. Inside a real iframe,
// `position: fixed` pins to the FRAME's viewport (its content box) and `sticky` scrolls
// against the FRAME's scroll container. Grafted in as plain <div> children, those
// containing blocks no longer exist, so `fixed` would resolve against the MAIN page
// viewport — escaping the iframe's clip box (fixed ignores `overflow:hidden` ancestors)
// and painting frame chrome (e.g. a `fixed inset-0 z-[-1]` dark backdrop) across the
// whole clone. Demote to box-relative positioning: `fixed`→`absolute` (contained and
// clipped by the host div, whose bboxes are already rebased to page coords) and
// `sticky`→`relative` (stays in flow instead of sticking to the page scroll). The host
// is made a containing block below so these absolutes anchor to the frame box.
if (n.computed) {
const pos = n.computed.position;
if (pos === "fixed") n.computed = { ...n.computed, position: "absolute" };
else if (pos === "sticky") n.computed = { ...n.computed, position: "relative" };
}
const a = n.attrs ?? {}; const a = n.attrs ?? {};
delete a["data-cid-cap"]; // capture-ids belong to the main document only delete a["data-cid-cap"]; // capture-ids belong to the main document only
if (a.id) a.id = prefix + a.id; if (a.id) a.id = prefix + a.id;
@@ -184,8 +205,13 @@ export function graftFrameIntoSnapshot(
host.children = [wrapper]; host.children = [wrapper];
// A real frame viewport clips its document; the replacement <div> must too, at every // A real frame viewport clips its document; the replacement <div> must too, at every
// captured viewport (each per-viewport snapshot is grafted independently). // captured viewport (each per-viewport snapshot is grafted independently). It must also
// be a containing block: `visit` demoted the frame's `position: fixed` chrome (which was
// pinned to the frame viewport) to `absolute`, and those absolutes must anchor to — and
// be clipped by — this box, not some positioned ancestor further up the page. A `static`
// host wouldn't establish that containing block, so promote it to `relative`.
host.computed = { ...host.computed, overflow: "hidden", overflowX: "hidden", overflowY: "hidden" }; host.computed = { ...host.computed, overflow: "hidden", overflowX: "hidden", overflowY: "hidden" };
if ((host.computed.position || "static") === "static") host.computed.position = "relative";
// An iframe is a REPLACED element: `display:inline` (the default) still honors its // An iframe is a REPLACED element: `display:inline` (the default) still honors its
// width/height. The <div> that replaces it at generation is not — inline would collapse // width/height. The <div> that replaces it at generation is not — inline would collapse
// the box — so translate to the behavior-equivalent inline-block. // the box — so translate to the behavior-equivalent inline-block.
+58 -19
View File
@@ -24,6 +24,13 @@ const PSEUDO_PROPS = [
export type StyleDelta = Record<string, string>; export type StyleDelta = Record<string, string>;
/** Changed-properties delta: every key in `b` whose value differs from `a`. Pure (unit-tested). */
export function diffStyle(a: StyleDelta, b: StyleDelta): StyleDelta {
const d: StyleDelta = {};
for (const k of Object.keys(b)) if (a[k] !== b[k]) d[k] = b[k]!;
return d;
}
// Properties that distinguish a panel's shown/hidden state and a trigger's // Properties that distinguish a panel's shown/hidden state and a trigger's
// active/inactive state. Captured per discrete state so the generated controller // active/inactive state. Captured per discrete state so the generated controller
// can toggle between them faithfully (M2: tabs + accordion). // can toggle between them faithfully (M2: tabs + accordion).
@@ -363,11 +370,42 @@ export async function captureInteractions(page: Page, opts?: { maxCandidates?: n
return o; return o;
}, { capId, props: PSEUDO_PROPS as unknown as string[] }); }, { capId, props: PSEUDO_PROPS as unknown as string[] });
const diff = (a: StyleDelta, b: StyleDelta): StyleDelta => { const diff = diffStyle;
const d: StyleDelta = {};
for (const k of Object.keys(b)) if (a[k] !== b[k]) d[k] = b[k]!; // Pseudo-state driver via CDP `CSS.forcePseudoState`. Pointer-based hovering (page.hover)
return d; // moves the REAL cursor to the element's box centre, so the browser applies `:hover` to
// whatever sits under that point — on a page with a transparent full-viewport overlay (a
// fixed page-wrapper / scroll layer, common on modern builder stacks) the point lands on the
// overlay and the target never enters `:hover`, silently capturing ZERO authored hover states
// even though page.hover throws nothing. Forcing the pseudo-class on the node itself is
// geometry-independent (occlusion-immune), applies pure-CSS `:hover` rules directly, and — for
// `.card:hover .overlay` — also styles descendants of the forced node, so reveals still show.
// Resolves each cap → CDP nodeId; a live map is rebuilt per element (cheap) so it survives
// any DOM churn. Falls back to no forcing if a CDP session can't be opened (then hover/focus
// capture is simply empty rather than wrong).
const client = await page.context().newCDPSession(page).catch(() => null);
if (client) {
try { await client.send("DOM.enable"); await client.send("CSS.enable"); await client.send("DOM.getDocument", {}); }
catch { /* CDP unavailable — force() below no-ops */ }
}
const nodeIdFor = async (capId: string): Promise<number | null> => {
if (!client) return null;
let objectId: string | undefined;
try {
const ev = await client.send("Runtime.evaluate", { expression: `document.querySelector('[data-cid-cap="${capId}"]')` }) as { result?: { objectId?: string } };
objectId = ev.result?.objectId; if (!objectId) return null;
const r = await client.send("DOM.requestNode", { objectId }) as { nodeId?: number };
return r.nodeId ?? null;
} catch { return null; }
finally { if (objectId) await client.send("Runtime.releaseObject", { objectId }).catch(() => {}); }
}; };
// Force (or clear, with []) a pseudo-class on a node. Deterministic and reversible.
const force = async (nodeId: number | null, classes: string[]): Promise<boolean> => {
if (!client || nodeId == null) return false;
try { await client.send("CSS.forcePseudoState", { nodeId, forcedPseudoClasses: classes }); return true; }
catch { return false; }
};
const settlePseudo = () => page.waitForTimeout(180); // let a `:hover`/`:focus` transition land
// Reveal-relevant props for descendants that appear on hover (overlay glow + CTA). Only // Reveal-relevant props for descendants that appear on hover (overlay glow + CTA). Only
// clean show/hide props — NOT transform/filter, whose mid-animation values bake a janky // clean show/hide props — NOT transform/filter, whose mid-animation values bake a janky
@@ -413,21 +451,21 @@ export async function captureInteractions(page: Page, opts?: { maxCandidates?: n
const hoverDesc: Record<string, Record<string, StyleDelta>> = {}; const hoverDesc: Record<string, Record<string, StyleDelta>> = {};
for (const capId of candidates) { for (const capId of candidates) {
const sel = `[data-cid-cap="${capId}"]`;
const base = await read(capId); const base = await read(capId);
if (!base) continue; if (!base) continue;
const nodeId = await nodeIdFor(capId);
const hiddenBase = await readHiddenDesc(capId); // hidden descendants → hover-reveal candidates const hiddenBase = await readHiddenDesc(capId); // hidden descendants → hover-reveal candidates
const hiddenCaps = Object.keys(hiddenBase); const hiddenCaps = Object.keys(hiddenBase);
// :hover — settle before reading: framer-style hover is driven by JS (Framer Motion) // :hover — force the pseudo-class on the node (geometry-independent; see `force` above),
// with a short transition, so an immediate read catches the pre-transition frame and // then settle before reading: an authored `:hover`/JS transition animates from the resting
// sees no delta (the nav-link hover was missed this way). Wait for the transition to land. // frame, so an immediate read catches the pre-transition value and sees no delta. Wait for it.
try { if (await force(nodeId, ["hover"])) {
await page.hover(sel, { timeout: 1200, force: true }); await settlePseudo();
await page.waitForTimeout(180);
const h = await read(capId); const h = await read(capId);
if (h) { const d = diff(base, h); if (Object.keys(d).length) hover[capId] = d; } if (h) { const d = diff(base, h); if (Object.keys(d).length) hover[capId] = d; }
// Descendant reveals (while still hovered): a hidden child shown on hover — the card's // Descendant reveals (while still hovered): a hidden child shown on hover — the card's
// OWN style is unchanged, so this is the only signal (framer's "Read story" overlay). // OWN style is unchanged, so this is the only signal (a "Read story" overlay). Forcing
// `:hover` on the card also matches `.card:hover .overlay` rules on its descendants.
if (hiddenCaps.length) { if (hiddenCaps.length) {
await page.waitForTimeout(380); // let the reveal transition fully finish before reading await page.waitForTimeout(380); // let the reveal transition fully finish before reading
const after = await readDescProps(hiddenCaps); const after = await readDescProps(hiddenCaps);
@@ -444,16 +482,17 @@ export async function captureInteractions(page: Page, opts?: { maxCandidates?: n
} }
if (Object.keys(revealed).length) hoverDesc[capId] = revealed; if (Object.keys(revealed).length) hoverDesc[capId] = revealed;
} }
} catch { /* not hoverable (covered/offscreen) — skip */ } await force(nodeId, []); // clear :hover before probing :focus
await page.mouse.move(1, 1).catch(() => {}); }
// :focus (only for focusable-ish; cheap to attempt, guarded) // :focus — force the pseudo-class the same way (also occlusion-independent).
try { if (await force(nodeId, ["focus"])) {
await page.focus(sel, { timeout: 800 }); await settlePseudo();
const f = await read(capId); const f = await read(capId);
if (f) { const d = diff(base, f); if (Object.keys(d).length) focus[capId] = d; } if (f) { const d = diff(base, f); if (Object.keys(d).length) focus[capId] = d; }
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur()); await force(nodeId, []); // restore the resting state
} catch { /* not focusable — skip */ }
} }
}
await client?.detach().catch(() => {});
log({ event: "interactions_hover_focus", candidates: candidates.length, hover: Object.keys(hover).length, focus: Object.keys(focus).length, hoverDesc: Object.keys(hoverDesc).length }); log({ event: "interactions_hover_focus", candidates: candidates.length, hover: Object.keys(hover).length, focus: Object.keys(focus).length, hoverDesc: Object.keys(hoverDesc).length });
+195 -15
View File
@@ -60,6 +60,83 @@ export type MarqueeSpec = {
periodPx: number; // distance per seamless loop (one duplicated copy ≈ scrollWidth/2) periodPx: number; // distance per seamless loop (one duplicated copy ≈ scrollWidth/2)
}; };
// ---- Pure marquee discriminators (extracted so the in-browser sampling logic is unit
// testable). These are duplicated verbatim inside `detectMarquees`' page.evaluate body —
// module-scope functions do NOT cross the serialization boundary — so any change here must
// be mirrored there (and vice versa). They are deterministic: no randomness, no clock reads.
/**
* Median signed velocity (px/s) from a series of per-sample translateX deltas taken at a
* fixed cadence. Median (not mean) ignores the single per-loop wrap-reset outlier, which
* is a large jump opposite the travel direction when the track seamlessly restarts.
*/
export function medianVelocityPxPerSec(deltas: number[], sampleMs: number): number {
if (!deltas.length || sampleMs <= 0) return 0;
const med = [...deltas].sort((a, b) => a - b)[Math.floor(deltas.length / 2)]!;
return Math.round((med / sampleMs) * 1000);
}
/**
* Discriminator 2 — sustained-constant-velocity test. A real marquee holds a steady
* velocity across time; a scroll-settle lerp (still easing toward its scroll-target after
* `scrollIntoView`) decays, so its velocity in a later observation window is a small
* fraction of the earlier one. Two windows of per-sample deltas separated by ≥1.5s; pass
* only if the later window's speed is BOTH non-trivial AND close to the earlier window's
* (within a relative tolerance) — i.e. it did not decay and did not reverse.
*
* @param window1Deltas per-sample translateX deltas from the first observation window
* @param window2Deltas per-sample translateX deltas from the second (later) window
* @param sampleMs cadence between samples within a window
* @param minPxPerSec minimum sustained |velocity| to be considered moving at all
* @param relTol fractional tolerance: |v2| must be ≥ (1-relTol)·|v1| and same sign
*/
export function classifyVelocitySamples(
window1Deltas: number[],
window2Deltas: number[],
sampleMs: number,
minPxPerSec = 4,
relTol = 0.5,
): { isMarquee: boolean; pxPerSec: number; v1: number; v2: number } {
const v1 = medianVelocityPxPerSec(window1Deltas, sampleMs);
const v2 = medianVelocityPxPerSec(window2Deltas, sampleMs);
// The reported velocity is the first window's (measured closest to a clean, post-settle
// marquee; also what the old code reported), kept for output determinism vs. the old path.
const pxPerSec = v1;
if (Math.abs(v1) < minPxPerSec || Math.abs(v2) < minPxPerSec) return { isMarquee: false, pxPerSec, v1, v2 };
if (Math.sign(v1) !== Math.sign(v2)) return { isMarquee: false, pxPerSec, v1, v2 }; // reversed → not a steady ticker
// v2 must NOT have decayed relative to v1 (a lerp settling toward target loses speed).
const sustained = Math.abs(v2) >= Math.abs(v1) * (1 - relTol);
return { isMarquee: sustained, pxPerSec, v1, v2 };
}
/**
* Discriminator 4 — genuine duplicated content. A marquee duplicates its content ≥2×
* (that is how a seamless loop works); "≥2 children" alone is far too weak (any flex row
* of distinct logos passes). Require actual repetition: ≥2 CONSECUTIVE children whose
* shape repeats — equal outerHTML hash (exact duplicate) or, as a looser structural
* fallback, an equal consecutive width sequence (some marquees clone then tweak attributes
* so hashes differ but the geometry repeats).
*
* @param hashes per-child outerHTML hash (cheap 32-bit), index-aligned with `widths`
* @param widths per-child rounded offsetWidth
*/
export function hasRepeatedChildren(hashes: number[], widths: number[]): boolean {
const n = Math.min(hashes.length, widths.length);
if (n < 2) return false;
// (a) two consecutive children with an identical hash — a literal cloned copy.
for (let i = 1; i < n; i++) if (hashes[i] === hashes[i - 1] && hashes[i] !== 0) return true;
// (b) structural fallback: the first half's width sequence repeats in the second half
// (content duplicated as a block, e.g. [A B C A B C]). Require an even count and a real
// width so a row of zero-width nodes can't spuriously "repeat".
if (n >= 4 && n % 2 === 0) {
const half = n / 2;
let repeats = true;
for (let i = 0; i < half; i++) { if (widths[i] === 0 || widths[i] !== widths[i + half]) { repeats = false; break; } }
if (repeats) return true;
}
return false;
}
export type MotionCapture = { export type MotionCapture = {
waapi: WaapiAnim[]; waapi: WaapiAnim[];
rotators: RotatorSpec[]; rotators: RotatorSpec[];
@@ -132,6 +209,25 @@ export async function probeReveals(page: Page): Promise<void> {
* and record its signed velocity + seamless-loop period (one duplicated copy ≈ half the * and record its signed velocity + seamless-loop period (one duplicated copy ≈ half the
* scroll width) so the clone can replay the loop. Read-only beyond scrolling (restores * scroll width) so the clone can replay the loop. Read-only beyond scrolling (restores
* scroll to top); does not touch the captured snapshot/IR. * scroll to top); does not touch the captured snapshot/IR.
*
* Discriminators (ALL must pass — a candidate is only classified as a marquee if every one
* holds), added to defeat scroll-LINKED easing false positives (a static logo row on a
* scroll-eased page is still lerping toward its scroll-target right after `scrollIntoView`,
* which reads as a constant velocity over a single short window):
* 1. Content overflow — scrollWidth > 1.35·clientWidth (a marquee has content to scroll;
* a static row that fits its box, scrollWidth ≈ clientWidth, cannot be a marquee).
* 2. Sustained constant velocity — two observation windows separated by ≥1.5s; a settle
* lerp decays (v2 ≪ v1), a real marquee holds v2 ≈ v1 (see classifyVelocitySamples).
* 3. Scroll independence (jiggle) — nudge scroll by ±50px and re-sample; if the velocity
* responds to the nudge the motion is scroll-linked, not a self-driven ticker.
* 4. Genuine duplication — ≥2 consecutive children repeat (equal outerHTML hash or an
* equal consecutive width sequence), the shape real marquees use for a seamless loop.
*
* Added latency is bounded: the expensive per-candidate windows/jiggle run ONLY for the
* few candidates that already pass the cheap synchronous gates (overflow + translated +
* clip + duplication), and the candidate list is capped at 8. Per surviving candidate the
* async cost is ≈ 300ms settle + 2×~600ms windows + ~1.4s inter-window gap + 2×~250ms
* jiggle ≈ 3.4s; with the cap the whole pass is bounded regardless of page size.
*/ */
async function detectMarquees(page: Page): Promise<MarqueeSpec[]> { async function detectMarquees(page: Page): Promise<MarqueeSpec[]> {
try { try {
@@ -143,32 +239,113 @@ async function detectMarquees(page: Page): Promise<MarqueeSpec[]> {
while (p && depth < 6) { const ox = getComputedStyle(p).overflowX; if (ox === "hidden" || ox === "clip") return true; p = p.parentElement; depth++; } while (p && depth < 6) { const ox = getComputedStyle(p).overflowX; if (ox === "hidden" || ox === "clip") return true; p = p.parentElement; depth++; }
return false; return false;
}; };
// cheap 32-bit string hash (djb2-ish) — deterministic, for the duplication test.
const hashStr = (s: string): number => { let h = 5381; for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0; return h; };
// ---- Pure discriminators (mirror of the exported module-scope functions; kept inline
// because module scope does not cross the page.evaluate serialization boundary). ----
const medianVelocityPxPerSec = (deltas: number[], sampleMs: number): number => {
if (!deltas.length || sampleMs <= 0) return 0;
const med = [...deltas].sort((a, b) => a - b)[Math.floor(deltas.length / 2)]!;
return Math.round((med / sampleMs) * 1000);
};
const classifyVelocitySamples = (w1: number[], w2: number[], sampleMs: number, minPxPerSec = 4, relTol = 0.5) => {
const v1 = medianVelocityPxPerSec(w1, sampleMs);
const v2 = medianVelocityPxPerSec(w2, sampleMs);
const pxPerSec = v1;
if (Math.abs(v1) < minPxPerSec || Math.abs(v2) < minPxPerSec) return { isMarquee: false, pxPerSec, v1, v2 };
if (Math.sign(v1) !== Math.sign(v2)) return { isMarquee: false, pxPerSec, v1, v2 };
const sustained = Math.abs(v2) >= Math.abs(v1) * (1 - relTol);
return { isMarquee: sustained, pxPerSec, v1, v2 };
};
const hasRepeatedChildren = (hashes: number[], widths: number[]): boolean => {
const n = Math.min(hashes.length, widths.length);
if (n < 2) return false;
for (let i = 1; i < n; i++) if (hashes[i] === hashes[i - 1] && hashes[i] !== 0) return true;
if (n >= 4 && n % 2 === 0) {
const half = n / 2;
let repeats = true;
for (let i = 0; i < half; i++) { if (widths[i] === 0 || widths[i] !== widths[i + half]) { repeats = false; break; } }
if (repeats) return true;
}
return false;
};
// sample per-child outerHTML hash + width for the duplication test.
const childSignature = (el: Element): { hashes: number[]; widths: number[] } => {
const hashes: number[] = [], widths: number[] = [];
const kids = Array.from(el.children).slice(0, 24) as HTMLElement[];
for (const k of kids) { hashes.push(hashStr(k.outerHTML)); widths.push(Math.round(k.getBoundingClientRect().width)); }
return { hashes, widths };
};
// one observation window: `count` deltas at `sampleMs` cadence.
const sampleWindow = async (el: Element, count: number, sampleMs: number): Promise<number[]> => {
const xs: number[] = [txOf(el)];
for (let i = 0; i < count; i++) { await sleep(sampleMs); xs.push(txOf(el)); }
const dxs: number[] = []; for (let i = 1; i < xs.length; i++) dxs.push(xs[i]! - xs[i - 1]!);
return dxs;
};
const SAMPLE_MS = 120;
const WINDOW_SAMPLES = 5; // 5 deltas ≈ 600ms per window
const INTER_WINDOW_MS = 1500; // ≥1.5s between windows (discriminator 2)
const JIGGLE_PX = 50; // scroll nudge for the independence test (discriminator 3)
// Candidates: tagged, already translated (paused tickers keep their offset), with // Candidates: tagged, already translated (paused tickers keep their offset), with
// duplicated content (≥2 children) inside an overflow-clip viewport — the marquee shape. // GENUINE duplicated content inside an overflow-clip viewport, AND real content
// overflow (scrollWidth > 1.35·clientWidth) — the marquee shape. All cheap/synchronous
// so the expensive async windows only run for the few survivors.
const cand: Element[] = []; const cand: Element[] = [];
for (const el of Array.from(document.querySelectorAll("[data-cid-cap]"))) { for (const el of Array.from(document.querySelectorAll("[data-cid-cap]"))) {
if (Math.abs(txOf(el)) <= 0.5) continue; if (Math.abs(txOf(el)) <= 0.5) continue;
if (el.children.length < 2) continue; if (el.children.length < 2) continue;
if (!inClip(el)) continue; if (!inClip(el)) continue;
// Discriminator 1: content overflow. scrollWidth must meaningfully exceed clientWidth;
// a static row that fits (scrollWidth ≈ clientWidth) has nothing to scroll.
if (el.scrollWidth <= el.clientWidth * 1.35) continue;
// Discriminator 4: genuine repetition, not merely ≥2 distinct children.
const sig = childSignature(el);
if (!hasRepeatedChildren(sig.hashes, sig.widths)) continue;
cand.push(el); cand.push(el);
if (cand.length >= 16) break; if (cand.length >= 8) break;
} }
const out: Array<{ cap: string; axis: "x"; pxPerSec: number; periodPx: number }> = []; const out: Array<{ cap: string; axis: "x"; pxPerSec: number; periodPx: number }> = [];
const seen = new Set<string>(); const seen = new Set<string>();
for (const el of cand) { for (const el of cand) {
const cap = el.getAttribute("data-cid-cap"); if (!cap || seen.has(cap)) continue; const cap = el.getAttribute("data-cid-cap"); if (!cap || seen.has(cap)) continue;
try { el.scrollIntoView({ block: "center" }); } catch { /* ignore */ } try { el.scrollIntoView({ block: "center" }); } catch { /* ignore */ }
await sleep(450); // wake the off-screen-paused ticker + let the scroll settle await sleep(300); // wake the off-screen-paused ticker + let the scroll settle
const xs: number[] = [];
for (let i = 0; i < 6; i++) { xs.push(txOf(el)); await sleep(120); } // Discriminator 2: two velocity windows ≥1.5s apart. A scroll-settle lerp decays;
const dxs: number[] = []; for (let i = 1; i < xs.length; i++) dxs.push(xs[i]! - xs[i - 1]!); // a real marquee holds constant velocity.
if (dxs.length < 3) continue; const w1 = await sampleWindow(el, WINDOW_SAMPLES, SAMPLE_MS);
// velocity = median per-120ms delta (median ignores the single wrap-reset outlier). await sleep(INTER_WINDOW_MS);
const med = [...dxs].sort((a, b) => a - b)[Math.floor(dxs.length / 2)]!; const w2 = await sampleWindow(el, WINDOW_SAMPLES, SAMPLE_MS);
const pxPerSec = Math.round((med / 120) * 1000); if (w1.length < 3 || w2.length < 3) continue;
if (Math.abs(pxPerSec) < 4) continue; // not actually moving (e.g. a frozen scroll-scrub offset) // direction must be consistent within window 1, but for (at most) the one wrap step.
// direction must be consistent in all but (at most) the one wrap step. const med1 = [...w1].sort((a, b) => a - b)[Math.floor(w1.length / 2)]!;
if (dxs.filter((d) => Math.sign(d) === Math.sign(med)).length < dxs.length - 1) continue; if (w1.filter((d) => Math.sign(d) === Math.sign(med1)).length < w1.length - 1) continue;
const cls = classifyVelocitySamples(w1, w2, SAMPLE_MS);
if (!cls.isMarquee) continue;
const pxPerSec = cls.pxPerSec;
// Discriminator 3: scroll independence (jiggle). Nudge the scroll ±50px and re-sample
// velocity; a self-driven ticker is unaffected, a scroll-linked animation changes.
const baseY = window.scrollY;
const jiggle = async (dy: number): Promise<number> => {
try { window.scrollTo(0, Math.max(0, baseY + dy)); } catch { /* ignore */ }
await sleep(250); // let any scroll-linked easing react and settle at the new offset
const dxs = await sampleWindow(el, WINDOW_SAMPLES, SAMPLE_MS);
return medianVelocityPxPerSec(dxs, SAMPLE_MS);
};
const vDown = await jiggle(JIGGLE_PX);
const vUp = await jiggle(-JIGGLE_PX);
try { window.scrollTo(0, baseY); } catch { /* ignore */ }
// Scroll-linked motion responds to the nudge (velocity reverses, dies, or spikes as
// it re-lerps to the new target). A real marquee holds ~pxPerSec through both nudges.
const stableUnderJiggle = (v: number): boolean =>
Math.sign(v) === Math.sign(pxPerSec) && Math.abs(v) >= Math.abs(pxPerSec) * 0.5 && Math.abs(v) <= Math.abs(pxPerSec) * 2;
if (!stableUnderJiggle(vDown) || !stableUnderJiggle(vUp)) continue;
const periodPx = Math.round(el.scrollWidth / 2); const periodPx = Math.round(el.scrollWidth / 2);
if (periodPx < 40) continue; if (periodPx < 40) continue;
seen.add(cap); seen.add(cap);
@@ -305,8 +482,11 @@ export async function captureMotion(page: Page, opts?: { observeMs?: number; log
const cap = el && (el as Element).getAttribute?.("data-cid-cap"); const cap = el && (el as Element).getAttribute?.("data-cid-cap");
if (!cap) continue; if (!cap) continue;
const elem = el as Element; const elem = el as Element;
// only track small text-bearing elements (a rotating word/phrase, not a big block) // only track small text-bearing elements (a rotating word/phrase, not a big block).
if (elem.querySelector("[data-cid-cap]")) continue; // has element children → not a leaf word // Reject ANY element child, capped or not: rows injected at runtime (after cid-cap
// tagging, so uncapped) would otherwise defeat a capped-only check and let a
// multi-element panel pass as a "leaf word".
if (elem.firstElementChild) continue; // has element children → not a leaf word
const txt = norm(elem.textContent || ""); const txt = norm(elem.textContent || "");
if (!txt || txt.length > 80) continue; if (!txt || txt.length > 80) continue;
const e = changes.get(cap) ?? { texts: [], times: [] }; const e = changes.get(cap) ?? { texts: [], times: [] };
+267 -16
View File
@@ -44,6 +44,18 @@ export type RawNode = {
computed: RawStyle; computed: RawStyle;
bbox: RawBBox; bbox: RawBBox;
visible: boolean; visible: boolean;
// A font-metric / measurement scratch node injected by the SOURCE site's own JS (typography
// libraries, FontFaceObserver): absolutely positioned, parked far off-screen, and non-painting.
// Never user-visible; excluded from emission so it doesn't ship as page markup.
probe?: boolean;
// This element hosts an OPEN shadow root: its serialized children are the composed (flattened)
// shadow tree — the shadowRoot's children with each <slot> replaced by its assigned light-DOM
// nodes — NOT the host's light-DOM children. Custom elements (`<product-info>`) render all their
// swatches/title/price inside this shadow tree, so a childNodes-only walk captured them empty.
shadowHost?: boolean;
// This node was serialized from inside a shadow tree (a shadowRoot descendant, or the shadow
// subtree of a slotted light node). Tagged so generation can emit it as ordinary light DOM.
inShadow?: boolean;
sizing?: RawSizing; sizing?: RawSizing;
before?: RawStyle; before?: RawStyle;
after?: RawStyle; after?: RawStyle;
@@ -51,6 +63,10 @@ export type RawNode = {
// clone renders the browser's default gray, losing the authored placeholder color/type. // clone renders the browser's default gray, losing the authored placeholder color/type.
placeholder?: RawStyle; placeholder?: RawStyle;
rawHTML?: string; // set for inline <svg> rawHTML?: string; // set for inline <svg>
// Computed paint of an inline <svg> root (fill/stroke/color). A raw `fill="none"` attribute may
// still resolve to a real paint via site CSS (`fill: currentColor`); these resolved values let
// codegen recover a paint the extraction stripped. Set only for svg roots.
svgPaint?: { fill: string; stroke: string; color: string };
children: RawChild[]; children: RawChild[];
}; };
@@ -64,6 +80,10 @@ export type FontFace = {
display?: string; display?: string;
unicodeRange?: string; unicodeRange?: string;
stretch?: string; stretch?: string;
// Url of the stylesheet this face was declared in (undefined for an inline <style>, or for faces
// parsed out-of-band where the base is already baked into `src`). The src descriptor's relative
// url()s resolve against this, not the document — see readRules and parseSrcUrls.
baseHref?: string;
}; };
export type PageSnapshot = { export type PageSnapshot = {
@@ -134,6 +154,11 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
"letterSpacing", "wordSpacing", "textAlign", "textTransform", "letterSpacing", "wordSpacing", "textAlign", "textTransform",
"textDecorationLine", "textDecorationColor", "textDecorationStyle", "textDecorationLine", "textDecorationColor", "textDecorationStyle",
"whiteSpace", "wordBreak", "overflowWrap", "textOverflow", "textIndent", "whiteSpace", "wordBreak", "overflowWrap", "textOverflow", "textIndent",
// Modern line-wrapping: `text-wrap: balance/pretty` rebalances heading line breaks
// (getComputedStyle reports the shorthand — "balance"/"pretty"/"wrap"). Without it a
// balanced heading wraps differently in the clone (an even two-line title collapses
// to a lopsided break). Default "wrap" is elided downstream.
"textWrap",
"textShadow", "fontVariantCaps", "fontFeatureSettings", "textShadow", "fontVariantCaps", "fontFeatureSettings",
// Line clamping (`display:-webkit-box; -webkit-box-orient:vertical; -webkit-line-clamp:N`): // Line clamping (`display:-webkit-box; -webkit-box-orient:vertical; -webkit-line-clamp:N`):
// the mechanism that keeps cards equal height regardless of text length. Without it the engine // the mechanism that keeps cards equal height regardless of text length. Without it the engine
@@ -286,7 +311,24 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
return true; return true;
}; };
const serializeElement = (el: Element): RawNode | null => { // A measurement/probe scratch node: out-of-flow (absolute/fixed), parked far off-screen
// (≥10000px beyond any edge — real drawers/sr-only content never live out there), AND
// non-painting (visibility:hidden / collapse / opacity:0). The two together are exclusive to
// font-metric / measurement scratch elements the source's own JS injects (a11y sr-only text
// stays visibility:visible so AT can read it, so it never matches). Tagged so emission drops it.
const OFFSCREEN_PROBE_PX = 10000;
const isProbe = (cs: CSSStyleDeclaration, bbox: RawBBox): boolean => {
if (cs.position !== "absolute" && cs.position !== "fixed") return false;
const nonPainting = cs.visibility === "hidden" || cs.visibility === "collapse" || parseFloat(cs.opacity || "1") === 0;
if (!nonPainting) return false;
const rightEdge = bbox.x + bbox.width;
const bottomEdge = bbox.y + bbox.height;
const pageH = round2(scrollEl.scrollHeight);
return rightEdge <= -OFFSCREEN_PROBE_PX || bottomEdge <= -OFFSCREEN_PROBE_PX
|| bbox.x >= OFFSCREEN_PROBE_PX + vpW || bbox.y >= OFFSCREEN_PROBE_PX + pageH;
};
const serializeElement = (el: Element, inShadow = false): RawNode | null => {
if (nodeCount >= MAX_NODES) { truncated = true; return null; } if (nodeCount >= MAX_NODES) { truncated = true; return null; }
const tag = el.tagName.toLowerCase(); const tag = el.tagName.toLowerCase();
nodeCount++; nodeCount++;
@@ -359,9 +401,36 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
const hf = el.getBoundingClientRect().height; const hf = el.getBoundingClientRect().height;
// Tight 0.5px: only call a dimension "reproduced" when auto/100% lands essentially exactly, // Tight 0.5px: only call a dimension "reproduced" when auto/100% lands essentially exactly,
// so a drop can't accumulate a visible shift across many elements (favours fidelity). // so a drop can't accumulate a visible shift across many elements (favours fidelity).
let hAuto = Math.abs(ha - r.height) <= 0.5;
let hFill = Math.abs(hf - r.height) <= 0.5;
// Circular-height guard: a box whose fill child (height:100%) pins it back up makes BOTH
// `height:auto` and `height:100%` reproduce the box, so the raw verdict reads hAuto (drop) —
// even though the height is authored (e.g. `100vh` on a hero, an explicit px section). Both
// sides then wait on each other and the box collapses. When this element's own cascade/inline
// style declares an explicit definite height, trust that declaration: the height is authored,
// so it is neither content-sized (hAuto) nor a parent fill (hFill). Only overrides when auto
// actually reproduced — a genuine explicit height that auto already shrinks stays hAuto:false.
if ((hAuto || hFill) && r.height > 2 && authorsExplicitHeight(el, sh)) {
hAuto = false;
hFill = false;
}
let wAuto = Math.abs(wa - r.width) <= 0.5;
let wFill = Math.abs(wf - r.width) <= 0.5;
// Circular-WIDTH guard (the mirror of the height guard above): an authored `width:24px` inside a
// SHRINK-TO-FIT parent (a color-swatch link in a span that shrink-wraps to it) makes BOTH
// `width:auto` and `width:100%` reproduce the box — the parent's width still holds while the
// child re-measures — so the raw verdict reads wAuto/wFill (drop) and the clone collapses the
// child to 0 content width (and the shrink-wrap span to its borders). When this element's own
// cascade/inline style declares an explicit definite width, trust it: the width is authored, so
// it is neither content-sized (wAuto) nor a parent fill (wFill). Only overrides when a probe
// actually reproduced — a genuine explicit width that auto already shrinks stays wAuto:false.
if ((wAuto || wFill) && r.width > 2 && authorsExplicitWidth(el, sw)) {
wAuto = false;
wFill = false;
}
sizing = { sizing = {
wAuto: Math.abs(wa - r.width) <= 0.5, wFill: Math.abs(wf - r.width) <= 0.5, hAuto: Math.abs(ha - r.height) <= 0.5, wAuto, wFill, hAuto,
hFill: Math.abs(hf - r.height) <= 0.5, hFill,
wMin: Math.round(wmin * 100) / 100, wMax: Math.round(wmax * 100) / 100, wMin: Math.round(wmin * 100) / 100, wMax: Math.round(wmax * 100) / 100,
}; };
} finally { } finally {
@@ -401,6 +470,8 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
computed, computed,
bbox, bbox,
visible: isVisible(el, cs, bbox), visible: isVisible(el, cs, bbox),
...(isProbe(cs, bbox) ? { probe: true } : {}),
...(inShadow ? { inShadow: true } : {}),
...(sizing ? { sizing } : {}), ...(sizing ? { sizing } : {}),
children: [], children: [],
}; };
@@ -429,6 +500,14 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
// Inline SVG → raw markup, no recursion. // Inline SVG → raw markup, no recursion.
if (tag === "svg") { if (tag === "svg") {
node.rawHTML = el.outerHTML; node.rawHTML = el.outerHTML;
// Capture the svg root's COMPUTED paint (fill/stroke/color) separately from the general
// computed-prop list. A raw `fill="none"` presentation attribute paints nothing on its own; a
// wordmark/icon is visible on the source only because site CSS (a `fill: currentColor` class,
// an inherited `color`) overrides it. Extraction strips that CSS, so the raw attribute alone is
// misleading — codegen consults these resolved values to decide whether the root truly paints.
try {
node.svgPaint = { fill: cs.fill, stroke: cs.stroke, color: cs.color };
} catch { /* getComputedStyle already read above; guard against exotic UAs */ }
return node; return node;
} }
@@ -436,8 +515,28 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
// code blocks with highlighted spans separated by newlines). // code blocks with highlighted spans separated by newlines).
const preserveWs = /^(pre|pre-wrap|break-spaces)/.test(cs.whiteSpace || ""); const preserveWs = /^(pre|pre-wrap|break-spaces)/.test(cs.whiteSpace || "");
// Composed (flattened) child list — the tree the user actually sees, matching what
// getComputedStyle/getBoundingClientRect already report for every node:
// - An open shadow HOST renders its shadow tree, not its light children: iterate the
// shadowRoot's children and tag them (and the host) so generation emits them as light DOM.
// - A <slot> is a placeholder for the light-DOM nodes distributed into it: replace it with its
// assignedNodes (or its own fallback children when nothing is assigned). Assigned nodes are
// the author's real light content, so they are NOT tagged inShadow — only genuine shadow
// descendants are. Walking the shadow tree (never the host's raw light children) means a
// slotted child is serialized once, at the slot position, with no double-emission.
const sr = (el as HTMLElement).shadowRoot; // open roots only; closed roots are null here
let childInShadow = inShadow;
let childNodes: Node[];
if (sr) {
node.shadowHost = true;
childInShadow = true;
childNodes = Array.from(sr.childNodes);
} else {
childNodes = Array.from(el.childNodes);
}
// Recurse children (elements + text nodes), preserving order. // Recurse children (elements + text nodes), preserving order.
for (const child of Array.from(el.childNodes)) { for (const child of childNodes) {
if (child.nodeType === Node.TEXT_NODE) { if (child.nodeType === Node.TEXT_NODE) {
const t = child.textContent || ""; const t = child.textContent || "";
if (preserveWs && t.length > 0) { if (preserveWs && t.length > 0) {
@@ -457,8 +556,28 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
} }
if (child.nodeType !== Node.ELEMENT_NODE) continue; if (child.nodeType !== Node.ELEMENT_NODE) continue;
const childEl = child as Element; const childEl = child as Element;
if (SKIP_TAGS.has(childEl.tagName.toLowerCase())) continue; const childTag = childEl.tagName.toLowerCase();
const sn = serializeElement(childEl); if (SKIP_TAGS.has(childTag)) continue;
// A slot nested deeper inside a shadow tree: expand its assigned light nodes in place. The
// assigned nodes are light DOM (author content), so they drop back to inShadow=false.
if (childTag === "slot" && childInShadow) {
const assigned = (childEl as HTMLSlotElement).assignedNodes({ flatten: true });
const slotKids = assigned.length ? assigned : Array.from(childEl.childNodes);
for (const sk of slotKids) {
if (sk.nodeType === Node.TEXT_NODE) {
const t = sk.textContent || "";
if (t.trim().length > 0) node.children.push({ text: t });
continue;
}
if (sk.nodeType !== Node.ELEMENT_NODE) continue;
const skEl = sk as Element;
if (SKIP_TAGS.has(skEl.tagName.toLowerCase())) continue;
const skn = serializeElement(skEl, assigned.length ? false : true);
if (skn) node.children.push(skn);
}
continue;
}
const sn = serializeElement(childEl, childInShadow);
if (sn) node.children.push(sn); if (sn) node.children.push(sn);
} }
@@ -470,22 +589,107 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
const cssUrlSet = new Set<string>(); const cssUrlSet = new Set<string>();
const keyframes: string[] = []; const keyframes: string[] = [];
const cssVars: Record<string, string> = {}; const cssVars: Record<string, string> = {};
// Selectors that AUTHOR an explicit, definite height/min-height (px/rem/em/vh/vw/…,
// NOT auto, NOT a percentage, NOT a keyword). Harvested once from the cascade below and
// consulted by the sizing probe to break circular parent/child height verdicts: when a box
// and its fill child mutually justify each other's height, `height:auto` reproduces the box
// for a reason that is NOT "content-sized", so the probe alone reads hAuto:true and the
// authored dimension gets dropped downstream. A declared explicit length is ground truth that
// the height is load-bearing, so we trust the declaration over the reflow verdict.
const explicitHeightSelectors: string[] = [];
// The WIDTH twin of explicitHeightSelectors: selectors that author an explicit definite `width`/
// `min-width` (px/rem/em/…, not auto/%/keyword). Consulted by the sizing probe to break a CIRCULAR
// width verdict — an authored `width:24px` inside a shrink-to-fit parent reproduces at both auto and
// 100% (the parent's width still holds), so the probe alone reads wAuto and the authored width gets
// dropped, collapsing the box in the clone.
const explicitWidthSelectors: string[] = [];
const absUrl = (u: string): string => { const absUrl = (u: string, base?: string): string => {
try { return new URL(u, document.baseURI).href; } catch { return u; } try { return new URL(u, base || document.baseURI).href; } catch { return u; }
}; };
const harvestUrlsFromText = (text: string): void => { // A relative url() inside a stylesheet resolves against THAT STYLESHEET'S url, not the document —
// `url("../media/x.woff2")` in `/a/b/css/sheet.css` points at `/a/b/media/x.woff2`, which is a
// different file from the document-relative `/media/x.woff2`. Resolving font/image srcs against
// `document.baseURI` fetches the wrong path (often the SPA router's 200+HTML shell), so a harvested
// face src must carry its owning sheet's base. Walk up through `@import` nesting (parentStyleSheet
// via ownerRule) to the nearest sheet that actually has an href; an inline `<style>` has none, so
// it correctly falls back to the document base.
const sheetBaseHref = (sheet: CSSStyleSheet | null | undefined): string | undefined => {
let s: CSSStyleSheet | null | undefined = sheet;
// Bound the walk defensively; @import chains are shallow in practice.
for (let i = 0; s && i < 32; i++) {
if (s.href) return s.href;
s = s.ownerRule?.parentStyleSheet ?? null;
}
return undefined;
};
const harvestUrlsFromText = (text: string, base?: string): void => {
const re = /url\(\s*(['"]?)([^'")]+)\1\s*\)/g; const re = /url\(\s*(['"]?)([^'")]+)\1\s*\)/g;
let m: RegExpExecArray | null; let m: RegExpExecArray | null;
while ((m = re.exec(text)) !== null) { while ((m = re.exec(text)) !== null) {
const raw = m[2]; const raw = m[2];
if (!raw || raw.startsWith("data:")) continue; if (!raw || raw.startsWith("data:")) continue;
cssUrlSet.add(absUrl(raw)); cssUrlSet.add(absUrl(raw, base));
} }
}; };
const readRules = (rules: CSSRuleList): void => { // True when a `height`/`min-height` value is an explicit, definite length the browser resolves
// to a fixed px box (px/rem/em/vh/vw/vmin/vmax/ch/…, or a calc/min/max/clamp over them). False for
// `auto`, an empty value, a pure percentage (resolves against the parent — that's the fill case the
// hFill probe already handles), `0`, and intrinsic keywords (fit-/min-/max-content). A definite
// authored height is load-bearing and must survive even when the reflow probe reads hAuto:true.
const isExplicitHeight = (raw: string): boolean => {
const v = (raw || "").trim().toLowerCase();
if (!v || v === "auto" || v === "0" || v === "0px" || v === "none") return false;
if (v === "fit-content" || v === "min-content" || v === "max-content" || v === "inherit" ||
v === "initial" || v === "unset" || v === "revert" || v === "revert-layer") return false;
// A bare percentage resolves against the parent (fill), not an authored definite length.
if (/^[\d.]+%$/.test(v)) return false;
// A definite length unit (or a calc()/min()/max()/clamp() that contains one) anchors the box.
return /(?:^|[\s(*/+-])[\d.]+(?:px|rem|em|vh|vw|vmin|vmax|svh|lvh|dvh|cm|mm|in|pt|pc|ex|ch|q)\b/.test(v);
};
// Does this element author an explicit definite height — via its own inline style (passed as
// `inlineHeight`, already read by the probe) or via any matched cascade rule harvested into
// `explicitHeightSelectors`? getComputedStyle resolves height to used px (so `100vh` reads as a
// plain number and is indistinguishable from a content height there); the specified value is only
// recoverable from the inline declaration and the cascade, which is why we consult both.
const authorsExplicitHeight = (el: Element, inlineHeight: string): boolean => {
if (isExplicitHeight(inlineHeight)) return true;
try {
const inlineMin = (el as HTMLElement).style?.getPropertyValue("min-height") || "";
if (isExplicitHeight(inlineMin)) return true;
} catch { /* ignore */ }
for (const sel of explicitHeightSelectors) {
try { if (el.matches(sel)) return true; } catch { /* invalid/unsupported selector */ }
}
return false;
};
// The WIDTH twin of authorsExplicitHeight: does this element author an explicit definite width via
// its own inline style (`inlineWidth`, already read by the probe) or any matched harvested rule?
// `isExplicitHeight` is unit-agnostic (it only rejects auto/%/keywords and matches definite lengths),
// so it doubles as the width predicate.
const authorsExplicitWidth = (el: Element, inlineWidth: string): boolean => {
if (isExplicitHeight(inlineWidth)) return true;
try {
const inlineMin = (el as HTMLElement).style?.getPropertyValue("min-width") || "";
if (isExplicitHeight(inlineMin)) return true;
} catch { /* ignore */ }
for (const sel of explicitWidthSelectors) {
try { if (el.matches(sel)) return true; } catch { /* invalid/unsupported selector */ }
}
return false;
};
// `base` is the url of the stylesheet these rules live in (undefined for an inline <style>, which
// resolves against the document). It is threaded so every harvested url() — @font-face src and
// ordinary style-rule url() alike — resolves relative to its OWNING sheet, not the page. The src
// string is left verbatim on the FontFace (parseSrcUrls re-absolutizes it downstream against the
// same base), but the base still drives the cssUrlSet entry that triggers the download.
const readRules = (rules: CSSRuleList, base?: string): void => {
for (const rule of Array.from(rules)) { for (const rule of Array.from(rules)) {
const type = rule.constructor.name; const type = rule.constructor.name;
if (type === "CSSFontFaceRule") { if (type === "CSSFontFaceRule") {
@@ -502,26 +706,40 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
display: s.getPropertyValue("font-display") || undefined, display: s.getPropertyValue("font-display") || undefined,
unicodeRange: s.getPropertyValue("unicode-range") || undefined, unicodeRange: s.getPropertyValue("unicode-range") || undefined,
stretch: s.getPropertyValue("font-stretch") || undefined, stretch: s.getPropertyValue("font-stretch") || undefined,
baseHref: base,
}); });
harvestUrlsFromText(src); harvestUrlsFromText(src, base);
} }
} else if (type === "CSSKeyframesRule") { } else if (type === "CSSKeyframesRule") {
keyframes.push((rule as CSSKeyframesRule).cssText); keyframes.push((rule as CSSKeyframesRule).cssText);
} else if (type === "CSSStyleRule") { } else if (type === "CSSStyleRule") {
const r = rule as CSSStyleRule; const r = rule as CSSStyleRule;
if (r.style && r.style.cssText.includes("url(")) harvestUrlsFromText(r.style.cssText); if (r.style && r.style.cssText.includes("url(")) harvestUrlsFromText(r.style.cssText, base);
if (r.selectorText && r.style &&
(isExplicitHeight(r.style.getPropertyValue("height")) ||
isExplicitHeight(r.style.getPropertyValue("min-height")))) {
explicitHeightSelectors.push(r.selectorText);
}
if (r.selectorText && r.style &&
(isExplicitHeight(r.style.getPropertyValue("width")) ||
isExplicitHeight(r.style.getPropertyValue("min-width")))) {
explicitWidthSelectors.push(r.selectorText);
}
} else if (type === "CSSMediaRule" || type === "CSSSupportsRule") { } else if (type === "CSSMediaRule" || type === "CSSSupportsRule") {
try { readRules((rule as CSSGroupingRule).cssRules); } catch { /* ignore */ } // Nested grouping rules live in the same sheet — keep the base.
try { readRules((rule as CSSGroupingRule).cssRules, base); } catch { /* ignore */ }
} else if (type === "CSSImportRule") { } else if (type === "CSSImportRule") {
// The imported sheet is a separate file: its rules resolve against ITS url, falling back to
// the importing sheet's base only if the imported sheet reports none.
const imp = rule as CSSImportRule; const imp = rule as CSSImportRule;
try { if (imp.styleSheet) readRules(imp.styleSheet.cssRules); } catch { /* cross-origin */ } try { if (imp.styleSheet) readRules(imp.styleSheet.cssRules, sheetBaseHref(imp.styleSheet) ?? base); } catch { /* cross-origin */ }
} }
} }
}; };
for (const sheet of Array.from(document.styleSheets)) { for (const sheet of Array.from(document.styleSheets)) {
try { try {
readRules(sheet.cssRules); readRules(sheet.cssRules, sheetBaseHref(sheet));
} catch { } catch {
// Cross-origin sheet — record its href so the capture layer can fetch the // Cross-origin sheet — record its href so the capture layer can fetch the
// raw text out-of-band and parse font-faces/urls from it. // raw text out-of-band and parse font-faces/urls from it.
@@ -529,6 +747,39 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
} }
} }
// Custom elements (web components) carry their styles INSIDE their open shadow root — via
// `adoptedStyleSheets` (constructable sheets) and/or `<style>`/`<link>` in the shadow tree
// (`shadowRoot.styleSheets`). Those sheets never appear in `document.styleSheets`, so an authored
// rule like `color-swatch a { width: 24px }` is invisible to the harvest above and the sizing probe
// can't see the explicit width. Walk every open shadow root (composed-tree sweep, matching the
// serializer) and read its sheets too — same `readRules` (font-faces, url()s, explicit width/height
// selectors). Closed roots return null and are skipped, exactly as elsewhere.
const shadowRoots: ShadowRoot[] = [];
const collectShadowRoots = (): void => {
const stack: Element[] = [];
const pushChildren = (parent: Element | Document | ShadowRoot): void => {
let c = parent.firstElementChild;
while (c) { stack.push(c); c = c.nextElementSibling; }
};
pushChildren(document);
// Depth-first over the composed element tree, descending into every open shadow root.
while (stack.length) {
const node = stack.pop()!;
const sr = (node as HTMLElement).shadowRoot;
if (sr) { shadowRoots.push(sr); pushChildren(sr); }
pushChildren(node);
}
};
try { collectShadowRoots(); } catch { /* ignore */ }
for (const sr of shadowRoots) {
const sheets: CSSStyleSheet[] = [];
try { sheets.push(...(sr.adoptedStyleSheets || [])); } catch { /* ignore */ }
try { sheets.push(...Array.from(sr.styleSheets || [])); } catch { /* ignore */ }
for (const sheet of sheets) {
try { readRules(sheet.cssRules, sheetBaseHref(sheet)); } catch { if ((sheet as CSSStyleSheet).href) cssUrlSet.add(absUrl((sheet as CSSStyleSheet).href!)); }
}
}
// CSS custom properties declared on :root. // CSS custom properties declared on :root.
try { try {
const rootCs = window.getComputedStyle(document.documentElement); const rootCs = window.getComputedStyle(document.documentElement);
+290 -39
View File
@@ -1,7 +1,7 @@
import { join } from "node:path"; import { join } from "node:path";
import { rmSync } from "node:fs"; import { rmSync } from "node:fs";
import { writeText } from "../util/fsx.js"; import { writeText } from "../util/fsx.js";
import type { IR, IRNode, IRChild, IRTextNode } from "../normalize/ir.js"; import type { IR, IRNode, IRChild, IRTextNode, StyleMap } from "../normalize/ir.js";
import { isTextChild } from "../normalize/ir.js"; import { isTextChild } from "../normalize/ir.js";
import { generateCss, RESET_CSS } from "./css.js"; import { generateCss, RESET_CSS } from "./css.js";
import { generateInteractionCss } from "./interactionCss.js"; import { generateInteractionCss } from "./interactionCss.js";
@@ -17,7 +17,7 @@ import { buildClassMap } from "./classMap.js";
import { buildTailwind, tailwindGlobalsCss } from "./tailwind.js"; import { buildTailwind, tailwindGlobalsCss } from "./tailwind.js";
import { planSections, type SectionPlan } from "./sectionSplit.js"; import { planSections, type SectionPlan } from "./sectionSplit.js";
import type { RecipeReport } from "../infer/recipes.js"; import type { RecipeReport } from "../infer/recipes.js";
import { emitSeoAssetFiles, emitSeoRoutes, jsonLdHeadMarkup, metadataExport, routeSummaryFromIr, seoStaticFiles, viewportExport, type SeoInventory } from "./seo.js"; import { emitSeoAssetFiles, emitSeoRoutes, jsonLdHeadMarkup, metadataExport, routeSummaryFromIr, seoStaticFiles, siteOriginImportLine, SITE_ORIGIN_LAYOUT_IMPORT, SITE_ORIGIN_MODULE, viewportExport, type SeoInventory } from "./seo.js";
import { emitGeneratedDocs } from "./docs.js"; import { emitGeneratedDocs } from "./docs.js";
const VOID_TAGS = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"]); const VOID_TAGS = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"]);
@@ -282,6 +282,17 @@ function jsxText(raw: string): string {
return `{${escapeText(raw)}}`; return `{${escapeText(raw)}}`;
} }
/** Collapse a text run under `white-space: normal` the way CSS renders it: every run of
* whitespace (captured \n\t indentation, doubled spaces) becomes a single space, so the
* markup carries content — not the source file's frozen formatting. Leading/trailing single
* spaces are KEPT (JSX-significant for inline flow); the boundary is preserved so `word <b>x</b>`
* keeps its space. A run that is entirely whitespace collapses to a single space. The text gate
* compares whitespace-normalized (gates.ts:normText), so this stays faithful. */
function collapseWs(raw: string): string {
const collapsed = raw.replace(/\s+/g, " ");
return collapsed;
}
/** The ordered [propKey, valueExpr] list a node emits — rendered to clean JSX attributes /** The ordered [propKey, valueExpr] list a node emits — rendered to clean JSX attributes
* by renderAttrs. Each valueExpr is ready-to-emit JS source (a JSON literal, * by renderAttrs. Each valueExpr is ready-to-emit JS source (a JSON literal,
* `true`, or a `{ __html: … }` object). Component extraction reuses this so the * `true`, or a `{ __html: … }` object). Component extraction reuses this so the
@@ -358,11 +369,20 @@ export function propsList(node: IRNode, assetMap: Map<string, string>, sourceUrl
if (kept.length === 0) continue; if (kept.length === 0) continue;
value = kept.join(", "); value = kept.join(", ");
} else if (key === "href") { } else if (key === "href") {
// A `javascript:*` href (announcement bars, JS-driven buttons authored as links) is a
// script trigger, not a navigable URL. React refuses to render one: it rewrites the
// attribute to a long `javascript:throw new Error('React has blocked a javascript: URL…')`
// string, which no longer matches the source href in the link gate. The behaviour is
// script we don't reproduce anyway, so emit an inert `#` and keep the anchor navigable-inert.
if (/^\s*javascript:/i.test(value)) {
value = "#";
} else if (!value.startsWith("#")) {
// Preserve in-page anchors. For multi-route sites a linkRewrite maps internal // Preserve in-page anchors. For multi-route sites a linkRewrite maps internal
// links to the generated clone routes (and collapsed-collection links to their // links to the generated clone routes (and collapsed-collection links to their
// representative); otherwise absolutize so it never 404s inside the clone // representative); otherwise absolutize so it never 404s inside the clone
// (external navigation is allowed by the rubric). // (external navigation is allowed by the rubric).
if (!value.startsWith("#")) value = ctx?.linkRewrite ? ctx.linkRewrite(value) : resolveUrl(value, sourceUrl); value = ctx?.linkRewrite ? ctx.linkRewrite(value) : resolveUrl(value, sourceUrl);
}
} }
let reactName = isCustom ? key : (ATTR_RENAME[key] ?? key); let reactName = isCustom ? key : (ATTR_RENAME[key] ?? key);
@@ -380,24 +400,44 @@ export function propsList(node: IRNode, assetMap: Map<string, string>, sourceUrl
if (isVideo && !videoLocal && !props.some(([k]) => k === "preload")) props.push(["preload", JSON.stringify("none")]); if (isVideo && !videoLocal && !props.some(([k]) => k === "preload")) props.push(["preload", JSON.stringify("none")]);
// A canvas emitted as its raster still (<img> — see resolveTag) is decorative
// painted surface with no captured alt text; emit alt="" so the img is valid.
if (node.tag === "canvas" && node.attrs.src && !props.some(([k]) => k === "alt")) props.push(["alt", JSON.stringify("")]);
if (node.rawHTML && node.tag === "svg") { if (node.rawHTML && node.tag === "svg") {
// Strip the Stage-4 capture-id (`data-cid-cap`) the interaction pass stamps on // Strip the Stage-4 capture-id (`data-cid-cap`) the interaction pass stamps on
// elements: it's internal instrumentation, render-inert, and would otherwise // elements: it's internal instrumentation, render-inert, and would otherwise
// surface verbatim in the markup and (post-extraction) as a bogus data field. // surface verbatim in the markup and (post-extraction) as a bogus data field.
const inner = svgInnerForNode(node, ctx); const inner = svgInnerForNode(node, ctx);
const svgAttrs = extractSvgAttrs(node.rawHTML); const svgAttrs = extractSvgAttrs(node.rawHTML);
let hasFillAttr = false; let rawFill: string | undefined;
for (const [k, v] of svgAttrs) { for (const [k, v] of svgAttrs) {
if (k.toLowerCase() === "fill") hasFillAttr = true; if (k.toLowerCase() === "fill") { rawFill = v; continue; } // the root fill is resolved below, not copied verbatim
if (k === "class" || k === "style" || k === "data-cid-cap" || k.includes(":")) continue; if (k === "class" || k === "style" || k === "data-cid-cap" || k.includes(":")) continue;
const reactName = SVG_ATTR_RENAME[k] ?? k; const reactName = SVG_ATTR_RENAME[k] ?? k;
const propKey = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(reactName) ? reactName : JSON.stringify(reactName); const propKey = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(reactName) ? reactName : JSON.stringify(reactName);
if (!props.some(([pk]) => pk === propKey)) props.push([propKey, JSON.stringify(v)]); if (!props.some(([pk]) => pk === propKey)) props.push([propKey, JSON.stringify(v)]);
} }
// Raw SVG icons often rely on site CSS (`svg { fill: currentColor }` or a class) that is // Reconcile the root fill against the captured COMPUTED paint. A raw `fill="none"` only looks
// stripped during extraction. If the root didn't declare a fill, inherit the surrounding text // unfilled: site CSS (`fill: currentColor` on the wordmark, an inherited color) may actually
// color so monochrome wordmarks/icons don't fall back to the browser's black default. // paint it, and that CSS is stripped during extraction. resolveSvgRootFill recovers the real
if (!hasFillAttr && !props.some(([pk]) => pk === "fill")) props.push(["fill", JSON.stringify("currentColor")]); // paint — as currentColor when the computed fill tracks the element's color (emit that color
// too), else the literal value — and leaves a genuinely unfilled svg as fill="none". When the
// root declared no fill at all we fall back to currentColor so monochrome icons don't drop to
// the browser's black default.
if (!props.some(([pk]) => pk === "fill")) {
const resolved = resolveSvgRootFill(rawFill, node.svgPaint);
if (resolved.mode === "keep") {
if (rawFill !== undefined) props.push(["fill", JSON.stringify(rawFill)]);
} else if (resolved.mode === "emit") {
props.push(["fill", JSON.stringify(resolved.value!)]);
if (resolved.emitColor && !props.some(([pk]) => pk === "color")) {
props.push(["color", JSON.stringify(resolved.emitColor)]);
}
} else {
props.push(["fill", JSON.stringify("currentColor")]);
}
}
props.push(["dangerouslySetInnerHTML", `{ __html: ${JSON.stringify(inner)} }`]); props.push(["dangerouslySetInnerHTML", `{ __html: ${JSON.stringify(inner)} }`]);
} }
@@ -445,6 +485,50 @@ function extractSvgAttrs(outerHTML: string): Array<[string, string]> {
return out; return out;
} }
/** A resolved paint value is "real" (paints something) when it is neither absent nor an explicit
* non-paint (`none`) nor fully transparent. `currentColor` counts as real (it paints the inherited
* color). Case/whitespace-insensitive. */
export function isRealPaint(value: string | null | undefined): boolean {
const v = (value ?? "").trim().toLowerCase();
if (!v || v === "none" || v === "transparent") return false;
if (v === "rgba(0, 0, 0, 0)" || v === "rgba(0,0,0,0)") return false;
return true;
}
/**
* Decide what `fill` an inline <svg> ROOT should emit, reconciling the raw `fill` attribute against
* the svg's COMPUTED paint (captured from the live source). Pure and deterministic.
*
* - Raw `fill` present and itself a real paint → keep it (`keep`): the author meant that fill.
* - Raw `fill="none"` but the computed fill IS a real paint → the root only looked unfilled because
* `fill="none"` is a presentation attribute the site's CSS overrode. Recover the real paint:
* emit `currentColor` when the computed fill equals the computed `color` (a `fill: currentColor`
* class — the common wordmark case; caller must also emit `color`), else the literal computed fill.
* - Raw `fill="none"` and computed fill also not a real paint → genuinely unfilled: keep `none`.
* - Raw `fill` absent → `fallback`: caller applies its existing currentColor default.
*/
export function resolveSvgRootFill(
rawFill: string | null | undefined,
computed?: { fill?: string; color?: string } | null,
): { mode: "keep" | "fallback" | "emit"; value?: string; emitColor?: string } {
const raw = (rawFill ?? "").trim();
if (raw && raw.toLowerCase() !== "none") return { mode: "keep" };
if (raw.toLowerCase() === "none") {
const cf = computed?.fill;
if (isRealPaint(cf)) {
const color = (computed?.color ?? "").trim();
// fill:currentColor resolves to the element's color; recover it as currentColor so the clone
// tracks whatever color the surrounding CSS supplies, and signal the caller to emit that color.
if (color && cf!.trim().toLowerCase() === color.toLowerCase() && isRealPaint(color)) {
return { mode: "emit", value: "currentColor", emitColor: color };
}
return { mode: "emit", value: cf!.trim() };
}
return { mode: "keep" }; // genuinely unfilled — leave fill="none"
}
return { mode: "fallback" };
}
// SVG presentation attributes that React requires in camelCase (kebab in JSX is silently // SVG presentation attributes that React requires in camelCase (kebab in JSX is silently
// dropped — and `fill-rule`/`clip-rule` being dropped changes how a path fills). Anything not // dropped — and `fill-rule`/`clip-rule` being dropped changes how a path fills). Anything not
// here that's already camel/lowercase (d, cx, fill, viewBox, opacity, transform, offset…) or a // here that's already camel/lowercase (d, cx, fill, viewBox, opacity, transform, offset…) or a
@@ -546,6 +630,12 @@ export function resolveTag(node: IRNode, insideInteractive: boolean, insideTable
// children inside <iframe> are unrendered fallback content, so emit a <div> container // children inside <iframe> are unrendered fallback content, so emit a <div> container
// carrying the iframe's box/styles (CSS is keyed by cid, so the geometry is identical). // carrying the iframe's box/styles (CSS is keyed by cid, so the geometry is identical).
if (tag === "iframe" && node.children.some((c) => !isTextChild(c))) tag = "div"; if (tag === "iframe" && node.children.some((c) => !isTextChild(c))) tag = "div";
// A canvas is runtime-drawn surface the clone cannot reproduce; when capture
// rasterized it (canvas-still synthetic URL stamped as `src` — see
// captureCanvasStillsInPage) emit the still as an <img> filling the canvas's box
// (CSS is keyed by cid, so the geometry is identical). A canvas with no still
// keeps rendering as an empty <canvas> box, same as before.
if (tag === "canvas" && node.attrs.src) tag = "img";
if (TABLE_SCOPED.has(tag) && !insideTable) tag = "div"; // orphan table element → neutral box if (TABLE_SCOPED.has(tag) && !insideTable) tag = "div"; // orphan table element → neutral box
if (violatesContentModel(node, tag)) tag = "div"; if (violatesContentModel(node, tag)) tag = "div";
return tag; return tag;
@@ -591,7 +681,7 @@ function renderNode(node: IRNode, assetMap: Map<string, string>, sourceUrl: stri
return `${pad}<${tag}${attrs} />`; return `${pad}<${tag}${attrs} />`;
} }
const childParts = emitChildren(node.children, tag, assetMap, sourceUrl, indent + 1, childInteractive, ctx, childTable); const childParts = emitChildren(node.children, tag, assetMap, sourceUrl, indent + 1, childInteractive, ctx, childTable, preservesWhitespace(node));
if (childParts.length === 0) { if (childParts.length === 0) {
return `${pad}<${tag}${attrs} />`; return `${pad}<${tag}${attrs} />`;
} }
@@ -602,7 +692,7 @@ function renderNode(node: IRNode, assetMap: Map<string, string>, sourceUrl: stri
* `{Name_data.map(...)}` call for any run of extracted-component instances. Shared * `{Name_data.map(...)}` call for any run of extracted-component instances. Shared
* by renderNode (parentTag is the element tag) and the body/chrome fragment * by renderNode (parentTag is the element tag) and the body/chrome fragment
* renderers (parentTag null → no element-only-parent whitespace rule). */ * renderers (parentTag null → no element-only-parent whitespace rule). */
function emitChildren(children: IRChild[], parentTag: string | null, assetMap: Map<string, string>, sourceUrl: string, indent: number, childInteractive: boolean, ctx?: RenderCtx, insideTable = false): string[] { function emitChildren(children: IRChild[], parentTag: string | null, assetMap: Map<string, string>, sourceUrl: string, indent: number, childInteractive: boolean, ctx?: RenderCtx, insideTable = false, preserveWs = false): string[] {
const pad = " ".repeat(indent); const pad = " ".repeat(indent);
const parts: string[] = []; const parts: string[] = [];
// Coalesce consecutive text children into one. Emitting them as separate JSX // Coalesce consecutive text children into one. Emitting them as separate JSX
@@ -612,7 +702,12 @@ function emitChildren(children: IRChild[], parentTag: string | null, assetMap: M
let textBuf = ""; let textBuf = "";
const flushText = () => { const flushText = () => {
if (parentTag && ELEMENT_ONLY_PARENTS.has(parentTag) && textBuf.trim() === "") { textBuf = ""; return; } if (parentTag && ELEMENT_ONLY_PARENTS.has(parentTag) && textBuf.trim() === "") { textBuf = ""; return; }
if (textBuf.length) parts.push(`${pad}${jsxText(textBuf)}`); // Under white-space:normal, collapse the captured formatting (\n\t runs, doubled/leading
// multi-space) to CSS-equivalent single spaces so markup ships content, not source-file
// indentation. A whitespace-only run collapses to a single significant space ({" "}) — the
// huge `{" "}` literals disappear. Preserve verbatim under pre/pre-wrap/pre-line.
const out = preserveWs ? textBuf : collapseWs(textBuf);
if (out.length) parts.push(`${pad}${jsxText(out)}`);
textBuf = ""; textBuf = "";
}; };
const reg = ctx?.components; const reg = ctx?.components;
@@ -708,12 +803,20 @@ function takeCid(coll: CidCollector, instances: IRNode[]): number {
return idx; return idx;
} }
/** A self-contained class-name merger emitted into each component module that needs it /** The single shared `cn()` module (`src/lib/utils.ts`), imported by every module that
* (kept import-free so component files stay standalone). Skips falsy parts and joins — * merges class names — one definition per clone instead of a copy per component file.
* exact for our output because a node's baked base classes and its per-instance overrides * Skips falsy parts and joins — exact for our output because a node's baked base classes
* are disjoint token sets. Swap in `tailwind-merge` if you want conflict-aware merging * and its per-instance overrides are disjoint token sets. Swap in `tailwind-merge` if you
* when hand-editing the ./_styles overrides. */ * want conflict-aware merging when hand-editing the ./_styles overrides. */
const CN_HELPER = `function cn(...parts: Array<string | false | null | undefined>) {\n return parts.filter(Boolean).join(" ");\n}`; export const CN_UTILS_MODULE = `export function cn(...parts: Array<string | false | null | undefined>) {\n return parts.filter(Boolean).join(" ");\n}\n`;
/** The `import { cn } from "…/lib/utils"` line a module emits when it references `cn(`.
* `depth` is how many directory levels the consuming file sits BELOW `src` (page.tsx in
* `src/app` → 1; a component in `src/app/components` → 2), so the relative path always
* resolves to the single `src/lib/utils`. */
export function cnImportLine(depth: number): string {
return `import { cn } from "${"../".repeat(Math.max(1, depth))}lib/utils";`;
}
/** Split a className value SOURCE (a JSON string literal as emitted by propsList) into its /** Split a className value SOURCE (a JSON string literal as emitted by propsList) into its
* whitespace-separated tokens. Returns [] for a non-string / unparseable source. */ * whitespace-separated tokens. Returns [] for a non-string / unparseable source. */
@@ -852,6 +955,15 @@ function canonicalViewportFor(n: IRNode): number {
return keys[0] ?? 1280; return keys[0] ?? 1280;
} }
/** Whether a node's `white-space` preserves captured whitespace verbatim (pre/pre-wrap/pre-line/
* break-spaces) — in which case emission must NOT collapse its text runs. `<pre>`/`<textarea>`
* default to pre even without an explicit declaration. Normal/nowrap collapse per CSS. */
function preservesWhitespace(n: IRNode): boolean {
const ws = (n.computedByVp[canonicalViewportFor(n)] ?? Object.values(n.computedByVp)[0])?.whiteSpace;
if (ws) return /^(pre|pre-wrap|pre-line|break-spaces)$/.test(ws);
return n.tag === "pre" || n.tag === "textarea";
}
type TextLeaf = { text: string; node: IRNode; index: number; ancestorTags: string[] }; type TextLeaf = { text: string; node: IRNode; index: number; ancestorTags: string[] };
function normalizeTextValue(text: string): string { function normalizeTextValue(text: string): string {
@@ -1594,7 +1706,8 @@ export function componentPreamble(reg: ComponentRegistry | undefined): string {
const cids = reg.cidDecls.map((c) => `const ${c.varName}: string[][] = ${c.body};`); const cids = reg.cidDecls.map((c) => `const ${c.varName}: string[][] = ${c.body};`);
const styles = reg.styleDecls.map((s) => `const ${s.varName} = ${s.body};`); const styles = reg.styleDecls.map((s) => `const ${s.varName} = ${s.body};`);
const fns = [...reg.funcDefs.values()]; const fns = [...reg.funcDefs.values()];
const cn = fns.some((f) => f.includes("cn(")) ? [CN_HELPER] : []; // Inlined into a chrome module at src/app (layout.tsx) or src/ (Chrome.tsx) — depth 1.
const cn = fns.some((f) => f.includes("cn(")) ? [cnImportLine(1)] : [];
const parts = [...cn, ...fns, ...data, ...cids, ...styles]; const parts = [...cn, ...fns, ...data, ...cids, ...styles];
return parts.length ? parts.join("\n\n") : ""; return parts.length ? parts.join("\n\n") : "";
} }
@@ -1818,13 +1931,13 @@ function describeComponent(name: string): string {
return map[base] ?? `${base.replace(/([a-z])([A-Z])/g, "$1 $2").toLowerCase()} component.`; return map[base] ?? `${base.replace(/([a-z])([A-Z])/g, "$1 $2").toLowerCase()} component.`;
} }
export function componentFiles(reg: ComponentRegistry | undefined, svgs?: SvgRegistry): Array<{ name: string; module: string }> { export function componentFiles(reg: ComponentRegistry | undefined, svgs?: SvgRegistry, depth = 2): Array<{ name: string; module: string }> {
if (!reg || reg.funcDefs.size === 0) return []; if (!reg || reg.funcDefs.size === 0) return [];
return [...reg.funcDefs].map(([name, src]) => { return [...reg.funcDefs].map(([name, src]) => {
const agg = reg.byName.get(name); const agg = reg.byName.get(name);
void agg; void agg;
const header = `/** ${describeComponent(name)} */`; const header = `/** ${describeComponent(name)} */`;
const cnDef = src.includes("cn(") ? CN_HELPER + "\n" : ""; const cnImport = src.includes("cn(") ? cnImportLine(depth) : "";
// The component's props-data type is DEFINED here (colocated), not imported from a central content // The component's props-data type is DEFINED here (colocated), not imported from a central content
// file — and exported so the section that supplies the data can type its array. Per-instance class // file — and exported so the section that supplies the data can type its array. Per-instance class
// overrides still come from _styles.ts (pure styling plumbing). // overrides still come from _styles.ts (pure styling plumbing).
@@ -1842,9 +1955,9 @@ export function componentFiles(reg: ComponentRegistry | undefined, svgs?: SvgReg
for (const c of scanRefs(src).comps) { for (const c of scanRefs(src).comps) {
if (svgs?.defs.has(c)) svgImports.push(`import ${c} from "../svgs/${svgFileBase(c)}";`); if (svgs?.defs.has(c)) svgImports.push(`import ${c} from "../svgs/${svgFileBase(c)}";`);
} }
const imports = [...typeImports, ...svgImports]; const imports = [...typeImports, ...svgImports, ...(cnImport ? [cnImport] : [])];
const importBlock = imports.length ? imports.join("\n") + "\n" : ""; const importBlock = imports.length ? imports.join("\n") + "\n" : "";
return { name, module: `${importBlock}${typeDef}${header}\n${cnDef}export default ${src}\n` }; return { name, module: `${importBlock}${typeDef}${header}\nexport default ${src}\n` };
}); });
} }
@@ -2082,6 +2195,7 @@ export function sectionFiles(sreg: SectionRegistry | undefined, reg: ComponentRe
]; ];
const params = paramParts.length ? `{ ${paramParts.join(", ")} } = {}` : ""; const params = paramParts.length ? `{ ${paramParts.join(", ")} } = {}` : "";
const header = `/** ${describeSection(name)} */`; const header = `/** ${describeSection(name)} */`;
if (/\bcn\(/.test(body)) lines.push(cnImportLine(2)); // sections live at src/app/sections
const importBlock = lines.length ? lines.join("\n") + "\n" : ""; const importBlock = lines.length ? lines.join("\n") + "\n" : "";
const allConsts = consts; const allConsts = consts;
const constBlock = allConsts.length ? allConsts.join("\n") + "\n" : ""; const constBlock = allConsts.length ? allConsts.join("\n") + "\n" : "";
@@ -2121,6 +2235,7 @@ export function generatePageTsx(ir: IR, assetMap: Map<string, string>, sourceUrl
hasMotion ? `import DittoMotion from "${dittoMotionImportPath(0)}";` : "", hasMotion ? `import DittoMotion from "${dittoMotionImportPath(0)}";` : "",
hasLottie ? `import DittoLottie from "${dittoLottieImportPath(0)}";` : "", hasLottie ? `import DittoLottie from "${dittoLottieImportPath(0)}";` : "",
hasMenus ? `import DropdownMenu from "${dropdownMenuImportPath(0)}";` : "", hasMenus ? `import DropdownMenu from "${dropdownMenuImportPath(0)}";` : "",
/\bcn\(/.test(body) ? cnImportLine(1) : "", // page.tsx lives at src/app
...compImports, ...compImports,
].filter(Boolean).join("\n"); ].filter(Boolean).join("\n");
const importBlock = imports ? imports + "\n\n" : ""; const importBlock = imports ? imports + "\n\n" : "";
@@ -2137,31 +2252,33 @@ ${body}${wiresBlock}${accordionBlock}${motionBlock}${lottieBlock}${menusBlock}
/** SEO scaffolding (Next App Router): robots.ts + sitemap.ts + an llms.txt route, generated /** SEO scaffolding (Next App Router): robots.ts + sitemap.ts + an llms.txt route, generated
* from the captured URL / title / description — the discovery files a real site ships. */ * from the captured URL / title / description — the discovery files a real site ships. */
function seoFiles(ir: IR): Array<[string, string]> { function seoFiles(ir: IR): Array<[string, string]> {
let origin = "https://example.com";
try { origin = new URL(ir.doc.sourceUrl).origin; } catch { /* keep default */ }
const url = ir.doc.sourceUrl || origin + "/";
const title = ir.doc.title || "Home"; const title = ir.doc.title || "Home";
const desc = ir.doc.head?.description || ""; const desc = ir.doc.head?.description || "";
// robots.ts / sitemap.ts live at src/app → depth 1 below src. URLs resolve against the
// clone's own origin (SITE_ORIGIN, relative by default), never the source domain.
const siteImport = siteOriginImportLine(1);
const robots = `import type { MetadataRoute } from "next"; const robots = `import type { MetadataRoute } from "next";
${siteImport}
export const dynamic = "force-static"; export const dynamic = "force-static";
export default function robots(): MetadataRoute.Robots { export default function robots(): MetadataRoute.Robots {
return { return {
rules: { userAgent: "*", allow: "/" }, rules: { userAgent: "*", allow: "/" },
sitemap: ${JSON.stringify(origin + "/sitemap.xml")}, sitemap: SITE_ORIGIN + "/sitemap.xml",
}; };
} }
`; `;
const sitemap = `import type { MetadataRoute } from "next"; const sitemap = `import type { MetadataRoute } from "next";
${siteImport}
export const dynamic = "force-static"; export const dynamic = "force-static";
export default function sitemap(): MetadataRoute.Sitemap { export default function sitemap(): MetadataRoute.Sitemap {
return [{ url: ${JSON.stringify(url)}, changeFrequency: "weekly", priority: 1 }]; return [{ url: SITE_ORIGIN + "/", changeFrequency: "weekly", priority: 1 }];
} }
`; `;
const llmsText = [`# ${title}`, ...(desc ? ["", `> ${desc}`] : []), "", "## Pages", "", `- [${title}](${url})`, ""].join("\n"); const llmsText = [`# ${title}`, ...(desc ? ["", `> ${desc}`] : []), "", "## Pages", "", `- [${title}](/)`, ""].join("\n");
const llms = `export const dynamic = "force-static"; const llms = `export const dynamic = "force-static";
export function GET() { export function GET() {
@@ -2183,10 +2300,11 @@ function generateLayoutTsx(ir: IR, bodyClass?: string, seo?: SeoInventory): stri
const viewport = seo ? viewportExport(seo) : `export const viewport = { width: "device-width", initialScale: 1 };\n`; const viewport = seo ? viewportExport(seo) : `export const viewport = { width: "device-width", initialScale: 1 };\n`;
const jsonLd = seo ? jsonLdHeadMarkup(seo, 8) : ""; const jsonLd = seo ? jsonLdHeadMarkup(seo, 8) : "";
const head = jsonLd ? ` <head>\n${jsonLd}\n </head>\n` : ""; const head = jsonLd ? ` <head>\n${jsonLd}\n </head>\n` : "";
const siteImport = seo ? SITE_ORIGIN_LAYOUT_IMPORT + "\n" : "";
return `import "./globals.css"; return `import "./globals.css";
import "./ditto.css"; import "./ditto.css";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
${siteImport}
${metadata}${viewport} ${metadata}${viewport}
export default function RootLayout({ children }: { children: ReactNode }) { export default function RootLayout({ children }: { children: ReactNode }) {
@@ -2201,10 +2319,36 @@ ${head} <body${bodyAttrs}>
`; `;
} }
const TRANSPARENT_BG = "rgba(0, 0, 0, 0)";
/** Decide what background (if any) the clone's `html` element should paint.
*
* Per CSS 2.1 §14.2, when `html` is transparent the `body` background propagates
* to the canvas and paints *beneath* every descendant — including negative-z-index
* layers (full-bleed hero video/image/canvas backdrops). The moment `html` paints
* an opaque background, `body` stops propagating and paints its own in-flow block
* background, which sits *above* negative-z descendants and buries them.
*
* So we only give `html` a background when the SOURCE `html` actually painted one.
* A transparent source `html` stays transparent (returns null → no rule emitted), so
* the captured body background propagates to the canvas exactly as in the source.
* The white fallback applies only when BOTH html and body are transparent, otherwise
* the UA-default canvas would show through. Deterministic: pure function of the IR. */
export function resolveHtmlBg(pv: { htmlBg?: string; bodyBg?: string } | undefined): string | null {
const srcHtmlBg = pv?.htmlBg && pv.htmlBg !== TRANSPARENT_BG ? pv.htmlBg : null;
const srcBodyBg = pv?.bodyBg && pv.bodyBg !== TRANSPARENT_BG ? pv.bodyBg : null;
return srcHtmlBg ?? (srcBodyBg ? null : "#ffffff");
}
/** `html { background: … }` rule, or empty string when html should stay transparent. */
export function htmlBgRule(htmlBg: string | null): string {
return htmlBg !== null ? `html { background: ${htmlBg}; }\n` : "";
}
function generateGlobalsCss(ir: IR, fontGraph: FontGraph, tokensCss: string): string { function generateGlobalsCss(ir: IR, fontGraph: FontGraph, tokensCss: string): string {
const cw = ir.doc.canonicalViewport; const cw = ir.doc.canonicalViewport;
const pv = ir.doc.perViewport[cw]; const pv = ir.doc.perViewport[cw];
const htmlBg = pv?.htmlBg && pv.htmlBg !== "rgba(0, 0, 0, 0)" ? pv.htmlBg : (pv?.bodyBg ?? "#ffffff"); const htmlBg = resolveHtmlBg(pv);
// If the SOURCE page never scrolls horizontally (its scrollWidth fits the // If the SOURCE page never scrolls horizontally (its scrollWidth fits the
// viewport at every captured width), neither should the clone. JS-driven // viewport at every captured width), neither should the clone. JS-driven
// widgets (custom-element carousels, sliders) position children off-axis via // widgets (custom-element carousels, sliders) position children off-axis via
@@ -2222,8 +2366,7 @@ ${fontGraph.css}
${tokensCss} ${tokensCss}
/* page base */ /* page base */
html { background: ${htmlBg}; } ${htmlBgRule(htmlBg)}body { font-family: ${SYSTEM_FALLBACK}; }${clip}
body { font-family: ${SYSTEM_FALLBACK}; }${clip}
`; `;
} }
@@ -2345,17 +2488,113 @@ ${input}
`; `;
} }
function recipeResponsiveClassCleaner(recipes: RecipeReport | undefined, opts: { tailwind: boolean }): (cid: string, className: string | undefined) => string | undefined { // Number of explicit column tracks in a computed `grid-template-columns` value. The capture
// resolves the property to a used track list (e.g. `284px 284px 284px`), so track count is the
// whitespace-separated token count; `none`/empty means the element is not a track grid.
function computedTrackCount(value: string | undefined): number {
if (!value || value === "none") return 0;
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.
function computedColumnSpan(cs: StyleMap | undefined): number {
if (!cs) return 1;
const end = cs["gridColumnEnd"];
const start = cs["gridColumnStart"];
const span = /^span\s+(\d+)$/.exec(end ?? "") ?? /^span\s+(\d+)$/.exec(start ?? "");
if (span) return Math.max(1, Number(span[1]));
if (start && end && /^-?\d+$/.test(start) && /^-?\d+$/.test(end)) {
const diff = Number(end) - Number(start);
if (Number.isFinite(diff) && diff > 1) return diff;
}
return 1;
}
export function recipeResponsiveClassCleaner(ir: IR, recipes: RecipeReport | undefined, opts: { tailwind: boolean }): (cid: string, className: string | undefined) => string | undefined {
const recipeParents = new Set<string>(); const recipeParents = new Set<string>();
const repeatedGridParents = new Set<string>(); const repeatedGridParents = new Set<string>();
const fillGridParents = new Set<string>(); const fillGridParents = new Set<string>();
// Containers whose computed geometry agreed with the item-count heuristic — a genuinely uniform
// repeated grid the recipe may re-flow. Row-track utilities are only stripped here; on containers
// where the geometry disagreed (spanning items / differing track count) the authored row and
// column tracks are ground truth and must survive.
const uniformGridParents = new Set<string>();
const columnPlans = new Map<string, string[]>(); const columnPlans = new Map<string, string[]>();
const nodeByCid = new Map<string, IRNode>();
const index = (n: IRNode): void => { nodeByCid.set(n.id, n); for (const c of n.children) if (!isTextChild(c)) index(c); };
index(ir.root);
// The captured/computed grid geometry is ground truth. A recipe may add semantic structure, but
// its column count is inferred by grouping item bounding boxes into rows — which under-reports the
// real track count whenever an item spans multiple columns. Before letting a recipe's synthesized
// column plan override the authored grid, cross-check it against the computed `grid-template-columns`
// track count and per-item `grid-column` spans at each regime viewport. On disagreement, keep the
// emitted (computed) geometry and drop the plan so spans and track count survive.
const planAgreesWithComputed = (c: RecipeReport["candidates"][number], regimeVps: number[]): boolean => {
const parent = c.itemParentCid ? nodeByCid.get(c.itemParentCid) : undefined;
if (!parent) return false;
const itemCids = (c.repeatedItems ?? []).map((i) => i.cid);
for (const vp of regimeVps) {
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;
}
}
return true;
};
const gridColumnTokens = (c: RecipeReport["candidates"][number]): string[] | null => { const gridColumnTokens = (c: RecipeReport["candidates"][number]): string[] | null => {
if (c.kind !== "card-grid" && c.kind !== "feature-grid" && c.kind !== "product-grid") return null; if (c.kind !== "card-grid" && c.kind !== "feature-grid" && c.kind !== "product-grid") return null;
const regimes = c.responsiveRegimes const regimes = c.responsiveRegimes
.filter((r) => (r.visibleItems ?? 0) > 0 && (r.columns ?? 0) > 0) .filter((r) => (r.visibleItems ?? 0) > 0 && (r.columns ?? 0) > 0)
.sort((a, b) => a.viewport - b.viewport); .sort((a, b) => a.viewport - b.viewport);
if (regimes.length < 2) return null; if (regimes.length < 2) return null;
// The computed track geometry disagrees with the item-count heuristic (spanning items or a
// differing track count) — defer to the authored grid rather than synthesize a column plan.
if (!planAgreesWithComputed(c, regimes.map((r) => r.viewport))) return null;
const prefixFor = (vp: number): string => vp >= 1536 ? "2xl:" : vp >= 1024 ? "lg:" : vp >= 768 ? "md:" : ""; const prefixFor = (vp: number): string => vp >= 1536 ? "2xl:" : vp >= 1024 ? "lg:" : vp >= 768 ? "md:" : "";
const tokens: string[] = []; const tokens: string[] = [];
let last: number | undefined; let last: number | undefined;
@@ -2377,6 +2616,12 @@ function recipeResponsiveClassCleaner(recipes: RecipeReport | undefined, opts: {
for (const c of recipes?.candidates ?? []) { for (const c of recipes?.candidates ?? []) {
if ((c.kind === "card-grid" || c.kind === "feature-grid" || c.kind === "product-grid" || c.kind === "gallery-showcase" || c.kind === "logo-cloud") && c.confidence >= 0.86 && c.itemParentCid) { if ((c.kind === "card-grid" || c.kind === "feature-grid" || c.kind === "product-grid" || c.kind === "gallery-showcase" || c.kind === "logo-cloud") && c.confidence >= 0.86 && c.itemParentCid) {
recipeParents.add(c.itemParentCid); recipeParents.add(c.itemParentCid);
// A container is a uniform repeated grid only if its computed track geometry agrees with the
// item-count heuristic at every sampled regime viewport (no spanning items, matching track
// count). Non-grid containers (flex/stack) have no computed tracks to disagree with, so they
// stay eligible; a track grid whose geometry disagrees is excluded and keeps its authored rows.
const sampledVps = c.responsiveRegimes.map((r) => r.viewport);
if (planAgreesWithComputed(c, sampledVps)) uniformGridParents.add(c.itemParentCid);
if (c.kind === "card-grid" || c.kind === "feature-grid" || c.kind === "product-grid" || c.kind === "gallery-showcase") repeatedGridParents.add(c.itemParentCid); if (c.kind === "card-grid" || c.kind === "feature-grid" || c.kind === "product-grid" || c.kind === "gallery-showcase") repeatedGridParents.add(c.itemParentCid);
if (c.kind === "card-grid" || c.kind === "feature-grid" || c.kind === "product-grid") fillGridParents.add(c.itemParentCid); if (c.kind === "card-grid" || c.kind === "feature-grid" || c.kind === "product-grid") fillGridParents.add(c.itemParentCid);
const columns = gridColumnTokens(c); const columns = gridColumnTokens(c);
@@ -2390,8 +2635,8 @@ function recipeResponsiveClassCleaner(recipes: RecipeReport | undefined, opts: {
const columnPlan = opts.tailwind ? columnPlans.get(cid) : undefined; const columnPlan = opts.tailwind ? columnPlans.get(cid) : undefined;
const keep = tokens.filter((token) => { const keep = tokens.filter((token) => {
if (columnPlan && /^(?:[a-z0-9-]+:)*grid-cols-(?:\d+|\[[^\]]+\])$/.test(token)) return false; if (columnPlan && /^(?:[a-z0-9-]+:)*grid-cols-(?:\d+|\[[^\]]+\])$/.test(token)) return false;
if (/^(?:[a-z0-9-]+:)*grid-rows-\[(?:auto_?)+\]$/.test(token)) return false; if (uniformGridParents.has(cid) && /^(?:[a-z0-9-]+:)*grid-rows-\[(?:auto_?)+\]$/.test(token)) return false;
if (repeatedGridParents.has(cid) && /^(?:[a-z0-9-]+:)*grid-rows-\d+$/.test(token)) return false; if (uniformGridParents.has(cid) && repeatedGridParents.has(cid) && /^(?:[a-z0-9-]+:)*grid-rows-\d+$/.test(token)) return false;
const initialCols = /^(?:(.*):)?grid-cols-\[initial\]$/.exec(token); const initialCols = /^(?:(.*):)?grid-cols-\[initial\]$/.exec(token);
if (initialCols) { if (initialCols) {
const prefix = initialCols[1] ? `${initialCols[1]}:` : ""; const prefix = initialCols[1] ? `${initialCols[1]}:` : "";
@@ -2436,9 +2681,12 @@ export function generateApp(input: GenerateInput, tokensCss: string): { pageTsx:
const mode = input.humanizeMode ?? "tailwind"; const mode = input.humanizeMode ?? "tailwind";
// Tailwind mode (default): translate each node's exact decls to utility classes. // Tailwind mode (default): translate each node's exact decls to utility classes.
// CSS mode: dedup into shared semantic CSS classes. Both fidelity-neutral. // CSS mode: dedup into shared semantic CSS classes. Both fidelity-neutral.
const tw = humanize && mode === "tailwind" ? buildTailwind(ir, assetMap, input.colorVar, { interaction: input.interaction, reflow: input.reflow }) : undefined; // Lottie mount boxes are pinned to their captured height (replaced-like) so the runtime player's
// aspect-sized svg fills a definite box instead of inflating. The spec item cid IS the mount cid.
const lottieMounts = new Set(lottieSpec.items.map((it) => it.cid));
const tw = humanize && mode === "tailwind" ? buildTailwind(ir, assetMap, input.colorVar, { interaction: input.interaction, reflow: input.reflow, lottieMounts }) : undefined;
const classMap = humanize && mode === "css" ? buildClassMap(ir, assetMap, input.colorVar, input.primitives, input.tokenResolver) : undefined; const classMap = humanize && mode === "css" ? buildClassMap(ir, assetMap, input.colorVar, input.primitives, input.tokenResolver) : undefined;
const cleanRecipeClass = recipeResponsiveClassCleaner(input.recipeReport, { tailwind: !!tw }); const cleanRecipeClass = recipeResponsiveClassCleaner(ir, input.recipeReport, { tailwind: !!tw });
const classOf = tw ? (cid: string) => cleanRecipeClass(cid, tw.classOf.get(cid)) : classMap ? (cid: string) => classMap.classOf.get(cid) : undefined; const classOf = tw ? (cid: string) => cleanRecipeClass(cid, tw.classOf.get(cid)) : classMap ? (cid: string) => classMap.classOf.get(cid) : undefined;
const styleOf = tw ? (cid: string) => tw.styleOf.get(cid) : undefined; const styleOf = tw ? (cid: string) => tw.styleOf.get(cid) : undefined;
// Section split (single-page humanized): plan section roots, render each into its own // Section split (single-page humanized): plan section roots, render each into its own
@@ -2527,7 +2775,7 @@ export function generateApp(input: GenerateInput, tokensCss: string): { pageTsx:
if (tw) { if (tw) {
const cw = ir.doc.canonicalViewport; const cw = ir.doc.canonicalViewport;
const pv = ir.doc.perViewport[cw]; const pv = ir.doc.perViewport[cw];
const htmlBg = pv?.htmlBg && pv.htmlBg !== "rgba(0, 0, 0, 0)" ? pv.htmlBg : (pv?.bodyBg ?? "#ffffff"); const htmlBg = resolveHtmlBg(pv);
const noHScroll = Object.entries(ir.doc.perViewport).every(([vp, d]) => d.scrollWidth <= Number(vp) * 1.03); const noHScroll = Object.entries(ir.doc.perViewport).every(([vp, d]) => d.scrollWidth <= Number(vp) * 1.03);
const clip = noHScroll ? "\nhtml, body { overflow-x: clip; }" : ""; const clip = noHScroll ? "\nhtml, body { overflow-x: clip; }" : "";
const globals = tailwindGlobalsCss({ const globals = tailwindGlobalsCss({
@@ -2541,6 +2789,9 @@ export function generateApp(input: GenerateInput, tokensCss: string): { pageTsx:
writeText(join(rootDir, "globals.css"), framework === "vite" ? viteGlobalsCss(globals) : globals); writeText(join(rootDir, "globals.css"), framework === "vite" ? viteGlobalsCss(globals) : globals);
} }
writeText(join(rootDir, "ditto.css"), cloneCss); writeText(join(rootDir, "ditto.css"), cloneCss);
writeText(join(appDir, "src", "lib", "utils.ts"), CN_UTILS_MODULE);
// SITE_ORIGIN constant for SEO/metadata routes (Next only — Vite ships static SEO files).
if (framework === "next") writeText(join(appDir, "src", "lib", "site.ts"), SITE_ORIGIN_MODULE);
if (wires.length) writeText(join(rootDir, "ditto", "DittoWire.tsx"), DITTO_WIRE_TSX); if (wires.length) writeText(join(rootDir, "ditto", "DittoWire.tsx"), DITTO_WIRE_TSX);
if (accordions.length) writeText(join(rootDir, "ditto", "Accordion.tsx"), ACCORDION_TSX); if (accordions.length) writeText(join(rootDir, "ditto", "Accordion.tsx"), ACCORDION_TSX);
if (motionHasContent(motionSpec)) writeText(join(rootDir, "ditto", "DittoMotion.tsx"), DITTO_MOTION_TSX); if (motionHasContent(motionSpec)) writeText(join(rootDir, "ditto", "DittoMotion.tsx"), DITTO_MOTION_TSX);
File diff suppressed because it is too large Load Diff
+21 -21
View File
@@ -151,7 +151,7 @@ export function accordionJsx(specs: AccordionRuntimeSpec[], indent: number): str
} }
export const ACCORDION_TSX = `"use client"; export const ACCORDION_TSX = `"use client";
import { useEffect, useRef } from "react"; import { useEffect } from "react";
type CapStyle = Record<string, string>; type CapStyle = Record<string, string>;
type RTAcc = { trigger: string; region: string; expanded: boolean; triggerOn: CapStyle; triggerOff: CapStyle; regionShown: CapStyle; regionHidden: CapStyle }; type RTAcc = { trigger: string; region: string; expanded: boolean; triggerOn: CapStyle; triggerOff: CapStyle; regionShown: CapStyle; regionHidden: CapStyle };
@@ -167,10 +167,9 @@ function applyStyle(el: HTMLElement | null, s: CapStyle) {
/** Wires captured accordion rows with small explicit state. /** Wires captured accordion rows with small explicit state.
* Hydration initializes the captured base state, then clicks toggle only the target row. */ * Hydration initializes the captured base state, then clicks toggle only the target row. */
export default function Accordion({ specs }: { specs: AccordionSpec[] }) { export default function Accordion({ specs }: { specs: AccordionSpec[] }) {
const wired = useRef(false);
useEffect(() => { useEffect(() => {
if (wired.current) return; const ac = new AbortController();
wired.current = true; const { signal } = ac;
for (const spec of specs) { for (const spec of specs) {
const state = spec.items.map((it) => it.expanded); const state = spec.items.map((it) => it.expanded);
const renderItem = (i: number) => { const renderItem = (i: number) => {
@@ -193,10 +192,11 @@ export default function Accordion({ specs }: { specs: AccordionSpec[] }) {
e.preventDefault(); e.preventDefault();
state[i] = !state[i]; state[i] = !state[i];
renderItem(i); renderItem(i);
}); }, { signal });
renderItem(i); renderItem(i);
}); });
} }
return () => ac.abort();
}, [specs]); }, [specs]);
return null; return null;
} }
@@ -204,7 +204,7 @@ export default function Accordion({ specs }: { specs: AccordionSpec[] }) {
/** The fixed DittoWire client component, written once per generated app. */ /** The fixed DittoWire client component, written once per generated app. */
export const DITTO_WIRE_TSX = `"use client"; export const DITTO_WIRE_TSX = `"use client";
import { useEffect, useRef } from "react"; import { useEffect } from "react";
type CapStyle = Record<string, string>; type CapStyle = Record<string, string>;
type RTTab = { trigger: string; panel: string; triggerOn: CapStyle; triggerOff: CapStyle; panelShown: CapStyle; panelHidden: CapStyle; descendants?: Record<string, CapStyle> }; type RTTab = { trigger: string; panel: string; triggerOn: CapStyle; triggerOff: CapStyle; panelShown: CapStyle; panelHidden: CapStyle; descendants?: Record<string, CapStyle> };
@@ -235,10 +235,9 @@ function applyDesc(d?: Record<string, CapStyle>) {
* on mount: the server-rendered markup + per-node CSS already reproduce the captured * on mount: the server-rendered markup + per-node CSS already reproduce the captured
* base state exactly, so state styles are applied only on user interaction. */ * base state exactly, so state styles are applied only on user interaction. */
export default function DittoWire({ spec }: { spec: Spec }) { export default function DittoWire({ spec }: { spec: Spec }) {
const wired = useRef(false);
useEffect(() => { useEffect(() => {
if (wired.current) return; const ac = new AbortController();
wired.current = true; const { signal } = ac;
if (spec.kind === "tabs") { if (spec.kind === "tabs") {
let active = spec.active; let active = spec.active;
const render = () => spec.tabs.forEach((t, i) => { const render = () => spec.tabs.forEach((t, i) => {
@@ -254,7 +253,7 @@ export default function DittoWire({ spec }: { spec: Spec }) {
spec.tabs.forEach((t, i) => { spec.tabs.forEach((t, i) => {
const trig = byCid(t.trigger); const trig = byCid(t.trigger);
if (!trig) return; if (!trig) return;
trig.addEventListener("click", (e) => { e.preventDefault(); active = i; render(); }); trig.addEventListener("click", (e) => { e.preventDefault(); active = i; render(); }, { signal });
trig.addEventListener("keydown", (e) => { trig.addEventListener("keydown", (e) => {
const k = (e as KeyboardEvent).key; const k = (e as KeyboardEvent).key;
if (k === "ArrowRight" || k === "ArrowLeft") { if (k === "ArrowRight" || k === "ArrowLeft") {
@@ -263,7 +262,7 @@ export default function DittoWire({ spec }: { spec: Spec }) {
render(); render();
byCid(spec.tabs[active].trigger)?.focus(); byCid(spec.tabs[active].trigger)?.focus();
} }
}); }, { signal });
}); });
// No initial render() — the static base state is already correct. // No initial render() — the static base state is already correct.
} else if (spec.kind === "accordion") { } else if (spec.kind === "accordion") {
@@ -278,7 +277,7 @@ export default function DittoWire({ spec }: { spec: Spec }) {
}; };
spec.items.forEach((it, i) => { spec.items.forEach((it, i) => {
const trig = byCid(it.trigger); const trig = byCid(it.trigger);
if (trig) trig.addEventListener("click", (e) => { e.preventDefault(); state[i] = !state[i]; renderItem(i); }); if (trig) trig.addEventListener("click", (e) => { e.preventDefault(); state[i] = !state[i]; renderItem(i); }, { signal });
}); });
// No initial renderItem — the static base state is already correct. // No initial renderItem — the static base state is already correct.
} else if (spec.kind === "carousel") { } else if (spec.kind === "carousel") {
@@ -293,9 +292,9 @@ export default function DittoWire({ spec }: { spec: Spec }) {
}; };
const nextEl = spec.next ? byCid(spec.next) : null; const nextEl = spec.next ? byCid(spec.next) : null;
const prevEl = spec.prev ? byCid(spec.prev) : null; const prevEl = spec.prev ? byCid(spec.prev) : null;
nextEl?.addEventListener("click", (e) => { e.preventDefault(); go(index + 1); }); nextEl?.addEventListener("click", (e) => { e.preventDefault(); go(index + 1); }, { signal });
prevEl?.addEventListener("click", (e) => { e.preventDefault(); go(index - 1); }); prevEl?.addEventListener("click", (e) => { e.preventDefault(); go(index - 1); }, { signal });
spec.bullets.forEach((b, bi) => byCid(b)?.addEventListener("click", (e) => { e.preventDefault(); go(bi); })); spec.bullets.forEach((b, bi) => byCid(b)?.addEventListener("click", (e) => { e.preventDefault(); go(bi); }, { signal }));
// No initial go() — the static base state is already correct. // No initial go() — the static base state is already correct.
} else { } else {
// Disclosure: dropdown / mega-menu / modal — a trigger reveals a hidden overlay. // Disclosure: dropdown / mega-menu / modal — a trigger reveals a hidden overlay.
@@ -311,18 +310,19 @@ export default function DittoWire({ spec }: { spec: Spec }) {
if (o) applyDesc(it.descendants); if (o) applyDesc(it.descendants);
if (o) panel.removeAttribute("hidden"); else panel.setAttribute("hidden", ""); if (o) panel.removeAttribute("hidden"); else panel.setAttribute("hidden", "");
}; };
trig.addEventListener("click", (e) => { e.preventDefault(); set(it.isDialog ? true : !open); }); trig.addEventListener("click", (e) => { e.preventDefault(); set(it.isDialog ? true : !open); }, { signal });
if (it.hoverOpen) { if (it.hoverOpen) {
const root = trig.parentElement ?? trig; const root = trig.parentElement ?? trig;
root.addEventListener("mouseenter", () => set(true)); root.addEventListener("mouseenter", () => set(true), { signal });
root.addEventListener("mouseleave", () => set(false)); root.addEventListener("mouseleave", () => set(false), { signal });
} }
it.closes.forEach((c) => byCid(c)?.addEventListener("click", (e) => { e.preventDefault(); set(false); })); it.closes.forEach((c) => byCid(c)?.addEventListener("click", (e) => { e.preventDefault(); set(false); }, { signal }));
if (it.backdropClose) panel.addEventListener("click", (e) => { if (e.target === panel) set(false); }); if (it.backdropClose) panel.addEventListener("click", (e) => { if (e.target === panel) set(false); }, { signal });
document.addEventListener("keydown", (e) => { if ((e as KeyboardEvent).key === "Escape" && open) set(false); }); document.addEventListener("keydown", (e) => { if ((e as KeyboardEvent).key === "Escape" && open) set(false); }, { signal });
}); });
// No initial set() — the static base state is already correct. // No initial set() — the static base state is already correct.
} }
return () => ac.abort();
}, [spec]); }, [spec]);
return null; return null;
} }
+46 -4
View File
@@ -126,7 +126,7 @@ const byCid = (cid: string): HTMLElement | null => document.querySelector('[data
export default function DittoLottie({ spec }: { spec: LottieSpec }) { export default function DittoLottie({ spec }: { spec: LottieSpec }) {
useEffect(() => { useEffect(() => {
const stopped = (window as any).__dittoMotionStopped === true; const stopped = (window as any).__dittoMotionStopped === true;
const anims: Array<{ destroy: () => void; goToAndStop: (value: number, isFrame?: boolean) => void }> = []; const anims: Array<{ destroy: () => void; goToAndStop: (value: number, isFrame?: boolean) => void; addEventListener: (name: string, cb: () => void) => void }> = [];
let cancelled = false; let cancelled = false;
void (async () => { void (async () => {
const lottie = (await import("lottie-web")).default; const lottie = (await import("lottie-web")).default;
@@ -134,17 +134,59 @@ export default function DittoLottie({ spec }: { spec: LottieSpec }) {
for (const it of spec.items) { for (const it of spec.items) {
const el = byCid(it.cid); const el = byCid(it.cid);
if (!el) continue; if (!el) continue;
// clear the captured placeholder frame so it never stacks with the live render
el.innerHTML = "";
try { try {
// Mount the live animation into an OVERLAY child, keeping the captured placeholder
// frame in the DOM until lottie signals a successful load — a failed load (bad JSON,
// network) then leaves the placeholder intact instead of erasing the container to blank.
// The overlay stays position:absolute;inset:0 for its WHOLE life (not just pre-swap):
// the host box carries the captured per-viewport height, and only an absolutely-filled
// overlay inherits that definite box. A static-flow overlay has indefinite height, so
// the player's svg (style height:100%) collapses to auto and inflates to the source
// aspect (a portrait viewBox then oversizes far past the captured, letterboxed box).
const mount = document.createElement("div");
mount.style.position = "absolute";
mount.style.inset = "0";
mount.style.opacity = "0";
const cs = getComputedStyle(el);
if (cs.position === "static") el.style.position = "relative";
// Mark the host so emitted CSS can force the runtime svg/canvas to fit the pinned box.
el.setAttribute("data-ditto-lottie", "");
el.appendChild(mount);
const anim = lottie.loadAnimation({ const anim = lottie.loadAnimation({
container: el, container: mount,
renderer: it.renderer === "canvas" ? "canvas" : "svg", renderer: it.renderer === "canvas" ? "canvas" : "svg",
loop: it.loop, loop: it.loop,
autoplay: it.autoplay && !stopped, autoplay: it.autoplay && !stopped,
...(it.path ? { path: it.path } : { animationData: it.animationData as object }), ...(it.path ? { path: it.path } : { animationData: it.animationData as object }),
rendererSettings: { preserveAspectRatio: "xMidYMid meet" }, rendererSettings: { preserveAspectRatio: "xMidYMid meet" },
}); });
// Swap only once the animation is genuinely ready: reveal the live render and remove
// every original child (the placeholder) so the two never stack. Before discarding the
// placeholder, forward its data-cid onto the runtime-rendered svg/canvas so the media
// node stays addressable by cid after the swap (it would otherwise mount without one).
const reveal = () => {
mount.style.opacity = "1";
// The captured placeholder is an svg/canvas (or wraps one) carrying its own data-cid.
let placeholderCid: string | null = null;
for (const child of Array.from(el.childNodes)) {
if (child === mount || placeholderCid !== null || !(child instanceof Element)) continue;
const media = child.matches("svg, canvas") ? child : child.querySelector("svg, canvas");
placeholderCid = (media ?? child).getAttribute("data-cid");
}
for (const child of Array.from(el.childNodes)) if (child !== mount) el.removeChild(child);
if (placeholderCid) {
const rendered = mount.querySelector("svg, canvas");
if (rendered) rendered.setAttribute("data-cid", placeholderCid);
}
// Do NOT clear position/inset: the overlay must keep filling the host's pinned box
// (see above). Stripping them reverts it to static flow → indefinite height → the
// player's height:100% svg inflates to its aspect and escapes the captured bbox.
};
// DOMLoaded fires only after the JSON parsed and the first frame rendered — the
// right moment to reveal the live render and drop the placeholder. data_failed
// (bad JSON / fetch error) tears the empty mount back out, leaving the placeholder.
anim.addEventListener("DOMLoaded", reveal);
anim.addEventListener("data_failed", () => { try { el.removeChild(mount); } catch {} });
if (stopped) { try { anim.goToAndStop(0, true); } catch {} } if (stopped) { try { anim.goToAndStop(0, true); } catch {} }
anims.push(anim); anims.push(anim);
} catch { } catch {
+7
View File
@@ -56,6 +56,13 @@ export function buildManifest(args: {
fallback: fontGraph.entries.filter((f) => f.status === "fallback").length, fallback: fontGraph.entries.filter((f) => f.status === "fallback").length,
}, },
components: { count: componentCount }, components: { count: componentCount },
// Fidelity note: containers whose children were a DIFFERENT SET at some band viewport(s)
// (content-identity drift — the source deterministically served other content there). The
// clone shows the canonical-viewport children at those widths (faithful-at-canonical) instead
// of an empty shell; a perceptual delta against the source at those widths is expected.
divergence: {
contentDrift: (ir.doc.contentDrift ?? []).map((d) => ({ id: d.id, tag: d.tag, viewports: d.viewports })),
},
// Stage 2: capture-sanity audit — what overlays were dismissed, whether any // Stage 2: capture-sanity audit — what overlays were dismissed, whether any
// still covered the page, video stills materialized, and per-viewport quiescence. // still covered the page, video stills materialized, and per-viewport quiescence.
capture: { capture: {
+26 -12
View File
@@ -94,7 +94,7 @@ export function dropdownMenuImportPath(depth: number): string {
/** The fixed DropdownMenu client component, written once per generated app. */ /** The fixed DropdownMenu client component, written once per generated app. */
export const DROPDOWN_MENU_TSX = `"use client"; export const DROPDOWN_MENU_TSX = `"use client";
import { useEffect, useRef } from "react"; import { useEffect } from "react";
type RTMenu = { trigger: string; hoverOpen: boolean; gap: number; align: "left" | "right"; html: string }; type RTMenu = { trigger: string; hoverOpen: boolean; gap: number; align: "left" | "right"; html: string };
const byCid = (cid: string): HTMLElement | null => document.querySelector('[data-cid="' + cid + '"]'); const byCid = (cid: string): HTMLElement | null => document.querySelector('[data-cid="' + cid + '"]');
@@ -103,10 +103,10 @@ const byCid = (cid: string): HTMLElement | null => document.querySelector('[data
* user interaction does it inject the captured panel fragment under its trigger. The base * user interaction does it inject the captured panel fragment under its trigger. The base
* render is therefore unchanged. */ * render is therefore unchanged. */
export default function DropdownMenu({ menus }: { menus: RTMenu[] }) { export default function DropdownMenu({ menus }: { menus: RTMenu[] }) {
const wired = useRef(false);
useEffect(() => { useEffect(() => {
if (wired.current) return; const ac = new AbortController();
wired.current = true; const { signal } = ac;
const openPanels: HTMLElement[] = [];
for (const m of menus) { for (const m of menus) {
const trig = byCid(m.trigger); const trig = byCid(m.trigger);
if (!trig) continue; if (!trig) continue;
@@ -127,27 +127,41 @@ export default function DropdownMenu({ menus }: { menus: RTMenu[] }) {
panel = wrap.firstElementChild as HTMLElement | null; panel = wrap.firstElementChild as HTMLElement | null;
if (!panel) return; if (!panel) return;
document.body.appendChild(panel); document.body.appendChild(panel);
openPanels.push(panel);
place(); place();
trig.setAttribute("aria-expanded", "true"); trig.setAttribute("aria-expanded", "true");
}; };
const close = () => { if (panel) { panel.remove(); panel = null; } trig.setAttribute("aria-expanded", "false"); }; const close = () => {
if (panel) {
const i = openPanels.indexOf(panel);
if (i !== -1) openPanels.splice(i, 1);
panel.remove();
panel = null;
}
trig.setAttribute("aria-expanded", "false");
};
const toggle = () => (panel ? close() : open()); const toggle = () => (panel ? close() : open());
if (m.hoverOpen) { if (m.hoverOpen) {
const root = trig.parentElement ?? trig; const root = trig.parentElement ?? trig;
root.addEventListener("mouseenter", open); root.addEventListener("mouseenter", open, { signal });
root.addEventListener("mouseleave", close); root.addEventListener("mouseleave", close, { signal });
} else { } else {
trig.addEventListener("click", (e) => { e.preventDefault(); toggle(); }); trig.addEventListener("click", (e) => { e.preventDefault(); toggle(); }, { signal });
} }
document.addEventListener("keydown", (e) => { if (e.key === "Escape") close(); }); document.addEventListener("keydown", (e) => { if (e.key === "Escape") close(); }, { signal });
document.addEventListener("click", (e) => { document.addEventListener("click", (e) => {
const t = e.target as Node; const t = e.target as Node;
if (panel && !trig.contains(t) && !panel.contains(t)) close(); if (panel && !trig.contains(t) && !panel.contains(t)) close();
}); }, { signal });
window.addEventListener("resize", place); window.addEventListener("resize", place, { signal });
window.addEventListener("scroll", place, { passive: true }); window.addEventListener("scroll", place, { passive: true, signal });
} }
(window as any).__dittoMenuReady = true; // wiring done — lets the gate drive deterministically (window as any).__dittoMenuReady = true; // wiring done — lets the gate drive deterministically
return () => {
ac.abort();
// Remove any still-open panels appended to document.body so unmount leaves no orphan nodes.
for (const p of openPanels.splice(0)) p.remove();
};
}, [menus]); }, [menus]);
return null; return null;
} }
+35 -4
View File
@@ -41,12 +41,31 @@ function capToCid(ir: IR): Map<string, string> {
return m; return m;
} }
/** Map every IR node's cid → node, so an emission guard can inspect the resolved target. */
function cidToNode(ir: IR): Map<string, IRNode> {
const m = new Map<string, IRNode>();
const walk = (n: IRNode): void => {
m.set(n.id, n);
for (const c of n.children) if (!isTextChild(c)) walk(c);
};
walk(ir.root);
return m;
}
/** True when an IR node has 1 element child. A genuine rotating word/phrase is a text leaf
* (no element children); a container captured as a rotator would have the runtime flatten its
* whole subtree to one text node, destroying every descendant element. */
function hasElementChild(n: IRNode | undefined): boolean {
return !!n && n.children.some((c) => !isTextChild(c));
}
/** Resolve cap-keyed motion specs to cids that survived into this IR (optionally /** Resolve cap-keyed motion specs to cids that survived into this IR (optionally
* scoped by an include filter, for multi-route body/chrome splitting). Specs whose * scoped by an include filter, for multi-route body/chrome splitting). Specs whose
* element was pruned are dropped (left static). */ * element was pruned are dropped (left static). */
export function buildMotionSpec(ir: IR, motion: MotionCapture | undefined, include?: (cid: string) => boolean): MotionSpec { export function buildMotionSpec(ir: IR, motion: MotionCapture | undefined, include?: (cid: string) => boolean): MotionSpec {
if (!motion) return { waapi: [], rotators: [], reveals: [], marquees: [] }; if (!motion) return { waapi: [], rotators: [], reveals: [], marquees: [] };
const map = capToCid(ir); const map = capToCid(ir);
const nodeByCid = cidToNode(ir);
const ok = (cid: string | undefined): cid is string => !!cid && (!include || include(cid)); const ok = (cid: string | undefined): cid is string => !!cid && (!include || include(cid));
const waapi: RTWaapi[] = []; const waapi: RTWaapi[] = [];
for (const w of motion.waapi as WaapiAnim[]) { for (const w of motion.waapi as WaapiAnim[]) {
@@ -58,6 +77,11 @@ export function buildMotionSpec(ir: IR, motion: MotionCapture | undefined, inclu
for (const r of motion.rotators as RotatorSpec[]) { for (const r of motion.rotators as RotatorSpec[]) {
const cid = map.get(r.cap); const cid = map.get(r.cap);
if (!ok(cid)) continue; if (!ok(cid)) continue;
// A rotator cycles the text of a single leaf word/phrase. If the resolved target has element
// children, capture misclassified a structural container (e.g. one whose rows were injected at
// runtime, uncapped, after cid-cap tagging) as a rotator. Emitting it would let the runtime
// flatten every descendant element to one text node; drop it so the static subtree survives.
if (hasElementChild(nodeByCid.get(cid))) continue;
rotators.push({ cid, texts: r.texts, intervalMs: r.intervalMs }); rotators.push({ cid, texts: r.texts, intervalMs: r.intervalMs });
} }
const reveals: RTReveal[] = []; const reveals: RTReveal[] = [];
@@ -157,7 +181,7 @@ export default function DittoMotion({ spec }: { spec: MotionSpec }) {
useEffect(() => { useEffect(() => {
if ((window as any).__dittoMotionStopped) return; // measurement mode — apply nothing if ((window as any).__dittoMotionStopped) return; // measurement mode — apply nothing
const intervals: ReturnType<typeof setInterval>[] = []; const intervals: ReturnType<typeof setInterval>[] = [];
const rotators: Array<{ el: HTMLElement; original: string | null }> = []; const rotators: Array<{ el: HTMLElement; original: Node[] }> = [];
const anims: Animation[] = []; const anims: Animation[] = [];
// per-reveal "show now" fns (also the cleanup); animate=false jumps to the settled frame // per-reveal "show now" fns (also the cleanup); animate=false jumps to the settled frame
const revealed: Array<(animate: boolean) => void> = []; const revealed: Array<(animate: boolean) => void> = [];
@@ -196,8 +220,15 @@ export default function DittoMotion({ spec }: { spec: MotionSpec }) {
for (const r of spec.rotators) { for (const r of spec.rotators) {
const el = byCid(r.cid); const el = byCid(r.cid);
if (!el || r.texts.length < 2) continue; if (!el || r.texts.length < 2) continue;
const original = el.textContent; // Defense in depth: a genuine rotating word is a text leaf. If the target has element
const start = r.texts.findIndex((t) => t === (original || "").replace(/\\s+/g, " ").trim()); // children, it was misclassified (its subtree would be destroyed by a text write) — skip it
// so the static structure survives even if the emission guard was bypassed.
if (el.childElementCount > 0) continue;
// Save the ORIGINAL child nodes (cloned) rather than a flattened textContent string, so the
// settle/restore path rebuilds the exact subtree via replaceChildren — never a lossy text
// node. For a real text leaf this is just the single text node round-tripped intact.
const original = Array.from(el.childNodes).map((n) => n.cloneNode(true));
const start = r.texts.findIndex((t) => t === (el.textContent || "").replace(/\\s+/g, " ").trim());
let i = start < 0 ? 0 : start; let i = start < 0 ? 0 : start;
rotators.push({ el, original }); rotators.push({ el, original });
intervals.push(setInterval(() => { i = (i + 1) % r.texts.length; el.textContent = r.texts[i]!; }, Math.max(400, r.intervalMs))); intervals.push(setInterval(() => { i = (i + 1) % r.texts.length; el.textContent = r.texts[i]!; }, Math.max(400, r.intervalMs)));
@@ -274,7 +305,7 @@ export default function DittoMotion({ spec }: { spec: MotionSpec }) {
const stopAll = () => { const stopAll = () => {
(window as any).__dittoMotionStopped = true; (window as any).__dittoMotionStopped = true;
for (const id of intervals) clearInterval(id); for (const id of intervals) clearInterval(id);
for (const r of rotators) r.el.textContent = r.original; for (const r of rotators) r.el.replaceChildren(...r.original.map((n) => n.cloneNode(true)));
for (const a of anims) { try { a.cancel(); } catch { /* ignore */ } } for (const a of anims) { try { a.cancel(); } catch { /* ignore */ } }
if (io) io.disconnect(); if (io) io.disconnect();
if (forceTimer) clearTimeout(forceTimer); if (forceTimer) clearTimeout(forceTimer);
+110 -43
View File
@@ -15,6 +15,7 @@
import type { IR, IRNode } from "../normalize/ir.js"; import type { IR, IRNode } from "../normalize/ir.js";
import { isTextChild } from "../normalize/ir.js"; import { isTextChild } from "../normalize/ir.js";
import type { RecipeCandidate, RecipeKind, RecipeReport } from "../infer/recipes.js"; import type { RecipeCandidate, RecipeKind, RecipeReport } from "../infer/recipes.js";
import { detectSectionNodes, heroLikeHeader } from "../infer/sections.js";
import { subtreeSignature } from "../site/sharedLayout.js"; import { subtreeSignature } from "../site/sharedLayout.js";
export type SectionPlan = { export type SectionPlan = {
@@ -23,7 +24,7 @@ export type SectionPlan = {
}; };
const MIN_SECTION_H = 56; const MIN_SECTION_H = 56;
const MAX_SECTIONS = 24; const MAX_SECTIONS = 32;
function box(n: IRNode, cw: number): { width: number; height: number } | undefined { function box(n: IRNode, cw: number): { width: number; height: number } | undefined {
return n.bboxByVp[cw] ?? Object.values(n.bboxByVp)[0]; return n.bboxByVp[cw] ?? Object.values(n.bboxByVp)[0];
@@ -31,15 +32,9 @@ function box(n: IRNode, cw: number): { width: number; height: number } | undefin
function yOf(n: IRNode, cw: number): number { function yOf(n: IRNode, cw: number): number {
return (n.bboxByVp[cw] ?? Object.values(n.bboxByVp)[0])?.y ?? 0; return (n.bboxByVp[cw] ?? Object.values(n.bboxByVp)[0])?.y ?? 0;
} }
function visible(n: IRNode, cw: number): boolean {
return !!(n.visibleByVp[cw] ?? Object.values(n.visibleByVp)[0]);
}
function elementChildren(n: IRNode): IRNode[] { function elementChildren(n: IRNode): IRNode[] {
return n.children.filter((c): c is IRNode => !isTextChild(c)); return n.children.filter((c): c is IRNode => !isTextChild(c));
} }
function significantChildren(n: IRNode, cw: number): IRNode[] {
return elementChildren(n).filter((c) => visible(c, cw) && (box(c, cw)?.height ?? 0) >= MIN_SECTION_H);
}
function subtreeHasTag(n: IRNode, tag: string, depth = 4): boolean { function subtreeHasTag(n: IRNode, tag: string, depth = 4): boolean {
if (depth < 0) return false; if (depth < 0) return false;
@@ -105,6 +100,83 @@ function identName(name: string): string {
return /^[A-Za-z_$]/.test(name) ? name : "S" + name; return /^[A-Za-z_$]/.test(name) ? name : "S" + name;
} }
// Generic structural words that carry no section identity — dropped from source-derived names.
const GENERIC_NAME_WORDS = new Set([
"section", "sections", "wrapper", "container", "block", "blocks", "template", "templates",
"group", "inner", "outer", "content", "contents", "row", "col", "column", "grid", "layout",
"shopify", "js", "tvg", "elementor", "wp", "widget", "module", "region", "main", "page",
"component", "components", "el", "root", "body", "area", "box", "item", "items", "wrap",
]);
/** A trailing token looks like a build hash (mixed-case or long alnum entropy, e.g.
* `JtTWTt`, `RbEALJ`, `x19f2a`, `dDMm2q`) rather than a real word strip it. A token is
* hashy when it is 4 chars and either mixes upper+lower case or is a long digit-bearing run. */
function looksHashy(tok: string): boolean {
if (tok.length < 4) return false;
const hasUpper = /[A-Z]/.test(tok), hasLower = /[a-z]/.test(tok), hasDigit = /\d/.test(tok);
if (hasUpper && hasLower) return true; // camel/entropy hash: JtTWTt, dDMm2q
if (hasDigit && tok.length >= 6) return true; // long id run: 19797275672650
if (!/[aeiou]/i.test(tok) && tok.length >= 5) return true; // vowelless run: bcdfgh
return false;
}
/** Turn a source id/class token stream into 3 semantic PascalCase words, or "" when the
* evidence is all-generic or hashy. Strips CMS prefixes (shopify-section-__), leading
* numeric template ids, generic structural words, and trailing hash suffixes. Exported for
* tests (the hashy-suffix stripping + generic-word filtering is the load-bearing part). */
export function nameFromSourceToken(raw: string): string {
// Peel known CMS section-id prefixes, keeping the semantic slug after `__` / the hook name.
// Case is preserved through prefix-stripping + tokenizing so `looksHashy` can see the
// mixed-case entropy of build hashes (RbEALJ, JtTWTt) BEFORE we normalize to lowercase.
const s = raw.trim()
.replace(/^shopify-section-(?:template|sections)--\d+__/i, "")
.replace(/^shopify-(?:section|block)-/i, "")
.replace(/^(?:js|tvg|elementor|wp|et|elementor-element)[-_]/i, "");
const tokens = s.split(/[\s\-_]+/).filter(Boolean);
const kept: string[] = [];
for (const t of tokens) {
if (kept.length >= 3) break;
if (/^\d+$/.test(t)) continue; // pure numeric template ids
if (GENERIC_NAME_WORDS.has(t.toLowerCase())) continue; // structural noise
if (looksHashy(t)) continue; // trailing entropy suffix (mixed-case aware)
if (t.length < 2) continue;
const lc = t.toLowerCase();
kept.push(lc[0]!.toUpperCase() + lc.slice(1));
}
return kept.join("");
}
// A source `id` (or `data-section-type`) is a DELIBERATE, developer-authored block name only
// when it carries a recognized CMS section prefix — Shopify's `shopify-section-…__split_callout`,
// or a generic `section-…`/`…-section`. Arbitrary utility classes (`g_section_space`,
// `duraldar-cta_section`) are styling noise, so we do NOT mine class names — a truncated heading
// slug reads better than a made-up name. `js-*` behaviour hooks ARE intentional and allowed.
const CMS_SECTION_ID = /shopify-section|^section[-_]|[-_]section(?:[-_]|$)|data-section/i;
/** Best semantic name derivable from a section subtree's source *ids* + `js-*` hooks the
* only source signals reliable enough to beat a heading slug. Scans the root + shallow
* descendants. Returns "" when no trustworthy semantic evidence exists. */
function sourceNameForSection(sec: IRNode): string {
const candidates: string[] = [];
const collect = (n: IRNode, depth: number): void => {
const id = n.attrs.id;
if (id && CMS_SECTION_ID.test(id)) candidates.push(id);
const sectionType = n.attrs["data-section-type"] ?? n.attrs["data-section"];
if (sectionType) candidates.push(sectionType);
if (n.srcClass) {
// Only explicit `js-…` behaviour hooks — a deliberate semantic handle, not a utility class.
for (const cls of n.srcClass.split(/\s+/)) if (/^js[-_][a-z]/i.test(cls)) candidates.push(cls);
}
if (depth > 0) for (const c of elementChildren(n)) collect(c, depth - 1);
};
collect(sec, 2);
for (const c of candidates) {
const name = nameFromSourceToken(c);
if (name && name.length >= 3) return name;
}
return "";
}
function looksLikeNav(n: IRNode, cw: number): boolean { function looksLikeNav(n: IRNode, cw: number): boolean {
if (n.tag === "nav" || n.tag === "header") return true; if (n.tag === "nav" || n.tag === "header") return true;
if (subtreeHasTag(n, "nav")) return true; if (subtreeHasTag(n, "nav")) return true;
@@ -225,18 +297,11 @@ function recipeFallbackSections(ir: IR, recipes?: RecipeReport): { sections: IRN
export function planSections(ir: IR, recipes?: RecipeReport): SectionPlan { export function planSections(ir: IR, recipes?: RecipeReport): SectionPlan {
const cw = ir.doc.canonicalViewport; const cw = ir.doc.canonicalViewport;
// Descend through the wrapper chain (single significant child) to the container const pageH = ir.doc.perViewport[cw]?.scrollHeight ?? 0;
// whose children are the actual sections. // The shared recursive band decomposition (infer/sections) — the same roots the
let node = ir.root; // section gate validates, so each emitted file corresponds to a gate section.
for (let i = 0; i < 10; i++) { const bands = detectSectionNodes(ir).filter((n) => n.id !== ir.root.id);
const sig = significantChildren(node, cw); // Exclude any band that is part of a REPEATED run (≥3 same-signature siblings): that's
if (sig.length >= 2) break;
if (sig.length === 1) { node = sig[0]!; continue; }
const kids = elementChildren(node);
if (kids.length === 1) { node = kids[0]!; continue; }
break;
}
// Exclude any child that is part of a REPEATED run (≥3 same-signature siblings): that's
// a component cluster (a card/logo grid), which component extraction should turn into a // a component cluster (a card/logo grid), which component extraction should turn into a
// `.map()` over a data array — not a wall of near-identical "section" files. Only the // `.map()` over a data array — not a wall of near-identical "section" files. Only the
// distinct, one-off blocks become sections. // distinct, one-off blocks become sections.
@@ -246,30 +311,15 @@ export function planSections(ir: IR, recipes?: RecipeReport): SectionPlan {
return list.filter((s) => (count.get(subtreeSignature(s)) ?? 0) < 3); return list.filter((s) => (count.get(subtreeSignature(s)) ?? 0) < 3);
}; };
let candidates = significantChildren(node, cw); let sections = distinctOf(bands);
let sections = distinctOf(candidates);
const fallback = recipeFallbackSections(ir, recipes);
// If the container yields too few sections, a dominant child is a wrapper (e.g. <main>)
// holding the real sections — expand such oversized children into their own significant
// children, keeping the other siblings (e.g. a sibling <footer>). Gated on <3 so a page
// that already splits cleanly is untouched.
if (sections.length < 3) {
const pageH = ir.doc.perViewport[cw]?.scrollHeight ?? 0;
const expanded: IRNode[] = [];
for (const c of candidates) {
const inner = significantChildren(c, cw);
const h = box(c, cw)?.height ?? 0;
if (inner.length >= 3 && pageH > 0 && h >= pageH * 0.4) expanded.push(...inner);
else expanded.push(c);
}
candidates = expanded;
sections = distinctOf(candidates);
}
let recipeNameHints = new Map<string, string>(); let recipeNameHints = new Map<string, string>();
if (sections.length < 3 && fallback.sections.length >= 3) { if (sections.length < 3) {
const fallback = recipeFallbackSections(ir, recipes);
if (fallback.sections.length >= 3) {
sections = fallback.sections; sections = fallback.sections;
recipeNameHints = fallback.names; recipeNameHints = fallback.names;
} }
}
const roots = new Map<string, string>(); const roots = new Map<string, string>();
if (sections.length < 3 || sections.length > MAX_SECTIONS) return { roots }; if (sections.length < 3 || sections.length > MAX_SECTIONS) return { roots };
@@ -280,6 +330,10 @@ export function planSections(ir: IR, recipes?: RecipeReport): SectionPlan {
return n === 1 ? base : `${base}${n}`; return n === 1 ? base : `${base}${n}`;
}; };
// Hero must actually start near the top of the page; without the gate a
// mid-page band inherits the name when the true hero was excluded (e.g. as a
// repeated run), which is worse than an honest content-derived name.
const heroMaxY = Math.max(900, pageH * 0.25);
let heroAssigned = false; let heroAssigned = false;
sections.forEach((sec, i) => { sections.forEach((sec, i) => {
const isLast = i === sections.length - 1; const isLast = i === sections.length - 1;
@@ -287,18 +341,31 @@ export function planSections(ir: IR, recipes?: RecipeReport): SectionPlan {
let name: string; let name: string;
if (sec.tag === "footer" || (isLast && looksLikeNav(sec, cw) === false && subtreeHasTag(sec, "a") && (box(sec, cw)?.height ?? 0) < 700)) { if (sec.tag === "footer" || (isLast && looksLikeNav(sec, cw) === false && subtreeHasTag(sec, "a") && (box(sec, cw)?.height ?? 0) < 700)) {
name = sec.tag === "footer" ? "Footer" : (titleText(sec) ? slugWords(titleText(sec)) + "Section" : "Footer"); name = sec.tag === "footer" ? "Footer" : (titleText(sec) ? slugWords(titleText(sec)) + "Section" : "Footer");
} else if (i === 0 && looksLikeNav(sec, cw)) { } else if (sec.tag === "nav"
|| (i === 0 && looksLikeNav(sec, cw) && !heroLikeHeader(sec, cw))
// a thin fixed bar wrapping the real <nav> (often a styled <div>, sorted after
// the hero it overlays) is still the navbar
|| (yOf(sec, cw) <= 160 && (box(sec, cw)?.height ?? 0) <= 160 && subtreeHasTag(sec, "nav"))) {
name = "Navbar"; name = "Navbar";
} else if (sec.tag === "header") { } else if (sec.tag === "header" && !heroLikeHeader(sec, cw)) {
// a <header> carrying the page h1 at real height is the hero, not chrome
name = "Header"; name = "Header";
} else if (!heroAssigned) { } else if (!heroAssigned && yOf(sec, cw) <= heroMaxY) {
heroAssigned = true; heroAssigned = true;
name = "HeroSection"; name = "HeroSection";
} else if (recipeName) { } else if (recipeName) {
name = recipeName; name = recipeName;
} else { } else {
// Prefer a clean semantic name from the source markup (Shopify/CMS section ids +
// `js-*` hooks, hash suffixes stripped) over a truncated heading slug — the source
// slug (`split_callout` → SplitCalloutSection) reads more like a hand-authored name.
const sourceName = sourceNameForSection(sec);
const slug = slugWords(titleText(sec)); const slug = slugWords(titleText(sec));
name = slug ? `${slug}Section` : `Section${i + 1}`; name = sourceName ? `${sourceName}Section`
: slug ? `${slug}Section`
: subtreeHasTag(sec, "form", 6) ? "ContactSection"
: subtreeHasTag(sec, "video", 6) || subtreeHasTag(sec, "iframe", 6) ? "MediaSection"
: `Section${i + 1}`;
} }
roots.set(sec.id, dedupe(identName(name))); roots.set(sec.id, dedupe(identName(name)));
}); });
+83 -11
View File
@@ -289,8 +289,15 @@ function metadataObject(report: SeoInventory): Record<string, unknown> {
if (keywords) metadata.keywords = keywords; if (keywords) metadata.keywords = keywords;
if (report.robots) metadata.robots = report.robots; if (report.robots) metadata.robots = report.robots;
if (report.referrer) metadata.referrer = report.referrer; if (report.referrer) metadata.referrer = report.referrer;
const sourceOrigin = originOf(report.sourceUrl);
const alternates: Record<string, unknown> = {}; const alternates: Record<string, unknown> = {};
if (report.canonicalUrl) alternates.canonical = report.canonicalUrl; // Canonical is relativized to the source origin path so metadataBase (the clone's own
// origin) resolves it — never hard-coding the source domain. Off-origin canonicals are rare;
// keep them verbatim.
if (report.canonicalUrl) {
const c = relativizeToSource(report.canonicalUrl, sourceOrigin);
alternates.canonical = c.path;
}
if (report.alternates.length) { if (report.alternates.length) {
const languages: Record<string, string> = {}; const languages: Record<string, string> = {};
for (const alt of report.alternates) languages[alt.hrefLang] = alt.href; for (const alt of report.alternates) languages[alt.hrefLang] = alt.href;
@@ -314,7 +321,7 @@ function metadataObject(report: SeoInventory): Record<string, unknown> {
else other["og:type"] = ogType; else other["og:type"] = ogType;
} }
if (ogSiteName) og.siteName = ogSiteName; if (ogSiteName) og.siteName = ogSiteName;
if (ogUrl) og.url = ogUrl; if (ogUrl) og.url = relativizeToSource(ogUrl, sourceOrigin).path;
if (ogImages.length) og.images = ogImages; if (ogImages.length) og.images = ogImages;
if (Object.keys(og).length) metadata.openGraph = og; if (Object.keys(og).length) metadata.openGraph = og;
@@ -352,8 +359,23 @@ function metadataObject(report: SeoInventory): Record<string, unknown> {
return metadata; return metadata;
} }
// A sentinel object value that metadataExport rewrites into the runtime `metadataBase`
// expression (JSON can't hold `new URL(...)`). Chosen so it never collides with real content.
const METADATA_BASE_SENTINEL = "__DITTO_METADATA_BASE__";
// The metadata/JSON-LD layout module references SITE_ORIGIN — callers must hoist this import.
export const SITE_ORIGIN_LAYOUT_IMPORT = siteOriginImportLine(1);
export function metadataExport(report: SeoInventory): string { export function metadataExport(report: SeoInventory): string {
return `export const metadata = ${JSON.stringify(metadataObject(report), null, 2)};\n`; const obj = metadataObject(report);
// metadataBase lets Next resolve the (now relative) canonical/openGraph URLs against the
// clone's OWN origin — SITE_ORIGIN when set, localhost in dev. Placed first for readability.
const withBase = { metadataBase: METADATA_BASE_SENTINEL, ...obj };
const body = JSON.stringify(withBase, null, 2).replace(
JSON.stringify(METADATA_BASE_SENTINEL),
`new URL(SITE_ORIGIN || "http://localhost:3000")`,
);
return `export const metadata = ${body};\n`;
} }
export function viewportExport(report: SeoInventory): string { export function viewportExport(report: SeoInventory): string {
@@ -367,13 +389,31 @@ function safeJsonLd(text: string): string {
return text.replace(/<\/script/gi, "<\\/script").replace(/<!--/g, "<\\!--"); return text.replace(/<\/script/gi, "<\\/script").replace(/<!--/g, "<\\!--");
} }
/** Rewrite a JSON-LD block's on-origin @id/url/contentUrl/target values off the SOURCE domain.
* The frozen JSON escapes slashes (`https:\/\/host\/…`), so we split on both the escaped and
* plain origin forms and rejoin with SITE_ORIGIN at RUNTIME the source domain never appears
* in output, and setting NEXT_PUBLIC_SITE_ORIGIN re-hosts the ids under the clone's own origin.
* Returns a JS expression string. Off-origin links (genuinely external) are left untouched. */
function jsonLdHtmlExpr(text: string, sourceOrigin: string): string {
const safe = safeJsonLd(text);
if (!sourceOrigin) return JSON.stringify(safe);
const escaped = sourceOrigin.replace(/\//g, "\\/");
// Split on whichever origin form appears; only one form is present in a given block.
const forms = safe.includes(escaped) ? escaped : (safe.includes(sourceOrigin) ? sourceOrigin : null);
if (!forms) return JSON.stringify(safe);
const segments = safe.split(forms);
if (segments.length === 1) return JSON.stringify(safe);
return `[${segments.map((s) => JSON.stringify(s)).join(", ")}].join(SITE_ORIGIN)`;
}
export function jsonLdHeadMarkup(report: SeoInventory, indent = 8): string { export function jsonLdHeadMarkup(report: SeoInventory, indent = 8): string {
if (!report.jsonLd.length) return ""; if (!report.jsonLd.length) return "";
const pad = " ".repeat(indent); const pad = " ".repeat(indent);
const sourceOrigin = originOf(report.sourceUrl);
return report.jsonLd.map((entry, index) => `${pad}<script return report.jsonLd.map((entry, index) => `${pad}<script
${pad} key="ditto-json-ld-${index}" ${pad} key="ditto-json-ld-${index}"
${pad} type="application/ld+json" ${pad} type="application/ld+json"
${pad} dangerouslySetInnerHTML={{ __html: ${JSON.stringify(safeJsonLd(entry.text))} }} ${pad} dangerouslySetInnerHTML={{ __html: ${jsonLdHtmlExpr(entry.text, sourceOrigin)} }}
${pad}/>`).join("\n"); ${pad}/>`).join("\n");
} }
@@ -397,6 +437,27 @@ function originOf(url: string): string {
try { return new URL(url).origin; } catch { return "https://example.com"; } try { return new URL(url).origin; } catch { return "https://example.com"; }
} }
/** The single origin constant every generated SEO/metadata route resolves against. Empty by
* default so canonical/sitemap/JSON-LD URLs render RELATIVE to wherever the clone is served
* (never the source domain); set NEXT_PUBLIC_SITE_ORIGIN to make them absolute to the clone's
* own origin. Emitted once as src/lib/site.ts. */
export const SITE_ORIGIN_MODULE = `export const SITE_ORIGIN = (process.env.NEXT_PUBLIC_SITE_ORIGIN ?? "").replace(/\\/$/, "");\n`;
/** The `import { SITE_ORIGIN } from "…/lib/site"` line; `depth` mirrors cnImportLine. */
export function siteOriginImportLine(depth: number): string {
return `import { SITE_ORIGIN } from "${"../".repeat(Math.max(1, depth))}lib/site";`;
}
/** Path portion of a URL that sits on the source origin (so it can be re-hosted under
* SITE_ORIGIN); returns the input unchanged for off-origin (genuinely external) URLs. */
function relativizeToSource(url: string, sourceOrigin: string): { onOrigin: boolean; path: string } {
if (sourceOrigin && url.startsWith(sourceOrigin)) {
const path = url.slice(sourceOrigin.length) || "/";
return { onOrigin: true, path: path.startsWith("/") ? path : "/" + path };
}
return { onOrigin: false, path: url };
}
function generatedLlms(report: SeoInventory, routes: SeoRouteSummary[]): string { function generatedLlms(report: SeoInventory, routes: SeoRouteSummary[]): string {
const title = report.title || routes[0]?.title || "Generated Clone"; const title = report.title || routes[0]?.title || "Generated Clone";
const lines: string[] = [`# ${title}`, ""]; const lines: string[] = [`# ${title}`, ""];
@@ -422,15 +483,17 @@ function generatedLlms(report: SeoInventory, routes: SeoRouteSummary[]): string
export function seoRouteFiles(report: SeoInventory, routes: SeoRouteSummary[]): Array<[string, string]> { export function seoRouteFiles(report: SeoInventory, routes: SeoRouteSummary[]): Array<[string, string]> {
const origin = originOf(report.sourceUrl); const origin = originOf(report.sourceUrl);
const sitemapUrl = origin + "/sitemap.xml"; // robots.ts / sitemap.ts live at src/app → depth 1 below src.
const siteImport = siteOriginImportLine(1);
const robots = `import type { MetadataRoute } from "next"; const robots = `import type { MetadataRoute } from "next";
${siteImport}
export const dynamic = "force-static"; export const dynamic = "force-static";
export default function robots(): MetadataRoute.Robots { export default function robots(): MetadataRoute.Robots {
return { return {
rules: { userAgent: "*", allow: "/" }, rules: { userAgent: "*", allow: "/" },
sitemap: ${JSON.stringify(sitemapUrl)}, sitemap: SITE_ORIGIN + "/sitemap.xml",
}; };
} }
`; `;
@@ -441,13 +504,21 @@ export default function robots(): MetadataRoute.Robots {
title: report.title || "Home", title: report.title || "Home",
description: report.description, description: report.description,
excerpt: "", excerpt: "",
}]).map((route, index) => ({ url: route.url, changeFrequency: "weekly", priority: index === 0 ? 1 : 0.7 })); }]).map((route, index) => ({ path: relativizeToSource(route.url, origin).path, changeFrequency: "weekly", priority: index === 0 ? 1 : 0.7 }));
// Emit `url: SITE_ORIGIN + "<path>"` so URLs resolve to the clone's own origin (relative by
// default), never the source domain.
const entryLines = sitemapEntries.map((e) =>
` {\n url: SITE_ORIGIN + ${JSON.stringify(e.path)},\n changeFrequency: ${JSON.stringify(e.changeFrequency)},\n priority: ${e.priority},\n }`
).join(",\n");
const sitemap = `import type { MetadataRoute } from "next"; const sitemap = `import type { MetadataRoute } from "next";
${siteImport}
export const dynamic = "force-static"; export const dynamic = "force-static";
export default function sitemap(): MetadataRoute.Sitemap { export default function sitemap(): MetadataRoute.Sitemap {
return ${JSON.stringify(sitemapEntries, null, 2)}; return [
${entryLines},
];
} }
`; `;
const sourceLlms = resourceText(report, "llms"); const sourceLlms = resourceText(report, "llms");
@@ -472,11 +543,12 @@ function xmlEscape(value: string): string {
export function seoStaticFiles(report: SeoInventory, routes: SeoRouteSummary[]): Array<[string, string]> { export function seoStaticFiles(report: SeoInventory, routes: SeoRouteSummary[]): Array<[string, string]> {
const origin = originOf(report.sourceUrl); const origin = originOf(report.sourceUrl);
const sitemapUrl = origin + "/sitemap.xml"; // Static output (Vite) has no runtime env: emit the clone's own paths RELATIVE to the source
// origin so the source domain is never baked in (the requirement's relative default).
const robots = [ const robots = [
"User-agent: *", "User-agent: *",
"Allow: /", "Allow: /",
`Sitemap: ${sitemapUrl}`, "Sitemap: /sitemap.xml",
"", "",
].join("\n"); ].join("\n");
const sitemapRoutes = routes.length ? routes : [{ const sitemapRoutes = routes.length ? routes : [{
@@ -492,7 +564,7 @@ export function seoStaticFiles(report: SeoInventory, routes: SeoRouteSummary[]):
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">', '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
...sitemapRoutes.map((route, index) => [ ...sitemapRoutes.map((route, index) => [
" <url>", " <url>",
` <loc>${xmlEscape(route.url)}</loc>`, ` <loc>${xmlEscape(relativizeToSource(route.url, origin).path)}</loc>`,
" <changefreq>weekly</changefreq>", " <changefreq>weekly</changefreq>",
` <priority>${index === 0 ? "1.0" : "0.7"}</priority>`, ` <priority>${index === 0 ? "1.0" : "0.7"}</priority>`,
" </url>", " </url>",
+279 -22
View File
@@ -18,6 +18,7 @@
import type { IR, IRNode } from "../normalize/ir.js"; import type { IR, IRNode } from "../normalize/ir.js";
import { isTextChild } from "../normalize/ir.js"; import { isTextChild } from "../normalize/ir.js";
import { collectNodeRules, computeBands, keyframesCss, type NodeRule } from "./css.js"; import { collectNodeRules, computeBands, keyframesCss, type NodeRule } from "./css.js";
import { colorClusterKey } from "../infer/semanticTokens.js";
import type { InteractionCapture, StyleDelta } from "../capture/interactions.js"; import type { InteractionCapture, StyleDelta } from "../capture/interactions.js";
// ---- arbitrary-value escaping ---- // ---- arbitrary-value escaping ----
@@ -28,6 +29,25 @@ import type { InteractionCapture, StyleDelta } from "../capture/interactions.js"
function arb(v: string): string { function arb(v: string): string {
return v.replace(/"/g, "'").replace(/_/g, "\\_").replace(/ /g, "_"); return v.replace(/"/g, "'").replace(/_/g, "\\_").replace(/ /g, "_");
} }
/** Round a single simple `<number>px|rem` length, killing frozen sub-pixel noise: snap to an
* integer when the value is within 0.1px of one (measurement/rounding jitter `204.9994px` was
* meant to be `205px`), otherwise keep at most 1 decimal (`204.797px` `204.8px`, a genuine
* fraction). `627px` is unchanged. Only touches a lone number+unit token: multi-token values,
* calc()/clamp()/var(), percentages, and unitless numbers pass through untouched, so nothing that
* needs exact sub-pixel precision (borders, transforms handled on their own branches) is moved. */
export function snapLen(value: string): string {
const m = /^(-?\d*\.?\d+)(px|rem)$/.exec(value.trim());
if (!m) return value;
const n = parseFloat(m[1]!);
const unit = m[2]!;
// rem thresholds scale by the 16px root so "within 0.1px" is unit-consistent.
const pxPerUnit = unit === "rem" ? 16 : 1;
const rounded = Math.abs(n - Math.round(n)) * pxPerUnit < 0.1
? Math.round(n)
: Math.round(n * 10) / 10;
return `${rounded}${unit}`;
}
function arbList(v: string): string { function arbList(v: string): string {
return arb(v).replace(/,_/g, ","); return arb(v).replace(/,_/g, ",");
} }
@@ -53,6 +73,7 @@ const KW: Record<string, Record<string, string>> = {
"font-weight": { "100": "font-thin", "200": "font-extralight", "300": "font-light", "400": "font-normal", "500": "font-medium", "600": "font-semibold", "700": "font-bold", "800": "font-extrabold", "900": "font-black" }, "font-weight": { "100": "font-thin", "200": "font-extralight", "300": "font-light", "400": "font-normal", "500": "font-medium", "600": "font-semibold", "700": "font-bold", "800": "font-extrabold", "900": "font-black" },
"text-decoration-line": { underline: "underline", "line-through": "line-through", overline: "overline", none: "no-underline" }, "text-decoration-line": { underline: "underline", "line-through": "line-through", overline: "overline", none: "no-underline" },
"white-space": { nowrap: "whitespace-nowrap", normal: "whitespace-normal", pre: "whitespace-pre", "pre-line": "whitespace-pre-line", "pre-wrap": "whitespace-pre-wrap", "break-spaces": "whitespace-break-spaces" }, "white-space": { nowrap: "whitespace-nowrap", normal: "whitespace-normal", pre: "whitespace-pre", "pre-line": "whitespace-pre-line", "pre-wrap": "whitespace-pre-wrap", "break-spaces": "whitespace-break-spaces" },
"text-wrap": { wrap: "text-wrap", nowrap: "text-nowrap", balance: "text-balance", pretty: "text-pretty" },
"overflow-x": { hidden: "overflow-x-hidden", auto: "overflow-x-auto", scroll: "overflow-x-scroll", visible: "overflow-x-visible", clip: "overflow-x-clip" }, "overflow-x": { hidden: "overflow-x-hidden", auto: "overflow-x-auto", scroll: "overflow-x-scroll", visible: "overflow-x-visible", clip: "overflow-x-clip" },
"overflow-y": { hidden: "overflow-y-hidden", auto: "overflow-y-auto", scroll: "overflow-y-scroll", visible: "overflow-y-visible", clip: "overflow-y-clip" }, "overflow-y": { hidden: "overflow-y-hidden", auto: "overflow-y-auto", scroll: "overflow-y-scroll", visible: "overflow-y-visible", clip: "overflow-y-clip" },
"object-fit": { contain: "object-contain", cover: "object-cover", fill: "object-fill", none: "object-none", "scale-down": "object-scale-down" }, "object-fit": { contain: "object-contain", cover: "object-cover", fill: "object-fill", none: "object-none", "scale-down": "object-scale-down" },
@@ -243,7 +264,14 @@ export function declToUtil(prop: string, value: string): string {
if (value === "0" || value === "0px" || value === "0rem") { if (value === "0" || value === "0px" || value === "0rem") {
return ZERO_NAMED.has(prop) ? `${ARB[prop]}-0` : `${ARB[prop]}-[0px]`; return ZERO_NAMED.has(prop) ? `${ARB[prop]}-0` : `${ARB[prop]}-[0px]`;
} }
return `${ARB[prop]}-[${arb(value)}]`; // `snapLen`'s 0.1px integer snap is calibrated for BOX lengths (killing measurement jitter like
// `204.9994px`→`205px`). letter-spacing is authored at a far finer scale — a real `-0.08px`/
// `-0.0375px` tracking is WITHIN 0.1px of zero, so snapLen would collapse it to `0px`. Chromium
// then serializes computed `letter-spacing:0` as the keyword `normal`, which the style gate's
// numeric compare can't parse → a false exact-string mismatch. Skip the integer snap for tracking;
// `snapBase` rounds it to 2 decimals (`tracking-[-0.08px]`), the right precision for this axis.
if (prop === "letter-spacing") return `${ARB[prop]}-[${arb(value)}]`;
return `${ARB[prop]}-[${arb(snapLen(value))}]`;
} }
if (/^border-(top|right|bottom|left)-width$/.test(prop)) { if (/^border-(top|right|bottom|left)-width$/.test(prop)) {
const side = prop.split("-")[1]![0]!; // t/r/b/l const side = prop.split("-")[1]![0]!; // t/r/b/l
@@ -353,7 +381,7 @@ function parseSide(b: string): SidePart | null {
for (const h of COLLAPSE_HEADS) if (body.startsWith(h + "-")) return { neg, head: h, suf: body.slice(h.length + 1) }; for (const h of COLLAPSE_HEADS) if (body.startsWith(h + "-")) return { neg, head: h, suf: body.slice(h.length + 1) };
return null; return null;
} }
function collapseBases(bases: string[]): string[] { export function collapseBases(bases: string[]): string[] {
const byHead = new Map<string, SidePart>(); // head → its parsed part (sides are unique within a group) const byHead = new Map<string, SidePart>(); // head → its parsed part (sides are unique within a group)
for (const b of bases) { const p = parseSide(b); if (p) byHead.set(p.head, p); } for (const b of bases) { const p = parseSide(b); if (p) byHead.set(p.head, p); }
const origOf = new Map<string, string>(); // head → original base string (to drop/replace by identity) const origOf = new Map<string, string>(); // head → original base string (to drop/replace by identity)
@@ -410,6 +438,18 @@ function collapseBases(bases: string[]): string[] {
if (wv !== null) { const sfx = wv ? `-${wv}` : ""; replace.set(`border-t${sfx}`, `border${sfx}`); for (const s of ["r", "b", "l"]) drop.add(`border-${s}${sfx}`); } if (wv !== null) { const sfx = wv ? `-${wv}` : ""; replace.set(`border-t${sfx}`, `border${sfx}`); for (const s of ["r", "b", "l"]) drop.add(`border-${s}${sfx}`); }
const cv = allEqSide(bcSuf); const cv = allEqSide(bcSuf);
if (cv !== null) { replace.set(`border-t-${cv}`, `border-${cv}`); for (const s of ["r", "b", "l"]) drop.add(`border-${s}-${cv}`); } if (cv !== null) { replace.set(`border-t-${cv}`, `border-${cv}`); for (const s of ["r", "b", "l"]) drop.add(`border-${s}-${cv}`); }
// flex:1 1 0% → the idiomatic `flex-1` (Tailwind `flex-1` compiles to `flex: 1 1 0%`, EXACTLY these
// longhands). Fold when this band sets flex-grow:1 (`grow-[1]`) AND a zero flex-basis (`basis-[0%]`,
// or its already-shortened `basis-0` — reached from a band delta). flex-shrink is the CSS default 1
// (elided) unless an explicit `shrink-0` is present, which would break the equivalence — so require
// its absence. Emitting `flex-1` rather than `grow basis-[0%]` also sidesteps the `basis-[0%]`→`basis
// -0` prettify hazard (0% content-sizes against an indefinite main axis; 0px is a definite zero).
const hasGrow1 = bases.includes("grow-[1]");
const zeroBasis = bases.includes("basis-[0%]") ? "basis-[0%]" : bases.includes("basis-0") ? "basis-0" : null;
if (hasGrow1 && zeroBasis && !bases.includes("shrink-0")) {
replace.set("grow-[1]", "flex-1");
drop.add(zeroBasis);
}
const out: string[] = []; const out: string[] = [];
for (const b of bases) { if (drop.has(b)) continue; out.push(replace.get(b) ?? b); } for (const b of bases) { if (drop.has(b)) continue; out.push(replace.get(b) ?? b); }
return out; return out;
@@ -490,6 +530,14 @@ export function prettifyBase(base: string): string {
const pm = /^(w|h|min-w|min-h|basis|inset|inset-x|inset-y|top|right|bottom|left)-\[(\d*\.?\d+)%\]$/.exec(base); const pm = /^(w|h|min-w|min-h|basis|inset|inset-x|inset-y|top|right|bottom|left)-\[(\d*\.?\d+)%\]$/.exec(base);
if (pm) { if (pm) {
const v = parseFloat(pm[2]!); const v = parseFloat(pm[2]!);
// A percentage on a MAIN-SIZE axis whose containing block may be indefinite is NOT equal to the
// same length in px. `flex-basis:0%` against an auto-sized (indefinite) flex container falls back
// to CONTENT sizing per the flexbox spec, whereas `flex-basis:0` (definite zero) gives a zero base
// size — collapsing a `flex:1 1 0%` item to 0 in an auto-height column. `height`/`min-height` in %
// resolve to `auto` when the containing block's height is indefinite, the same hazard. So never
// rewrite `[0%]` to the definite `-0` for these prefixes; keep the literal `basis-[0%]`/`h-[0%]`.
// (Width/inset percentages resolve against the always-definite containing-block WIDTH — safe.)
if (v === 0 && (pm[1] === "basis" || pm[1] === "h" || pm[1] === "min-h")) return base;
const frac = FRACTIONS.find(([p]) => Math.abs(p - v) < 0.4); const frac = FRACTIONS.find(([p]) => Math.abs(p - v) < 0.4);
return frac ? `${pm[1]}-${frac[1]}` : base; return frac ? `${pm[1]}-${frac[1]}` : base;
} }
@@ -544,7 +592,6 @@ function namedText(base: string): string | null {
// nearest scale step / integer px when comfortably INSIDE that budget (ε well under the gate tol), so // nearest scale step / integer px when comfortably INSIDE that budget (ε well under the gate tol), so
// the markup reads hand-authored. Bounded ε + the layout & perceptual gates backstop any accumulation; // the markup reads hand-authored. Bounded ε + the layout & perceptual gates backstop any accumulation;
// values not near a step stay arbitrary because they are genuine one-offs. // values not near a step stay arbitrary because they are genuine one-offs.
const SNAP_SPACE_PX = 1.0; // padding/margin/gap/inset/size → nearest 0.25rem scale step within this
const SNAP_TYPE_PX = 0.6; // font-size/line-height/radius → nearest integer px within this const SNAP_TYPE_PX = 0.6; // font-size/line-height/radius → nearest integer px within this
const NAMED_K_SORTED = [...NAMED_K].sort((a, b) => a - b); const NAMED_K_SORTED = [...NAMED_K].sort((a, b) => a - b);
function nearestNamedK(k: number): number { function nearestNamedK(k: number): number {
@@ -552,7 +599,7 @@ function nearestNamedK(k: number): number {
for (const c of NAMED_K_SORTED) if (Math.abs(c - k) < Math.abs(best - k)) best = c; for (const c of NAMED_K_SORTED) if (Math.abs(c - k) < Math.abs(best - k)) best = c;
return best; return best;
} }
function snapBase(base: string): string { export function snapBase(base: string): string {
const m = /^(-?)([a-z][a-z-]*)-\[(-?\d*\.?\d+)(px|rem)\]$/.exec(base); const m = /^(-?)([a-z][a-z-]*)-\[(-?\d*\.?\d+)(px|rem)\]$/.exec(base);
if (!m) return base; if (!m) return base;
const neg = m[1]!, prefix = m[2]!, px = parseFloat(m[3]!) * (m[4] === "rem" ? 16 : 1); const neg = m[1]!, prefix = m[2]!, px = parseFloat(m[3]!) * (m[4] === "rem" ? 16 : 1);
@@ -568,10 +615,16 @@ function snapBase(base: string): string {
if (Math.abs(r - px) <= SNAP_TYPE_PX && r in RADIUS_SUFFIX) return `${prefix}${RADIUS_SUFFIX[r]}`; if (Math.abs(r - px) <= SNAP_TYPE_PX && r in RADIUS_SUFFIX) return `${prefix}${RADIUS_SUFFIX[r]}`;
return base; return base;
} }
// spacing / size → nearest named scale step (0.25rem grid) // spacing / size → nearest named scale step (0.25rem grid). Only snap a value that lands ESSENTIALLY
// ON a step (≤0.25px): the scale is 2px-granular (`p-0.5`=2px, `p-1.5`=6px), so a captured value ≥0.25px
// off the nearest step is a genuine sub-step measurement (3.5px is BETWEEN 2px and 4px — not on the
// scale). Snapping such a value up by +0.5px per side accumulates across the links of a fixed-width
// flex row until the items overflow and wrap to a second line; the exact `p-[3.5px]` cannot. The wider
// ±1px budget the gate tolerates in isolation is unsafe here because the error is systematic, not
// random. Values genuinely on a step (2px→`p-0.5`, 4px→`p-1`) still snap.
if (SCALE_PREFIXES.has(prefix)) { if (SCALE_PREFIXES.has(prefix)) {
const k = nearestNamedK(px / 4); const k = nearestNamedK(px / 4);
if (Math.abs(k * 4 - px) <= SNAP_SPACE_PX) return k === 0 ? `${neg}${prefix}-0` : `${neg}${prefix}-${k}`; if (Math.abs(k * 4 - px) <= 0.25) return k === 0 ? `${neg}${prefix}-0` : `${neg}${prefix}-${k}`;
} }
return base; return base;
} }
@@ -694,6 +747,25 @@ function sourceVariantActive(variant: string, vp: number): boolean {
return true; 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 { function sourceArbitraryInner(suffix: string): string | null {
return suffix.startsWith("[") && suffix.endsWith("]") ? suffix.slice(1, -1) : null; return suffix.startsWith("[") && suffix.endsWith("]") ? suffix.slice(1, -1) : null;
} }
@@ -709,6 +781,118 @@ function sourceFluidLengthSuffix(suffix: string): boolean {
return /(%|vw|vh|svw|lvw|dvw|svh|lvh|dvh|fr|min-content|max-content|fit-content)/.test(inner); return /(%|vw|vh|svw|lvw|dvw|svh|lvh|dvh|fr|min-content|max-content|fit-content)/.test(inner);
} }
// A FIXED, definite authored length suffix on a `w-`/`h-` utility: an arbitrary `[<px|rem|em|…>]`, the
// `px` token (1px), or a numeric spacing-scale token (`24`, `2.5` → fixed rem). Unlike
// sourceFluidLengthSuffix these do NOT re-derive from the container, so the source-intent pass must
// re-emit them as the CAPTURED computed px (the clone's root font-size may differ from the source's, so
// echoing `h-[4rem]` verbatim would mis-size), and only where geometry corroborates the resolved box.
function sourceFixedLengthSuffix(suffix: string): boolean {
const inner = sourceArbitraryInner(suffix);
if (inner !== null) {
const v = inner.trim().toLowerCase();
if (/^var\(/.test(v)) return false; // handled by the var-length path
if (/%|vw|vh|vmin|vmax|svw|lvw|dvw|svh|lvh|dvh|\bfr\b|min-content|max-content|fit-content/.test(v)) return false;
return /(?:^|[\s(*/+-])[\d.]+(?:px|rem|em|cm|mm|in|pt|pc|ex|ch|q)\b/.test(v);
}
if (suffix === "px") return true;
return /^\d+(?:\.\d+)?$/.test(suffix);
}
// A `w-`/`h-` utility carrying a fixed authored length (as recognised by sourceFixedLengthSuffix).
function isFixedLengthUtil(util: string): boolean {
const m = /^([wh])-(.+)$/.exec(util);
return !!m && sourceFixedLengthSuffix(m[2]!);
}
// The captured computed px on this axis is definite and matches the measured bbox — i.e. the authored
// fixed length resolved to the box the capture recorded. Geometry corroboration for a fixed w/h util.
function fixedLengthResolvesToBox(node: IRNode, axis: "w" | "h", vp: number): boolean {
const cs = node.computedByVp[vp]; const b = node.bboxByVp[vp];
if (!cs || !b) return false;
const val = axis === "h" ? cs.height : cs.width;
const ext = axis === "h" ? b.height : b.width;
if (!val || val === "auto" || !/px$/.test(val)) return false;
if (!(ext > 0)) return false;
return Math.abs(pf(val) - ext) <= Math.max(1.5, 0.01 * ext);
}
// Rewrite a fixed-length `w-`/`h-` source utility to the node's CAPTURED computed px at a viewport,
// preserving any variant prefix. `h-[4rem]` → `h-[60px]` (the source resolved 4rem against a 15px root;
// the clone's root may differ, so bake the measured px). Returns the utility unchanged when it is not a
// fixed-length util or the computed px is unavailable.
function fixedLengthUtilAsPx(util: string, node: IRNode, vp: number): string {
const m = VARIANT_PREFIX.exec(util);
const prefix = m ? m[1]! : "";
const core = m ? m[2]! : util;
const sm = /^([wh])-(.+)$/.exec(core);
if (!sm || !sourceFixedLengthSuffix(sm[2]!)) return util;
const val = sm[1] === "h" ? node.computedByVp[vp]?.height : node.computedByVp[vp]?.width;
if (!val || !/px$/.test(val)) return util;
const px = Math.round(pf(val) * 100) / 100;
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. v0v2 `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<string, number> = {
"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 { function sourceAspectUtility(core: string): string | null {
let m = /^aspect-(\d+)\/(\d+)-box$/.exec(core); let m = /^aspect-(\d+)\/(\d+)-box$/.exec(core);
if (m) return m[1] === m[2] ? "aspect-square" : `aspect-[${m[1]}/${m[2]}]`; if (m) return m[1] === m[2] ? "aspect-square" : `aspect-[${m[1]}/${m[2]}]`;
@@ -740,7 +924,7 @@ function sourceAxisForCore(core0: string): { axis: SourceAxis; utility: string }
const maxH = /^max-h-(.+)$/.exec(core); const maxH = /^max-h-(.+)$/.exec(core);
if (maxH && sourceFluidLengthSuffix(maxH[1]!)) return { axis: "max-h", utility: core }; if (maxH && sourceFluidLengthSuffix(maxH[1]!)) return { axis: "max-h", utility: core };
const size = /^([wh])-(.+)$/.exec(core); const size = /^([wh])-(.+)$/.exec(core);
if (size && sourceFluidLengthSuffix(size[2]!)) return { axis: size[1] as SourceAxis, utility: core }; if (size && (sourceFluidLengthSuffix(size[2]!) || sourceFixedLengthSuffix(size[2]!))) return { axis: size[1] as SourceAxis, utility: core };
return null; return null;
} }
@@ -904,6 +1088,12 @@ function sourceAxisCompatible(node: IRNode, parent: IRNode | undefined, axis: So
if (axis === "h" && v === "h-full") return !!s?.hFill; if (axis === "h" && v === "h-full") return !!s?.hFill;
if (axis === "h" && v === "h-auto") return !!s?.hAuto; if (axis === "h" && v === "h-auto") return !!s?.hAuto;
if (axis === "h" && v && sourceVarName(v)) return /px$/.test(node.computedByVp[vp]?.height || ""); if (axis === "h" && v && sourceVarName(v)) return /px$/.test(node.computedByVp[vp]?.height || "");
// A FIXED authored length (`h-[4rem]`, `w-24`, `h-px`) — the source-intent pass will re-emit it as
// the captured computed px. Compatible when that px is definite and equals the measured bbox extent
// at this viewport (the authored length actually resolved to the captured box). This recovers a
// banded authored fixed height/width the sizing probe dropped through the fill↔content cycle
// (h-full) or the content/fill drop (auto), without depending on the clone's root font-size.
if (v && isFixedLengthUtil(v)) return fixedLengthResolvesToBox(node, axis, vp);
return false; return false;
}); });
} }
@@ -932,6 +1122,13 @@ function sourceIntentVarCss(node: IRNode, axis: SourceAxis, values: Map<number,
function sourceIntentUtilities(node: IRNode, parent: IRNode | undefined, viewports: number[], canonical: number): SourceIntent { function sourceIntentUtilities(node: IRNode, parent: IRNode | undefined, viewports: number[], canonical: number): SourceIntent {
const perAxis = new Map<SourceAxis, Map<number, string>>(); const perAxis = new Map<SourceAxis, Map<number, string>>();
// 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<SourceAxis, Map<number, number>>();
for (const tok of parseSourceClass(node.srcClass)) { for (const tok of parseSourceClass(node.srcClass)) {
const parsed = sourceAxisForCore(tok.core); const parsed = sourceAxisForCore(tok.core);
if (!parsed) continue; if (!parsed) continue;
@@ -939,7 +1136,12 @@ function sourceIntentUtilities(node: IRNode, parent: IRNode | undefined, viewpor
if (!sourceVariantActive(tok.variant, vp)) continue; if (!sourceVariantActive(tok.variant, vp)) continue;
let m = perAxis.get(parsed.axis); let m = perAxis.get(parsed.axis);
if (!m) { m = new Map<number, string>(); perAxis.set(parsed.axis, m); } if (!m) { m = new Map<number, string>(); perAxis.set(parsed.axis, m); }
m.set(vp, parsed.utility); let s = specAt.get(parsed.axis);
if (!s) { s = new Map<number, number>(); 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<SourceAxis>(); const axes = new Set<SourceAxis>();
@@ -947,15 +1149,47 @@ function sourceIntentUtilities(node: IRNode, parent: IRNode | undefined, viewpor
const css: string[] = []; const css: string[] = [];
const bands = computeBands(viewports, canonical); const bands = computeBands(viewports, canonical);
for (const [axis, byVp] of perAxis) { for (const [axis, byVp] of perAxis) {
if (!viewports.every((vp) => byVp.has(vp))) continue; // avoid partial custom-class inference for now if (!viewports.every((vp) => byVp.has(vp))) {
// Partial-coverage escape hatch for SUBGRID. `grid-rows-subgrid`/`grid-cols-subgrid` is a
// structural keyword authored as a variant-only utility (`max-lg:grid-rows-subgrid`, with the
// axis resolving to explicit tracks at ≥lg), so the map covers only a subset of viewports and
// the full-coverage bail below would throw it away. Subgrid is safe to band partially: emit it
// exactly at the covered viewports as banded variants (and as the base only when canonical is
// covered). Adding the axis lets the redundant computed-derived subgrid bands drop in favour of
// this authored intent. Other partial axes still bail (partial fluid inference is unsafe).
const subgridOnly = (axis === "grid-rows" || axis === "grid-cols") &&
[...byVp.values()].every((u) => /-subgrid$/.test(u));
if (!subgridOnly) continue;
if (!sourceAxisCompatible(node, parent, axis, byVp, viewports)) continue; if (!sourceAxisCompatible(node, parent, axis, byVp, viewports)) continue;
const base = byVp.get(canonical)!; axes.add(axis);
const canon = byVp.get(canonical);
if (canon) utilities.push(canon);
for (const b of bands) {
if (!b.media) continue;
const v = byVp.get(b.vp);
if (v && v !== canon) utilities.push(prefixFor(b.media) + v);
}
continue;
}
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. 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); axes.add(axis);
if (axis === "aspect") axes.add("grid-rows"); if (axis === "aspect") axes.add("grid-rows");
utilities.push(base); utilities.push(base);
for (const b of bands) { for (const b of bands) {
if (!b.media) continue; if (!b.media) continue;
const v = byVp.get(b.vp)!; const v = asEmit(b.vp);
if (v !== base) utilities.push(prefixFor(b.media) + v); if (v !== base) utilities.push(prefixFor(b.media) + v);
} }
css.push(...sourceIntentVarCss(node, axis, byVp, viewports, canonical)); css.push(...sourceIntentVarCss(node, axis, byVp, viewports, canonical));
@@ -988,11 +1222,12 @@ export type TailwindOutput = {
export type ColorInterner = { export type ColorInterner = {
defs: Map<string, string>; // minted token name → literal value defs: Map<string, string>; // minted token name → literal value
byValue: Map<string, string>; // literal value → minted token name byValue: Map<string, string>; // literal value → minted token name
byKey: Map<string, string>; // rounded-sRGB cluster key → minted token name (visual dedup)
tokens: Set<string>; // ALL referenced color token names (palette + minted) tokens: Set<string>; // ALL referenced color token names (palette + minted)
seq: { n: number }; // monotonic counter for clr-N names seq: { n: number }; // monotonic counter for clr-N names
}; };
export function createColorInterner(): ColorInterner { export function createColorInterner(): ColorInterner {
return { defs: new Map(), byValue: new Map(), tokens: new Set(), seq: { n: 0 } }; return { defs: new Map(), byValue: new Map(), byKey: new Map(), tokens: new Set(), seq: { n: 0 } };
} }
/** `:root { --clr-N: <literal>; … }` for the interner's minted tokens (empty if none). */ /** `:root { --clr-N: <literal>; … }` for the interner's minted tokens (empty if none). */
export function colorDefsCssOf(it: ColorInterner): string { export function colorDefsCssOf(it: ColorInterner): string {
@@ -1053,11 +1288,11 @@ function interactionUtilities(
return { byCid, groups }; return { byCid, groups };
} }
export function buildTailwind(ir: IR, assetMap: Map<string, string>, colorVar?: (v: string) => string | null, opts?: { interner?: ColorInterner; includeNode?: (id: string) => boolean; interaction?: InteractionCapture; reflow?: boolean }): TailwindOutput { export function buildTailwind(ir: IR, assetMap: Map<string, string>, colorVar?: (v: string) => string | null, opts?: { interner?: ColorInterner; includeNode?: (id: string) => boolean; interaction?: InteractionCapture; reflow?: boolean; lottieMounts?: ReadonlySet<string> }): TailwindOutput {
// Colors tokenized (var(--…)); typography/geometry kept RAW (text-[16px] reads cleaner // Colors tokenized (var(--…)); typography/geometry kept RAW (text-[16px] reads cleaner
// than a token ref). The full tokenResolver is deliberately NOT passed — only colors are // than a token ref). The full tokenResolver is deliberately NOT passed — only colors are
// tokenized below, so spacing/type stay as readable arbitrary values. // tokenized below, so spacing/type stay as readable arbitrary values.
const rules = collectNodeRules(ir, assetMap, opts?.includeNode, colorVar, undefined, opts?.reflow); const rules = collectNodeRules(ir, assetMap, opts?.includeNode, colorVar, undefined, opts?.reflow, opts?.lottieMounts);
const classOf = new Map<string, string>(); const classOf = new Map<string, string>();
const styleOf = new Map<string, Map<string, string>>(); // cid → inline style (base-only gradients/url) const styleOf = new Map<string, Map<string, string>>(); // cid → inline style (base-only gradients/url)
const extraParts: string[] = []; // pseudo rules + url()-bearing decls, keyed by [data-cid] const extraParts: string[] = []; // pseudo rules + url()-bearing decls, keyed by [data-cid]
@@ -1075,12 +1310,31 @@ export function buildTailwind(ir: IR, assetMap: Map<string, string>, colorVar?:
indexNode(ir.root); indexNode(ir.root);
// Color interner: every distinct color value → a stable theme token referenced as // Color interner: every distinct color value → a stable theme token referenced as
// var(--…). Palette colors already arrive as var(--color-*) (kept, semantic); any other // var(--…). A color the semantic palette recognizes (exactly OR within the grader's ±2
// color is minted a numbered token (--clr-N) so raw rgb/hex NEVER lands in markup. // sRGB tolerance — the same tolerance css.ts already trusts for color/bg/border) reuses
// The token holds the literal value, minted in deterministic first-encounter order. // that SEMANTIC name (--primary, --surface, --color-001…), so oklab/lch colours reached
// only through decoration/gradient/shadow props no longer each mint a fresh opaque
// --clr-N. Only colours with NO palette role fall through to a numbered --clr-N token,
// minted in deterministic first-encounter order (the literal is kept, byte-exact).
const internColor = (literal: string): string => { const internColor = (literal: string): string => {
const semantic = colorVar?.(literal);
if (semantic) { const n = tokenName(semantic); if (n) colorTokens.add(n); return semantic; }
let name = interner.byValue.get(literal); let name = interner.byValue.get(literal);
if (!name) { name = `clr-${interner.seq.n++}`; interner.byValue.set(literal, name); interner.defs.set(name, literal); } if (!name) {
// Dedup visually-identical literals (many oklab()/lch() forms round to the same sRGB):
// reuse the token minted for that colour rather than a fresh --clr-N. Fidelity-neutral —
// the grader compares in sRGB and the shared token holds the FIRST literal (within ±0 of
// its own colour). Values that don't parse fall back to exact-literal keying.
const key = colorClusterKey(literal);
const existing = key ? interner.byKey.get(key) : undefined;
if (existing) { name = existing; interner.byValue.set(literal, name); }
else {
name = `clr-${interner.seq.n++}`;
interner.byValue.set(literal, name);
interner.defs.set(name, literal);
if (key) interner.byKey.set(key, name);
}
}
colorTokens.add(name); colorTokens.add(name);
return `var(--${name})`; return `var(--${name})`;
}; };
@@ -1131,7 +1385,11 @@ export function buildTailwind(ir: IR, assetMap: Map<string, string>, colorVar?:
// one-off — emit it as an inline `style={{…}}` (exact, no Tailwind-escape mangling) so the node // one-off — emit it as an inline `style={{…}}` (exact, no Tailwind-escape mangling) so the node
// needs no `[data-cid]` ditto.css rule and the shipped data-cid is stripped. If the prop IS // needs no `[data-cid]` ditto.css rule and the shipped data-cid is stripped. If the prop IS
// banded, it must stay in ditto.css: an inline style would out-specify the @media override. // banded, it must stay in ditto.css: an inline style would out-specify the @media override.
const bandedRawProps = new Set<string>(bandRaws.flatMap((b) => [...b.raw.keys()])); // Count EVERY band-touched prop (raw or not), not just the raw ones: a band that RESETS the prop
// to a non-raw value (e.g. `max-lg:bg-[none]` turning a gradient off on mobile) becomes a utility
// rather than a raw decl, so a raw-only set misses it — and inlining the base gradient then
// out-specifies the `@media` reset, painting the gradient where the source turned it off.
const bandedRawProps = new Set<string>(nr.bands.flatMap((b) => [...b.decls.keys()]));
const inlineStyle = new Map<string, string>(); const inlineStyle = new Map<string, string>();
for (const [p, v] of [...baseRaw]) { for (const [p, v] of [...baseRaw]) {
if (!bandedRawProps.has(p)) { inlineStyle.set(p, tokenizeColors(p, v)); baseRaw.delete(p); } if (!bandedRawProps.has(p)) { inlineStyle.set(p, tokenizeColors(p, v)); baseRaw.delete(p); }
@@ -1222,7 +1480,7 @@ export function buildTailwind(ir: IR, assetMap: Map<string, string>, colorVar?:
* + breakpoint bindings, our token :root, and our reset/fonts/page-base inside @layer base * + breakpoint bindings, our token :root, and our reset/fonts/page-base inside @layer base
* so utilities override them. */ * so utilities override them. */
export function tailwindGlobalsCss(opts: { export function tailwindGlobalsCss(opts: {
reset: string; fontCss: string; tokensCss: string; htmlBg: string; bodyFont: string; reset: string; fontCss: string; tokensCss: string; htmlBg: string | null; bodyFont: string;
clip: string; colorTokens: string[]; viewports: number[]; canonical: number; clip: string; colorTokens: string[]; viewports: number[]; canonical: number;
}): string { }): string {
const screens = [ const screens = [
@@ -1250,8 +1508,7 @@ ${opts.tokensCss}
${opts.reset} ${opts.reset}
/* fonts */ /* fonts */
${opts.fontCss} ${opts.fontCss}
html { background: ${opts.htmlBg}; } ${opts.htmlBg !== null ? `html { background: ${opts.htmlBg}; }\n` : ""}body { font-family: ${opts.bodyFont}; }${opts.clip}
body { font-family: ${opts.bodyFont}; }${opts.clip}
} }
`; `;
} }
+47 -15
View File
@@ -61,19 +61,14 @@ export function buildFontGraph(fontFaces: FontFace[], assetGraph: AssetGraph, so
} }
} }
const entries: FontEntry[] = []; // Resolve one face's src descriptor against the downloaded-asset graph. A face harvested from
const cssBlocks: string[] = []; // CSSOM carries the url of its owning sheet in `baseHref`; its relative src url()s must resolve
const seen = new Set<string>(); // against THAT, not the document, or `../media/x` clamps to the wrong path (commonly the SPA
// router's HTML shell). Faces parsed out-of-band already have absolute srcs baked in, so
for (const ff of fontFaces) { // `baseHref` is absent and the document url is a harmless fallback.
const weight = (ff.weight || "400").trim(); const order = ["woff2", "woff", "truetype", "opentype", "embedded-opentype"];
const style = (ff.style || "normal").trim(); const resolveFace = (ff: FontFace): { resolved: Array<{ localPath: string; format: string }>; dataUris: string[] } => {
const display = (ff.display || "swap").trim(); const srcs = parseSrcUrls(ff.src, ff.baseHref || sourceUrl);
const key = `${ff.family}|${weight}|${style}|${ff.unicodeRange ?? ""}`;
if (seen.has(key)) continue;
seen.add(key);
const srcs = parseSrcUrls(ff.src, sourceUrl);
const resolved: Array<{ localPath: string; format: string }> = []; const resolved: Array<{ localPath: string; format: string }> = [];
const dataUris: string[] = []; const dataUris: string[] = [];
for (const s of srcs) { for (const s of srcs) {
@@ -84,10 +79,47 @@ export function buildFontGraph(fontFaces: FontFace[], assetGraph: AssetGraph, so
resolved.push({ localPath: entry.localPath, format: s.format || FORMAT_BY_EXT[ext] || "woff2" }); resolved.push({ localPath: entry.localPath, format: s.format || FORMAT_BY_EXT[ext] || "woff2" });
} }
} }
// Prefer woff2 > woff > others for ordering. // Prefer woff2 > woff > others for ordering.
const order = ["woff2", "woff", "truetype", "opentype", "embedded-opentype"];
resolved.sort((a, b) => order.indexOf(a.format) - order.indexOf(b.format)); resolved.sort((a, b) => order.indexOf(a.format) - order.indexOf(b.format));
return { resolved, dataUris };
};
// Deduplicate faces by family|weight|style|unicodeRange. Two sources can supply the same face
// with DIFFERENT (correctly- vs wrongly-resolved) src urls — the CSSOM harvest and the css-text
// parse both land in `fontFaces`. Keeping the first-inserted face lets a src that resolves to
// nothing (a rejected impostor, or a mis-based path) win the slot, so choose validity-aware:
// the first face whose src resolves to a downloaded/data-uri source wins; only if NONE in the
// group resolves do we fall back to the first-seen face (recorded as unavailable). Ties (more
// than one resolving) keep insertion order — determinism preserved.
const chosen = new Map<string, { ff: FontFace; res: ReturnType<typeof resolveFace> }>();
const orderKeys: string[] = [];
for (const ff of fontFaces) {
const weight = (ff.weight || "400").trim();
const style = (ff.style || "normal").trim();
const key = `${ff.family}|${weight}|${style}|${ff.unicodeRange ?? ""}`;
const res = resolveFace(ff);
const resolves = res.resolved.length > 0 || res.dataUris.length > 0;
const prev = chosen.get(key);
if (!prev) {
chosen.set(key, { ff, res });
orderKeys.push(key);
} else if (resolves && !(prev.res.resolved.length > 0 || prev.res.dataUris.length > 0)) {
// Upgrade: the incumbent resolved to nothing, this candidate resolves — replace it in place
// (keeping its original emission position).
chosen.set(key, { ff, res });
}
// Otherwise keep the incumbent (first-resolving wins; ties hold insertion order).
}
const entries: FontEntry[] = [];
const cssBlocks: string[] = [];
for (const key of orderKeys) {
const { ff, res } = chosen.get(key)!;
const weight = (ff.weight || "400").trim();
const style = (ff.style || "normal").trim();
const display = (ff.display || "swap").trim();
const { resolved, dataUris } = res;
if (resolved.length > 0 || dataUris.length > 0) { if (resolved.length > 0 || dataUris.length > 0) {
const srcParts: string[] = []; const srcParts: string[] = [];
+150 -83
View File
@@ -4,10 +4,19 @@ import { round } from "../util/canonical.js";
/** /**
* Deterministic section detection. Splits the page into visually coherent * Deterministic section detection. Splits the page into visually coherent
* top-level blocks using semantic tags and geometry (we do not rely on class * top-level bands using semantic tags and geometry (we do not rely on class
* names those are intentionally dropped from the IR). Sections are metadata: * names those are intentionally dropped from the IR).
* stable ids + per-viewport bboxes for the layout/section gate. Role guesses are *
* advisory and never affect fidelity. * The core is a recursive descent: a container that covers most of the page
* (<body> <div id=root> <main>) is a WRAPPER, not a band descend into it
* and re-collect among its children, repeating until every candidate is
* band-like. A descent only replaces the container when its children actually
* tile it (stacked full-width blocks with near-complete coverage), so a page
* that truly is one tall band legally stays a single section.
*
* Sections are metadata: stable ids + per-viewport bboxes for the layout/section
* gate, and the shared root set for section-per-file emission (sectionSplit).
* Role guesses are advisory and never affect fidelity.
*/ */
export type Section = { export type Section = {
@@ -19,104 +28,162 @@ export type Section = {
}; };
const SEMANTIC = new Set(["header", "nav", "main", "section", "article", "footer", "aside"]); const SEMANTIC = new Set(["header", "nav", "main", "section", "article", "footer", "aside"]);
const MIN_BAND_H = 64;
const MIN_SEMANTIC_H = 24; // a 62px navbar is still the navbar
const MIN_BAND_W_FRAC = 0.55; // a band spans most of the viewport
const WRAPPER_FRAC = 0.5; // taller than this fraction of the page → wrapper, try descending
const MIN_CHILD_COVERAGE = 0.8; // children must tile the container to replace it
const MAX_STACK_OVERLAP = 0.3; // consecutive bands may overlap at most this much of the smaller
const MAX_BANDS_PER_SPLIT = 20; // a split into more pieces than this is fragmentation, not bands
const MAX_DEPTH = 12;
type Cand = { node: IRNode; path: string; y: number; width: number; height: number }; type Cand = { node: IRNode; y: number; width: number; height: number };
type Ctx = { cw: number; pageH: number };
/** The ordered section-root nodes of the page (top to bottom). Shared by the
* validator (detectSections) and the generator's section splitter, so the gate's
* section list and the emitted section components describe the same bands.
* Falls back to `[ir.root]` when the page has no decomposable structure. */
export function detectSectionNodes(ir: IR): IRNode[] {
const cw = ir.doc.canonicalViewport;
const ctx: Ctx = { cw, pageH: ir.doc.perViewport[cw]?.scrollHeight ?? 0 };
const bands = bandsOf(ir.root, 0, ctx);
// Stable sort by top edge; ties keep document order (a fixed nav stays before
// the hero it overlays).
bands.sort((a, b) => a.y - b.y);
// Nested wrappers can repeat the same box from disjoint subtrees — keep the first.
const final: Cand[] = [];
for (const c of bands) {
if (!final.some((f) => Math.abs(f.y - c.y) < 4 && Math.abs(f.height - c.height) < 4)) final.push(c);
}
if (final.length === 0) return [ir.root];
return final.map((c) => c.node);
}
export function detectSections(ir: IR): Section[] { export function detectSections(ir: IR): Section[] {
const cw = ir.doc.canonicalViewport; const cw = ir.doc.canonicalViewport;
const vpWidth = cw; const pageH = ir.doc.perViewport[cw]?.scrollHeight ?? 0;
const nodes = detectSectionNodes(ir);
const candidates: Cand[] = []; const roles = guessRoles(nodes, cw, pageH);
const walk = (node: IRNode, path: string): void => { return nodes.map((node, i) => ({
const bbox = node.bboxByVp[cw];
const visible = node.visibleByVp[cw];
if (bbox && visible) {
const isSemantic = SEMANTIC.has(node.tag);
const isBigBlock = bbox.width >= vpWidth * 0.55 && bbox.height >= 64;
const display = node.computedByVp[cw]?.display ?? "";
const blockish = !/^(inline|inline-block|none)$/.test(display);
if ((isSemantic || isBigBlock) && blockish) {
candidates.push({ node, path, y: bbox.y, width: bbox.width, height: bbox.height });
}
}
for (const c of node.children) {
if (isTextChild(c)) continue;
walk(c, `${path}/${c.id}`);
}
};
walk(ir.root, ir.root.id);
// Outermost wins: drop candidates nested inside another candidate.
const byShallow = candidates.slice().sort((a, b) => a.path.split("/").length - b.path.split("/").length);
let kept: Cand[] = [];
for (const c of byShallow) {
if (kept.some((k) => c.path.startsWith(k.path + "/"))) continue;
kept.push(c);
}
// If the only survivor is a giant wrapper, expand into its section-like children.
const pageHeight = ir.doc.perViewport[cw]?.scrollHeight ?? 0;
kept = expandOversized(kept, vpWidth, pageHeight, cw);
// Sort by Y, then assign ids/roles.
kept.sort((a, b) => a.y - b.y || b.height - a.height);
// Deduplicate near-identical y/height wrappers (keep the first/shallowest).
const final: Cand[] = [];
for (const c of kept) {
const dup = final.find((f) => Math.abs(f.y - c.y) < 4 && Math.abs(f.height - c.height) < 4);
if (!dup) final.push(c);
}
return final.map((c, i) => ({
id: `section-${String(i + 1).padStart(3, "0")}`, id: `section-${String(i + 1).padStart(3, "0")}`,
nodeId: c.node.id, nodeId: node.id,
role: guessRole(c.node, i, final.length), role: roles[i]!,
order: i, order: i,
bboxByVp: bboxesFor(c.node), bboxByVp: bboxesFor(node),
})); }));
} }
function expandOversized(cands: Cand[], vpWidth: number, pageHeight: number, cw: number): Cand[] { /** Landmark evidence that a bar is site chrome: an ARIA landmark role on the node
if (cands.length > 2) return cands; * itself, or a <nav> within a shallow wrapper chain. Lets a thin fixed bar (often a
const threshold = Math.max(pageHeight * 0.55, 1200); * 62px styled <div> around the real <nav>) clear the semantic height bar. */
export function navEvidence(node: IRNode): boolean {
const role = node.attrs.role ?? "";
if (role === "banner" || role === "navigation") return true;
return subtreeHas(node, (n) => n.tag === "nav" || n.attrs.role === "navigation", 3);
}
function candOf(node: IRNode, ctx: Ctx): Cand | null {
const bbox = node.bboxByVp[ctx.cw];
if (!bbox || !node.visibleByVp[ctx.cw]) return null;
const display = node.computedByVp[ctx.cw]?.display ?? "";
if (/^(inline|inline-block|none)$/.test(display)) return null;
if (bbox.width < ctx.cw * MIN_BAND_W_FRAC) return null;
const minH = SEMANTIC.has(node.tag) || navEvidence(node) ? MIN_SEMANTIC_H : MIN_BAND_H;
if (bbox.height < minH) return null;
return { node, y: bbox.y, width: bbox.width, height: bbox.height };
}
/** Collect the band candidates among `container`'s children. A band-sized child is
* kept; a page-covering child is descended into (its children replace it only when
* they tile it see acceptSplit); anything else is a transparent wrapper we look
* through. Output is in document order. */
function bandsOf(container: IRNode, depth: number, ctx: Ctx): Cand[] {
if (depth > MAX_DEPTH) return [];
const out: Cand[] = []; const out: Cand[] = [];
for (const c of cands) { for (const child of container.children) {
if (c.height < threshold) { out.push(c); continue; } if (isTextChild(child)) continue;
const inner: Cand[] = []; const cand = candOf(child, ctx);
const collect = (node: IRNode, path: string, depth: number): void => { if (!cand) {
if (depth > 6) return; out.push(...bandsOf(child, depth + 1, ctx));
for (const ch of node.children) { continue;
if (isTextChild(ch)) continue; }
const bbox = ch.bboxByVp[cw]; if (ctx.pageH > 0 && cand.height > ctx.pageH * WRAPPER_FRAC) {
if (bbox && ch.visibleByVp[cw] && bbox.width >= vpWidth * 0.5 && bbox.height >= 64) { const inner = bandsOf(child, depth + 1, ctx);
inner.push({ node: ch, path: `${path}/${ch.id}`, y: bbox.y, width: bbox.width, height: bbox.height }); if (acceptSplit(inner, cand)) {
} else { out.push(...inner);
collect(ch, `${path}/${ch.id}`, depth + 1); continue;
} }
} }
}; out.push(cand);
collect(c.node, c.path, 0);
// outermost wins within inner
const byShallow = inner.slice().sort((a, b) => a.path.split("/").length - b.path.split("/").length);
const keptInner: Cand[] = [];
for (const ic of byShallow) {
if (keptInner.some((k) => ic.path.startsWith(k.path + "/"))) continue;
keptInner.push(ic);
}
if (keptInner.length >= 3) out.push(...keptInner);
else out.push(c);
} }
return out; return out;
} }
function guessRole(node: IRNode, index: number, total: number): string { /** May `inner` replace its containing candidate? Only when it reads as a stack of
if (node.tag === "header") return "header"; * bands: at least two, not a fragmentation explosion, vertically stacked (side-by-side
if (node.tag === "nav") return "nav"; * columns or overlaid layers must not be split), and tiling the container with
* near-complete coverage so no substantial content is silently dropped. */
function acceptSplit(inner: Cand[], parent: Cand): boolean {
if (inner.length < 2 || inner.length > MAX_BANDS_PER_SPLIT) return false;
const sorted = inner.slice().sort((a, b) => a.y - b.y);
for (let i = 1; i < sorted.length; i++) {
const prev = sorted[i - 1]!, cur = sorted[i]!;
const overlap = prev.y + prev.height - cur.y;
if (overlap > Math.min(prev.height, cur.height) * MAX_STACK_OVERLAP) return false;
}
let covered = 0, cursor = -Infinity;
for (const c of sorted) {
const top = Math.max(c.y, cursor), bot = c.y + c.height;
if (bot > top) covered += bot - top;
cursor = Math.max(cursor, bot);
}
return covered >= parent.height * MIN_CHILD_COVERAGE;
}
function subtreeHas(node: IRNode, pred: (n: IRNode) => boolean, depth = 6): boolean {
if (depth < 0) return false;
for (const c of node.children) {
if (isTextChild(c)) continue;
if (pred(c) || subtreeHas(c, pred, depth - 1)) return true;
}
return false;
}
/** A <header> that carries the page's h1 and real height is the hero band, not
* site chrome "header → chrome" only holds for the thin bar variant. */
export function heroLikeHeader(node: IRNode, cw: number): boolean {
const h = node.bboxByVp[cw]?.height ?? 0;
return h >= 200 && subtreeHas(node, (n) => n.tag === "h1");
}
/** Advisory roles, one pass top-to-bottom: tag evidence first, then the first
* near-top content band claims "hero" (once), then content evidence. */
function guessRoles(nodes: IRNode[], cw: number, pageH: number): string[] {
let heroClaimed = false;
return nodes.map((node, index) => {
if (node.tag === "nav") return "navbar";
if (node.tag === "header") {
if (!heroLikeHeader(node, cw)) return "header";
heroClaimed = true;
return "hero";
}
if (node.tag === "footer") return "footer"; if (node.tag === "footer") return "footer";
if (node.tag === "aside") return "aside";
if (node.tag === "main") return "main"; if (node.tag === "main") return "main";
if (index === 0) return "hero"; const bbox = node.bboxByVp[cw];
if (index === total - 1) return "footer"; const h = bbox?.height ?? 0, y = bbox?.y ?? 0;
// thin top bar without the <nav> tag (first band, or a fixed bar with landmark evidence)
if (h <= 140 && y <= 160 && (index === 0 || navEvidence(node))) return "navbar";
if (!heroClaimed && (bbox?.y ?? 0) <= Math.max(900, pageH * 0.25)) {
heroClaimed = true;
return "hero";
}
if (index === nodes.length - 1) return "footer";
if (subtreeHas(node, (n) => n.tag === "form")) return "contact";
if (subtreeHas(node, (n) => n.tag === "video" || n.tag === "iframe")) return "media";
return "section"; return "section";
});
} }
function bboxesFor(node: IRNode): Section["bboxByVp"] { function bboxesFor(node: IRNode): Section["bboxByVp"] {
+151 -22
View File
@@ -27,12 +27,117 @@ export type ColorPalette = {
const TRANSPARENT = new Set(["rgba(0, 0, 0, 0)", "transparent", "rgba(0,0,0,0)", ""]); const TRANSPARENT = new Set(["rgba(0, 0, 0, 0)", "transparent", "rgba(0,0,0,0)", ""]);
type RGBA = [number, number, number, number]; type RGBA = [number, number, number, number];
/** Parse the numeric args of a `fn(a b c / d)` / `fn(a,b,c,d)` color into [a,b,c,alpha].
* Percentages resolve against `pctBase` (255 for rgb channels, 1 for lab/lch/oklab/oklch
* L and the c/ab axes where the caller passes their own scale). `none` 0. */
function parseColorArgs(inner: string): { nums: number[]; alpha: number } | null {
const parts = inner.split("/");
const main = parts[0]!.trim().split(/[\s,]+/).filter(Boolean);
const nums = main.map((s) => (s === "none" ? 0 : parseFloat(s)));
if (nums.length < 3 || nums.slice(0, 3).some(Number.isNaN)) return null;
let alpha = 1;
const alphaTok = parts.length > 1 ? parts[1]!.trim() : main[3];
if (alphaTok !== undefined && alphaTok !== "none") {
const a = alphaTok.endsWith("%") ? parseFloat(alphaTok) / 100 : parseFloat(alphaTok);
if (!Number.isNaN(a)) alpha = a;
}
return { nums, alpha };
}
function pctOr(tok: string, base: number): number {
return tok.endsWith("%") ? (parseFloat(tok) / 100) * base : parseFloat(tok);
}
/** Linear-light sRGB channel → gamma-encoded 0255. */
function lin2srgb(c: number): number {
const v = c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
return Math.round(Math.min(1, Math.max(0, v)) * 255);
}
/** OKLab → sRGB (CSS Color 4 reference matrices). Returns gamma-encoded 0255. */
function oklabToRgb(L: number, a: number, b: number): [number, number, number] {
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
const l = l_ ** 3, m = m_ ** 3, s = s_ ** 3;
const r = +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s;
const g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s;
const bl = -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s;
return [lin2srgb(r), lin2srgb(g), lin2srgb(bl)];
}
/** CIE Lab (D50) → sRGB, gamma-encoded 0255. */
function labToRgb(L: number, a: number, b: number): [number, number, number] {
const fy = (L + 16) / 116, fx = fy + a / 500, fz = fy - b / 200;
const d = 6 / 29;
const inv = (t: number): number => (t > d ? t ** 3 : 3 * d * d * (t - 4 / 29));
// D50 white point.
const X = 0.9642956 * inv(fx), Y = 1.0 * inv(fy), Z = 0.8251046 * inv(fz);
// XYZ(D50) → linear sRGB (Bradford-adapted D50→D65 folded in).
const r = +3.1341359 * X - 1.6172206 * Y - 0.4906860 * Z;
const g = -0.9787684 * X + 1.9161415 * Y + 0.0334540 * Z;
const bl = +0.0719453 * X - 0.2289914 * Y + 1.4052427 * Z;
return [lin2srgb(r), lin2srgb(g), lin2srgb(bl)];
}
/** Parse any CSS color value (`rgb`/`rgba`/`hsl`/`hsla`/`oklab`/`oklch`/`lab`/`lch`/hex/
* named-ish) into rounded sRGB `[r,g,b,alpha]`, else null. The literal value is kept
* verbatim in emitted CSS; this RGB is used ONLY for clustering (grader works in sRGB
* ±2), so oklab/lch colours cluster + earn semantic roles instead of falling to --clr-N. */
function parseColor(v: string): RGBA | null {
const s = v.trim().toLowerCase();
if (s === "white") return [255, 255, 255, 1];
if (s === "black") return [0, 0, 0, 1];
const hex = /^#([0-9a-f]{3,8})$/i.exec(s);
if (hex) {
let h = hex[1]!;
if (h.length === 3 || h.length === 4) h = h.split("").map((c) => c + c).join("");
const r = parseInt(h.slice(0, 2), 16), g = parseInt(h.slice(2, 4), 16), b = parseInt(h.slice(4, 6), 16);
const a = h.length >= 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1;
return [r, g, b, a];
}
const fn = /^(rgba?|hsla?|oklab|oklch|lab|lch)\(([^)]*)\)$/i.exec(s);
if (!fn) return null;
const kind = fn[1]!, parsed = parseColorArgs(fn[2]!);
if (!parsed) return null;
const { nums, alpha } = parsed;
const raw = fn[2]!.split("/")[0]!.trim().split(/[\s,]+/).filter(Boolean);
if (kind.startsWith("rgb")) {
return [Math.round(pctOr(raw[0]!, 255)), Math.round(pctOr(raw[1]!, 255)), Math.round(pctOr(raw[2]!, 255)), alpha];
}
if (kind.startsWith("hsl")) {
const H = ((nums[0]! % 360) + 360) % 360, S = pctOr(raw[1]!, 1), Lh = pctOr(raw[2]!, 1);
const c = (1 - Math.abs(2 * Lh - 1)) * S, x = c * (1 - Math.abs(((H / 60) % 2) - 1)), m = Lh - c / 2;
const [r1, g1, b1] = H < 60 ? [c, x, 0] : H < 120 ? [x, c, 0] : H < 180 ? [0, c, x] : H < 240 ? [0, x, c] : H < 300 ? [x, 0, c] : [c, 0, x];
return [Math.round((r1 + m) * 255), Math.round((g1 + m) * 255), Math.round((b1 + m) * 255), alpha];
}
if (kind === "oklab") { const [r, g, b] = oklabToRgb(pctOr(raw[0]!, 1), nums[1]!, nums[2]!); return [r, g, b, alpha]; }
if (kind === "oklch") {
const L = pctOr(raw[0]!, 1), C = nums[1]!, h = (nums[2]! * Math.PI) / 180;
const [r, g, b] = oklabToRgb(L, C * Math.cos(h), C * Math.sin(h)); return [r, g, b, alpha];
}
if (kind === "lab") { const [r, g, b] = labToRgb(pctOr(raw[0]!, 100), nums[1]!, nums[2]!); return [r, g, b, alpha]; }
if (kind === "lch") {
const L = pctOr(raw[0]!, 100), C = nums[1]!, h = (nums[2]! * Math.PI) / 180;
const [r, g, b] = labToRgb(L, C * Math.cos(h), C * Math.sin(h)); return [r, g, b, alpha];
}
return null;
}
/** Back-compat alias: the palette parses every colour space now, not just rgb(). */
function parseRgb(v: string): RGBA | null { function parseRgb(v: string): RGBA | null {
const m = v.match(/rgba?\(([^)]+)\)/i); return parseColor(v);
if (!m) return null; }
const p = m[1]!.split(/[,\/]/).map((s) => parseFloat(s.trim()));
if (p.length < 3 || p.slice(0, 3).some(Number.isNaN)) return null; /** A stable clustering key for a color literal: its rounded sRGB (+ alpha), so two literals
return [Math.round(p[0]!), Math.round(p[1]!), Math.round(p[2]!), p.length >= 4 && !Number.isNaN(p[3]!) ? p[3]! : 1]; * that render the same colour (`oklab(0.988242 …)` and `oklab(0.988371 …)` rgb(251,251,251))
* collapse to ONE minted token instead of a wall of near-identical --clr-N. Null when the
* value can't be parsed (keeps the raw literal as its own key). Alpha bucketed to ±0.02. */
export function colorClusterKey(v: string): string | null {
const c = parseColor(v);
if (!c) return null;
return `${c[0]},${c[1]},${c[2]},${Math.round(c[3] * 50)}`;
} }
function within2(a: RGBA, b: RGBA): boolean { function within2(a: RGBA, b: RGBA): boolean {
return Math.abs(a[0] - b[0]) <= 2 && Math.abs(a[1] - b[1]) <= 2 && Math.abs(a[2] - b[2]) <= 2 && Math.abs(a[3] - b[3]) <= 0.04; return Math.abs(a[0] - b[0]) <= 2 && Math.abs(a[1] - b[1]) <= 2 && Math.abs(a[2] - b[2]) <= 2 && Math.abs(a[3] - b[3]) <= 0.04;
@@ -140,33 +245,57 @@ function paletteFrom(usage: Map<string, Usage>, roleIr: IR, minCount: number): C
const fgCluster = findCluster(pv?.bodyColor); const fgCluster = findCluster(pv?.bodyColor);
if (fgCluster) assign("--foreground", fgCluster); if (fgCluster) assign("--foreground", fgCluster);
// 2) Primary/accent: most-used chromatic color, weighted toward interactive usage. // Luminance (01) for deterministic tiebreaks (Rec.709). Ties in every ranking below
const remaining = clusters.filter((c) => !c.members.some((m) => valueToName.has(m))); // break by count, then luminance (dark→light), then the literal value — so the palette
// is byte-stable for a given capture (determinism gate 6).
const lum = (c: Usage): number => (0.2126 * c.rgba[0] + 0.7152 * c.rgba[1] + 0.0722 * c.rgba[2]) / 255;
const unnamed = (c: { canon: Usage; members: string[] }): boolean => !c.members.some((m) => valueToName.has(m));
const byCount = (score: (c: Usage) => number) => (a: { canon: Usage }, b: { canon: Usage }): number =>
score(b.canon) - score(a.canon) || b.canon.count - a.canon.count || lum(a.canon) - lum(b.canon) || a.canon.value.localeCompare(b.canon.value);
// 2) Brand + accent: the most-used chromatic colours, weighted toward interactive usage
// (buttons/links). `--primary` (a.k.a. brand) is the strongest; `--accent` the next.
// A colour is "chromatic" only with real hue: HSL saturation ≥ 0.25 AND an absolute
// channel spread ≥ 24, so near-neutral off-whites/creams (maxmin ≈ 10) that merely
// tip over the ratio threshold stay neutrals → --surface, not a fake --accent.
const chroma = (c: RGBA): number => Math.max(c[0], c[1], c[2]) - Math.min(c[0], c[1], c[2]);
const remaining = clusters.filter(unnamed);
const chromatic = remaining const chromatic = remaining
.filter((c) => { const { s } = satLight(c.canon.rgba); return s >= 0.25; }) .filter((c) => satLight(c.canon.rgba).s >= 0.25 && chroma(c.canon.rgba) >= 24)
.sort((a, b) => (b.canon.interactive * 3 + b.canon.count) - (a.canon.interactive * 3 + a.canon.count)); .sort(byCount((c) => c.interactive * 3 + c.count));
if (chromatic[0]) assign("--primary", chromatic[0]); if (chromatic[0]) assign("--primary", chromatic[0]);
if (chromatic[1]) assign("--accent", chromatic[1]); if (chromatic[1]) assign("--accent", chromatic[1]);
// 3) Border: most-used color that appears predominantly as a border. // 3) Border: most-used colour that appears predominantly as a border.
const borderC = remaining const borderC = remaining
.filter((c) => !c.members.some((m) => valueToName.has(m)) && c.canon.border > 0) .filter((c) => unnamed(c) && c.canon.border > 0)
.sort((a, b) => b.canon.border - a.canon.border)[0]; .sort(byCount((c) => c.border))[0];
if (borderC && borderC.canon.border >= Math.max(2, minCount - 1)) assign("--border", borderC); if (borderC && borderC.canon.border >= Math.max(2, minCount - 1)) assign("--border", borderC);
// 4) Neutrals: a light neutral used as a background → surface; a mid neutral text → muted. // 4) Neutrals, ranked so the most-used earns the cleanest name. Light neutrals used as
for (const c of remaining) { // backgrounds → --surface, --surface-2, … (section/card/footer alt backgrounds);
if (c.members.some((m) => valueToName.has(m))) continue; // mid/dark neutrals used as text → --muted-foreground then --muted. "Neutral" is the
if (c.canon.count < minCount) continue; // complement of "chromatic" above (no strong hue: low saturation OR tiny channel spread),
const { s, l } = satLight(c.canon.rgba); // so no colour falls through the gap between the two thresholds.
if (s < 0.15 && c.canon.bg >= c.canon.text && l > 0.85 && !taken.has("--surface")) assign("--surface", c); const neutrals = remaining
else if (s < 0.2 && c.canon.text >= c.canon.bg && l > 0.35 && l < 0.7 && !taken.has("--muted-foreground")) assign("--muted-foreground", c); .filter((c) => unnamed(c) && c.canon.count >= minCount && (satLight(c.canon.rgba).s < 0.25 || chroma(c.canon.rgba) < 24))
.sort(byCount((c) => c.count));
let surfaceN = 0;
for (const c of neutrals) {
if (!unnamed(c)) continue;
const { l } = satLight(c.canon.rgba);
if (c.canon.bg >= c.canon.text && l > 0.85) {
assign(surfaceN === 0 ? "--surface" : `--surface-${surfaceN + 1}`, c);
surfaceN++;
} else if (c.canon.text >= c.canon.bg && l > 0.2 && l < 0.7) {
assign(!taken.has("--muted-foreground") ? "--muted-foreground" : "--muted", c);
}
} }
// 5) Everything else used >= minCount → numbered. // 5) Everything else used >= minCount → numbered, in a deterministic order.
let ci = 1; let ci = 1;
for (const c of remaining) { for (const c of [...remaining].sort(byCount(() => 0))) {
if (c.members.some((m) => valueToName.has(m))) continue; if (!unnamed(c)) continue;
if (c.canon.count < minCount) continue; if (c.canon.count < minCount) continue;
assign(`--color-${String(ci++).padStart(3, "0")}`, c); assign(`--color-${String(ci++).padStart(3, "0")}`, c);
} }
+413 -4
View File
@@ -6,7 +6,10 @@ import type { InteractionCapture } from "../capture/interactions.js";
/** /**
* Normalized Render IR. Merges the per-viewport capture snapshots into a single * Normalized Render IR. Merges the per-viewport capture snapshots into a single
* tree whose structure comes from the canonical (1280) capture; each node carries * tree whose structure comes from the canonical (1280) capture; each node carries
* per-viewport computed styles, bounding boxes, and visibility. This is the single * per-viewport computed styles, bounding boxes, and visibility. Children that exist
* ONLY at non-canonical viewports are grafted in as siblings (visible only in their
* source bands); a container whose children are a wholly different set at some width
* is recorded as content drift instead (see childDriftVps). This is the single
* source of truth for section/token inference, generation, and validation. * source of truth for section/token inference, generation, and validation.
*/ */
@@ -25,6 +28,9 @@ export type IRNode = {
// element had no class. // element had no class.
srcClass?: string; srcClass?: string;
rawHTML?: string; // inline svg rawHTML?: string; // inline svg
// Computed paint of an inline <svg> root (fill/stroke/color), carried from capture. Lets codegen
// recover a paint that a raw `fill="none"` attribute hides but site CSS actually supplied.
svgPaint?: { fill: string; stroke: string; color: string };
visibleByVp: Record<number, boolean>; visibleByVp: Record<number, boolean>;
bboxByVp: Record<number, BBox>; bboxByVp: Record<number, BBox>;
computedByVp: Record<number, StyleMap>; computedByVp: Record<number, StyleMap>;
@@ -37,6 +43,14 @@ export type IRNode = {
// ::placeholder computed style (input/textarea with placeholder text) — emitted as a // ::placeholder computed style (input/textarea with placeholder text) — emitted as a
// `::placeholder` rule so form controls keep their authored placeholder color. // `::placeholder` rule so form controls keep their authored placeholder color.
placeholderByVp?: Record<number, StyleMap>; placeholderByVp?: Record<number, StyleMap>;
// Band viewports where this container's element children were a DIFFERENT SET than the
// canonical capture's (mutual whole-set mismatch — the source deterministically served
// different content at that width, e.g. a rotator picking other items per breakpoint).
// Policy: FAITHFUL-AT-CANONICAL — emission shows the canonical children there (it skips
// the display:none band it would otherwise emit for a child with no counterpart) instead
// of rendering an empty shell; the other set is NOT grafted (no duplication). Surfaced in
// the manifest via doc.contentDrift.
childDriftVps?: number[];
children: IRChild[]; children: IRChild[];
}; };
@@ -72,6 +86,11 @@ export type IRDoc = {
// re-emit the keyframes that the per-node `animation-name` declarations reference — // re-emit the keyframes that the per-node `animation-name` declarations reference —
// without these the animation properties are inert (the half-plumbed pre-Stage-5 gap). // without these the animation properties are inert (the half-plumbed pre-Stage-5 gap).
keyframes: string[]; keyframes: string[];
// Containers whose children diverged as a WHOLE SET at some band viewport(s) (see
// IRNode.childDriftVps). Recorded post-renumber so ids are final; carried into the
// generated manifest as a fidelity note ("the source served different content there").
// Omitted when no drift was detected.
contentDrift?: Array<{ id: string; tag: string; viewports: number[] }>;
}; };
export type IR = { export type IR = {
@@ -83,6 +102,33 @@ export function isTextChild(c: IRChild): c is IRTextNode {
return (c as IRTextNode).text !== undefined; return (c as IRTextNode).text !== undefined;
} }
/**
* In-flow content extent at a viewport: the largest border-box bottom (`bbox.y + bbox.height`)
* over every VISIBLE, IN-FLOW descendant. This is the true height the real page laid out to,
* independent of a scroll-lock that clips the root to one viewport (a popup vendor that sets
* `body{overflow:hidden;height:100vh}` collapses `document.scrollHeight` to the viewport, but
* the in-flow sections still carry their real coordinates). Out-of-flow (absolute/fixed) and
* floated boxes are excluded a fixed overlay/footer badge or a floated aside is not part of
* the document flow that determines page height. Pure + deterministic (reads only the IR).
*/
export function irContentExtent(root: IRNode, vp: number): number {
let maxBottom = 0;
const visit = (n: IRNode): void => {
for (const c of n.children) {
if (isTextChild(c)) continue;
const cs = c.computedByVp[vp];
const bb = c.bboxByVp[vp];
if (!cs || !bb || !c.visibleByVp[vp]) { visit(c); continue; }
const pos = cs.position || "static";
const inFlow = pos !== "absolute" && pos !== "fixed" && (cs.float || "none") === "none";
if (inFlow) maxBottom = Math.max(maxBottom, bb.y + bb.height);
visit(c);
}
};
visit(root);
return maxBottom;
}
/** /**
* Recover lazy-loaded CSS backgrounds dropped by uneven per-viewport capture. * Recover lazy-loaded CSS backgrounds dropped by uneven per-viewport capture.
* If an element has exactly one URL background at some sampled widths and * If an element has exactly one URL background at some sampled widths and
@@ -175,9 +221,24 @@ const NOISE_TAGS = new Set(["next-route-announcer"]);
// width where capture happened to read it open → an empty 400×700 white rectangle at 2xl). Drop // width where capture happened to read it open → an empty 400×700 white rectangle at 2xl). Drop
// the whole subtree so it never reaches generation. // the whole subtree so it never reaches generation.
const THIRD_PARTY_OVERLAY = /(?:^|[\s_-])(?:intercom|drift-|hubspot-messages|zendesk|zd-|onetrust|ot-sdk-|usercentrics|grecaptcha|crisp-client|tawk-|livechat|helpscout|beacon-container|cookiebot|cky-)/; const THIRD_PARTY_OVERLAY = /(?:^|[\s_-])(?:intercom|drift-|hubspot-messages|zendesk|zd-|onetrust|ot-sdk-|usercentrics|grecaptcha|crisp-client|tawk-|livechat|helpscout|beacon-container|cookiebot|cky-)/;
/**
* Email-capture / promo POPUP overlay containers (Attentive, Wunderkind/Bounce Exchange,
* Justuno, Privy, Omnisend, Sailthru, Wisepops, etc.). These vendors inject a full-viewport
* fixed overlay + backdrop that scroll-locks the page third-party chrome, not site content.
*
* CAUTION: these vendors ALSO ship inline, embedded signup forms that ARE real page content
* and are deliberately grafted (see iframeGraft tests). So this matches ONLY the OVERLAY
* CONTAINER markers each vendor uses for its popup (`attentive_overlay`, `bx-wrapper` /
* `wk-`, `justuno`-`container`, `privy-`popup, ) never a bare vendor name that an inline
* form embed would also carry. Overlay-container tokens only.
*/
export const POPUP_OVERLAY_CONTAINER =
/(?:^|[\s_-])(?:attentive_overlay|attentive_creative|attn-|bx-wrapper|bx-window|bounce-?exchange|wknd-|wunderkind|justuno_container|jw-overlay|privy-popup|privy-container|klaviyo-form-.*overlay|sailthru-overlay|wisepops-root|recart-popup|recart-modal)/;
function isThirdPartyOverlay(raw: RawNode): boolean { function isThirdPartyOverlay(raw: RawNode): boolean {
const idClass = `${raw.attrs?.id ?? ""} ${raw.attrs?.class ?? ""}`.toLowerCase(); const idClass = `${raw.attrs?.id ?? ""} ${raw.attrs?.class ?? ""}`.toLowerCase();
if (THIRD_PARTY_OVERLAY.test(idClass)) return true; if (THIRD_PARTY_OVERLAY.test(idClass)) return true;
if (POPUP_OVERLAY_CONTAINER.test(idClass)) return true;
// INT_MAX band: overlay libraries stack above everything. Real content should not reach here, // INT_MAX band: overlay libraries stack above everything. Real content should not reach here,
// so this cannot swallow site chrome under normal captures. // so this cannot swallow site chrome under normal captures.
const zi = parseInt(raw.computed?.zIndex ?? "", 10); const zi = parseInt(raw.computed?.zIndex ?? "", 10);
@@ -188,6 +249,121 @@ function elementChildren(n: RawNode): RawNode[] {
return n.children.filter((c) => (c as { text?: string }).text === undefined) as RawNode[]; return n.children.filter((c) => (c as { text?: string }).text === undefined) as RawNode[];
} }
/** True for a transform value that is visually the identity (no offset/rotation/scale):
* the `none` keyword or an identity matrix/matrix3d the browser reports as noise. */
export function isIdentityTransform(value: string | undefined): boolean {
if (!value || value === "none") return true;
const m = /^matrix\(([^)]*)\)$/.exec(value.trim());
if (m) {
const n = m[1]!.split(",").map((s) => parseFloat(s.trim()));
return n.length === 6 && n[0] === 1 && n[1] === 0 && n[2] === 0 && n[3] === 1 && n[4] === 0 && n[5] === 0;
}
const m3 = /^matrix3d\(([^)]*)\)$/.exec(value.trim());
if (m3) {
const n = m3[1]!.split(",").map((s) => parseFloat(s.trim()));
const id = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
return n.length === 16 && n.every((v, i) => v === id[i]);
}
return false;
}
/** Parse a CSS length to px (number), 0 for `auto`/empty/non-px. */
function pfLen(v: string | undefined): number {
if (!v) return 0;
const n = parseFloat(v);
return Number.isFinite(n) ? n : 0;
}
/** The horizontal translate (matrix `e`) of a computed transform, or null if the transform is not a
* pure-2D matrix/matrix3d with a definite tx (rotation/scale/skew or 3D perspective null: we only
* re-anchor plain horizontal track offsets). `matrix(a,b,c,d,e,f)` e; `matrix3d(...)` m41. */
function translateXOf(value: string | undefined): number | null {
if (!value || value === "none") return null;
const m = /^matrix\(([^)]*)\)$/.exec(value.trim());
if (m) {
const n = m[1]!.split(",").map((s) => parseFloat(s.trim()));
if (n.length !== 6 || n.some((x) => !Number.isFinite(x))) return null;
if (n[1] !== 0 || n[2] !== 0) return null; // has rotation/skew → not a plain slide
return n[4]!;
}
const m3 = /^matrix3d\(([^)]*)\)$/.exec(value.trim());
if (m3) {
const n = m3[1]!.split(",").map((s) => parseFloat(s.trim()));
if (n.length !== 16 || n.some((x) => !Number.isFinite(x))) return null;
// Require an otherwise-identity 3D matrix apart from the translate column (indices 12/13/14).
const idExceptTranslate = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, /*12*/ 0, /*13*/ 0, /*14*/ 0, 1];
for (let i = 0; i < 16; i++) { if (i === 12 || i === 13 || i === 14) continue; if (n[i] !== idExceptTranslate[i]) return null; }
return n[12]!;
}
return null;
}
/** Return `value` with its horizontal translate replaced by `tx` (keeps the rest of the matrix). */
function setTranslateX(value: string, tx: number): string {
const m = /^matrix\(([^)]*)\)$/.exec(value.trim());
if (m) {
const n = m[1]!.split(",").map((s) => s.trim());
n[4] = String(tx);
return `matrix(${n.join(", ")})`;
}
const m3 = /^matrix3d\(([^)]*)\)$/.exec(value.trim());
if (m3) {
const n = m3[1]!.split(",").map((s) => s.trim());
n[12] = String(tx);
return `matrix3d(${n.join(", ")})`;
}
return value;
}
/**
* Normalize per-viewport transform values so identity is represented uniformly as the literal
* `none`. Two problems this closes, both in the per-viewport delta emission downstream:
* 1. The browser reports "no transform" inconsistently `none` at some widths, an identity
* `matrix(1,0,0,1,0,0)` at others (composited-layer noise). The generator's default-skip
* only recognizes `none`, so an identity matrix at the base viewport is emitted as a real
* transform and then cascades across bands.
* 2. When ANY viewport carries a genuine (non-identity) transform, the identity value at the
* other viewports must stay observable canonicalizing it to `none` (not dropping it) lets
* the generator emit the explicit reset so the non-identity transform can't leak into a band
* where the source had none.
* Deterministic, in place; only touches the `transform` slot.
*/
export function canonicalizeTransforms(computedByVp: Record<number, StyleMap>): void {
for (const k of Object.keys(computedByVp)) {
const cs = computedByVp[Number(k)];
if (cs && isIdentityTransform(cs.transform)) cs.transform = "none";
}
}
/** True when an INFINITE CSS animation is active at this viewport. Such an animation perpetually
* drives its animated properties (opacity/transform), so the captured value is a frozen phase of
* the loop, not authored design. */
function hasInfiniteAnimation(cs: StyleMap | undefined): boolean {
if (!cs || (cs.animationName || "none") === "none") return false;
return /infinite/.test(cs.animationIterationCount || "1");
}
/**
* Neutralize the transform of a node that carries an INFINITE animation at ANY captured viewport.
* The capture shutter froze the marquee/spinner mid-loop, and critically a CSS animation gated
* to a breakpoint (Webflow's `max-lg` logo/big-text tracks) reads `animation:none` at the widths
* where it does NOT run, yet the browser still reports the last frozen `translateX` there. Banding
* those frozen values bakes a mid-scroll offset that shifts content offscreen-left AT REST (rows
* starting mid-glyph). The runtime `@keyframes` owns the transform and starts at translateX(0), so
* the faithful at-rest value is `none` at every width; generation's `animOwnedProps` then keeps the
* base holding while the animation drives it live. Only fires when a genuine infinite animation is
* present at some viewport a statically-offset design element (no animation) is untouched.
* Deterministic, in place; only touches the `transform` slot.
*/
export function neutralizeAnimatedTransforms(computedByVp: Record<number, StyleMap>): void {
const vps = Object.keys(computedByVp).map(Number);
if (!vps.some((vp) => hasInfiniteAnimation(computedByVp[vp]))) return;
for (const vp of vps) {
const cs = computedByVp[vp];
if (cs && cs.transform && cs.transform !== "none") cs.transform = "none";
}
}
/** Full identity signature (tag + id + class). */ /** Full identity signature (tag + id + class). */
function sigFull(n: RawNode): string { function sigFull(n: RawNode): string {
return `${n.tag}#${n.attrs?.id ?? ""}.${(n.attrs?.class ?? "").trim()}`; return `${n.tag}#${n.attrs?.id ?? ""}.${(n.attrs?.class ?? "").trim()}`;
@@ -259,6 +435,39 @@ function alignChildren(canon: RawNode[], other: RawNode[]): (RawNode | undefined
return out; return out;
} }
/** True when this raw node or any element descendant painted at its capture viewport. */
function rawSubtreeVisible(n: RawNode): boolean {
if (n.visible) return true;
for (const c of elementChildren(n)) if (rawSubtreeVisible(c)) return true;
return false;
}
/** Most frequent tag in a sibling group (ties broken alphabetically — deterministic). */
function dominantTag(nodes: RawNode[]): string {
const counts = new Map<string, number>();
for (const n of nodes) counts.set(n.tag, (counts.get(n.tag) ?? 0) + 1);
let best = "", bestC = -1;
for (const [t, c] of [...counts.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
if (c > bestC) { best = t; bestC = c; }
}
return best;
}
// A sibling appearing ONLY at non-canonical viewport(s) is grafted into the IR (visible only in
// its source band(s)). Cap per parent per viewport so a pathological capture (a virtualized list
// re-keying hundreds of rows) can't balloon the tree; the whole-set drift path below covers the
// legitimate large-mismatch case.
const GRAFT_MAX_PER_VP = 24;
// Whole-set content-identity drift: at least this many children UNMATCHED on BOTH sides before a
// container is treated as "the source served different content at this width" (vs a couple of
// responsive-only extras, which are grafted instead).
const DRIFT_MIN_UNMATCHED = 3;
/** A per-parent graft group: one non-canonical-only child, matched across the non-canonical
* viewports it appears at. `rep` is the raw node from the LOWEST viewport (structural identity
* for aligning later viewports into the group). */
type GraftGroup = { rep: RawNode; rawByVp: Record<number, RawNode> };
/** Capture-ids of recognized-pattern panels/regions to force-keep through pruning /** Capture-ids of recognized-pattern panels/regions to force-keep through pruning
* (they are display:none in the inactive/collapsed base state). Empty when no * (they are display:none in the inactive/collapsed base state). Empty when no
* interactions were captured, so non-interactive runs prune exactly as before. */ * interactions were captured, so non-interactive runs prune exactly as before. */
@@ -289,6 +498,9 @@ export function buildIR(sourceDir: string, viewports: number[], opts?: { motion?
} }
const canonical = viewports.includes(1280) ? 1280 : viewports[Math.floor(viewports.length / 2)]!; const canonical = viewports.includes(1280) ? 1280 : viewports[Math.floor(viewports.length / 2)]!;
const canonSnap = snapshots[canonical]!; const canonSnap = snapshots[canonical]!;
// Deterministic iteration order for the cross-viewport graft grouping below.
const vpsAsc = [...viewports].sort((a, b) => a - b);
const bandVpSet = new Set(bandVps);
// Stage 4: recognized interactive panels (tabs/accordion) are often display:none // Stage 4: recognized interactive panels (tabs/accordion) are often display:none
// at base (the inactive/collapsed state). Their subtrees must survive pruning so // at base (the inactive/collapsed state). Their subtrees must survive pruning so
@@ -304,6 +516,10 @@ export function buildIR(sourceDir: string, viewports: number[], opts?: { motion?
const convert = (raw: RawNode, matched: Record<number, RawNode | undefined>): IRNode | null => { const convert = (raw: RawNode, matched: Record<number, RawNode | undefined>): IRNode | null => {
if (NOISE_TAGS.has(raw.tag)) return null; if (NOISE_TAGS.has(raw.tag)) return null;
if (isThirdPartyOverlay(raw)) return null; if (isThirdPartyOverlay(raw)) return null;
// Font-metric / measurement scratch nodes the source's own JS injects (tagged by the walker):
// absolutely positioned, parked far off-screen, non-painting. Never user-visible — drop so they
// don't ship as page markup (e.g. the `<div … -top-[6249rem] invisible>Mgy</div>` probe).
if (raw.probe) return null;
const visibleByVp: Record<number, boolean> = {}; const visibleByVp: Record<number, boolean> = {};
const bboxByVp: Record<number, BBox> = {}; const bboxByVp: Record<number, BBox> = {};
@@ -324,6 +540,14 @@ export function buildIR(sourceDir: string, viewports: number[], opts?: { motion?
if (match.after) afterByVp[vw] = match.after; if (match.after) afterByVp[vw] = match.after;
if (match.placeholder) placeholderByVp[vw] = match.placeholder; if (match.placeholder) placeholderByVp[vw] = match.placeholder;
} }
// Canonicalize identity transforms (none / identity matrix) to `none` at every viewport so
// the generator's per-band delta treats them uniformly and a scroll/composite-noise transform
// at one width can't leak across bands.
canonicalizeTransforms(computedByVp);
// Drop transforms frozen mid-loop by an infinite animation (marquees/spinners) at every viewport —
// including the breakpoints where the animation is gated off but the browser still reports the last
// frozen offset. Prevents a baked mid-scroll translateX from clipping content offscreen at rest.
neutralizeAnimatedTransforms(computedByVp);
const node: IRNode = { const node: IRNode = {
id: nextId(), id: nextId(),
@@ -335,6 +559,7 @@ export function buildIR(sourceDir: string, viewports: number[], opts?: { motion?
children: [], children: [],
}; };
if (raw.rawHTML) node.rawHTML = raw.rawHTML; if (raw.rawHTML) node.rawHTML = raw.rawHTML;
if (raw.svgPaint) node.svgPaint = raw.svgPaint;
const srcClass = (raw.attrs?.class ?? "").trim(); const srcClass = (raw.attrs?.class ?? "").trim();
if (srcClass) node.srcClass = srcClass; if (srcClass) node.srcClass = srcClass;
if (Object.keys(sizingByVp).length) node.sizingByVp = sizingByVp; if (Object.keys(sizingByVp).length) node.sizingByVp = sizingByVp;
@@ -346,25 +571,131 @@ export function buildIR(sourceDir: string, viewports: number[], opts?: { motion?
const canonKids = elementChildren(raw); const canonKids = elementChildren(raw);
// Align this node's canonical element children to each viewport's children. // Align this node's canonical element children to each viewport's children.
const aligned: Record<number, (RawNode | undefined)[]> = {}; const aligned: Record<number, (RawNode | undefined)[]> = {};
const kidsByVp: Record<number, RawNode[]> = {};
for (const vw of viewports) { for (const vw of viewports) {
if (vw === canonical) continue; if (vw === canonical) continue;
const m = matched[vw]; const m = matched[vw];
const vwKids = m && m.tag === raw.tag ? elementChildren(m) : []; const vwKids = m && m.tag === raw.tag ? elementChildren(m) : [];
kidsByVp[vw] = vwKids;
aligned[vw] = alignChildren(canonKids, vwKids); aligned[vw] = alignChildren(canonKids, vwKids);
} }
// ---- Per-viewport DOM divergence (canonical-rooted subtrees only) ----
// Two inverse gaps closed here:
// • GRAFT: a child that exists ONLY at non-canonical viewport(s) (destroyed/never built at
// the canonical width) would otherwise never enter the IR — the clone then misses it at
// the widths where the source shows it (e.g. mobile-only carousel pagination bullets).
// Graft it as a sibling at its source position, carrying per-viewport data ONLY for the
// viewports it appeared at; emission hides it at base and reveals it in its band(s).
// • DRIFT: when a container's children at some viewport are a DIFFERENT SET on BOTH sides
// (mutual whole-set mismatch — the source deterministically served other content there),
// grafting would duplicate the component. Prefer faithful-at-canonical: record the drift
// so emission shows the canonical children there instead of banding them all display:none
// (an empty shell). Recorded in childDriftVps → doc.contentDrift → the manifest.
const driftVps = new Set<number>();
const graftsByAnchor = new Map<number, GraftGroup[]>();
if (matched[canonical]) {
for (const vw of vpsAsc) {
if (vw === canonical) continue;
const vwKids = kidsByVp[vw]!;
if (vwKids.length === 0) continue;
const matchedOther = new Set<RawNode>();
for (const mo of aligned[vw]!) if (mo) matchedOther.add(mo);
const unmatchedCanon = canonKids.filter((_, i) => !aligned[vw]![i]);
const unmatchedOther = vwKids.filter((k) => !matchedOther.has(k));
// Whole-set identity drift: many unmatched on BOTH sides, few matched, and both leftover
// groups are the same kind of element (same dominant tag — one component family serving
// different items, not a structurally different widget swapped in at this width).
if (
unmatchedCanon.length >= DRIFT_MIN_UNMATCHED &&
unmatchedOther.length >= DRIFT_MIN_UNMATCHED &&
matchedOther.size * 2 < Math.min(canonKids.length, vwKids.length) &&
dominantTag(unmatchedCanon) === dominantTag(unmatchedOther)
) {
driftVps.add(vw);
continue; // faithful-at-canonical: do NOT graft the other set (no duplication)
}
if (unmatchedOther.length === 0 || unmatchedOther.length > GRAFT_MAX_PER_VP) continue;
// Graft position: each unmatched child anchors AFTER the last canonical child matched
// before it in this viewport's sibling order (-1 = before every canonical child).
const canonIdxOf = new Map<RawNode, number>();
aligned[vw]!.forEach((mo, i) => { if (mo) canonIdxOf.set(mo, i); });
let lastCanonIdx = -1;
const newByAnchor = new Map<number, RawNode[]>();
for (const k of vwKids) {
const ci = canonIdxOf.get(k);
if (ci !== undefined) { lastCanonIdx = ci; continue; }
let list = newByAnchor.get(lastCanonIdx);
if (!list) newByAnchor.set(lastCanonIdx, (list = []));
list.push(k);
}
// Merge this viewport's unmatched children into the anchor's existing graft groups by
// structural signature (the same node present at several widths becomes ONE graft with
// per-viewport data), preserving sibling order; leftovers open new groups in order.
for (const [anchor, newcomers] of [...newByAnchor.entries()].sort((a, b) => a[0] - b[0])) {
const groups = graftsByAnchor.get(anchor) ?? [];
const al = alignChildren(groups.map((g) => g.rep), newcomers);
const groupOfNew = new Map<RawNode, number>();
al.forEach((r, idx) => { if (r) groupOfNew.set(r, idx); });
const merged: GraftGroup[] = [];
let gi = 0;
for (const k of newcomers) {
const gIdx = groupOfNew.get(k);
if (gIdx !== undefined) {
while (gi <= gIdx) merged.push(groups[gi++]!);
groups[gIdx]!.rawByVp[vw] = k;
} else {
merged.push({ rep: k, rawByVp: { [vw]: k } });
}
}
while (gi < groups.length) merged.push(groups[gi++]!);
graftsByAnchor.set(anchor, merged);
}
}
}
// Materialize graft groups → IR nodes. Keep only groups that actually PAINT at a band
// viewport (a graft visible solely at a dense sample width could never be shown — bands are
// emitted only at the standard breakpoints — so it would be dead markup).
const graftedByAnchor = new Map<number, IRNode[]>();
for (const [anchor, groups] of [...graftsByAnchor.entries()].sort((a, b) => a[0] - b[0])) {
const out: IRNode[] = [];
for (const g of groups) {
const graftVps = Object.keys(g.rawByVp).map(Number).sort((a, b) => a - b);
if (!graftVps.some((vw) => bandVpSet.has(vw) && rawSubtreeVisible(g.rawByVp[vw]!))) continue;
const gm: Record<number, RawNode | undefined> = {};
for (const vw of viewports) gm[vw] = g.rawByVp[vw];
const gn = convert(g.rawByVp[graftVps[0]!]!, gm);
if (gn) out.push(gn);
}
if (out.length) graftedByAnchor.set(anchor, out);
}
if (driftVps.size) {
const bandDrift = [...driftVps].filter((v) => bandVpSet.has(v)).sort((a, b) => a - b);
if (bandDrift.length) node.childDriftVps = bandDrift;
}
let ei = 0; let ei = 0;
let pushedLeading = false;
const pushGrafts = (anchor: number): void => {
for (const gn of graftedByAnchor.get(anchor) ?? []) node.children.push(gn);
};
for (const c of raw.children) { for (const c of raw.children) {
if ((c as IRTextNode).text !== undefined) { if ((c as IRTextNode).text !== undefined) {
node.children.push({ text: (c as IRTextNode).text }); node.children.push({ text: (c as IRTextNode).text });
continue; continue;
} }
if (!pushedLeading) { pushGrafts(-1); pushedLeading = true; }
const child = c as RawNode; const child = c as RawNode;
const childMatched: Record<number, RawNode | undefined> = {}; const childMatched: Record<number, RawNode | undefined> = {};
for (const vw of viewports) childMatched[vw] = vw === canonical ? child : aligned[vw]![ei]; // A grafted subtree has no canonical counterpart: its own children must not be treated as
ei++; // canonical-matched either (they carry data only at the graft's source viewports).
for (const vw of viewports) childMatched[vw] = vw === canonical ? (matched[canonical] ? child : undefined) : aligned[vw]![ei];
const converted = convert(child, childMatched); const converted = convert(child, childMatched);
if (converted) node.children.push(converted); if (converted) node.children.push(converted);
pushGrafts(ei);
ei++;
} }
if (!pushedLeading) pushGrafts(-1);
} }
return node; return node;
}; };
@@ -381,6 +712,8 @@ export function buildIR(sourceDir: string, viewports: number[], opts?: { motion?
// interactive panel so its whole (possibly display:none) subtree is retained. // interactive panel so its whole (possibly display:none) subtree is retained.
const keepAll = forced || preserveCaps.has(node.attrs["data-cid-cap"] ?? ""); const keepAll = forced || preserveCaps.has(node.attrs["data-cid-cap"] ?? "");
const keptChildren: IRChild[] = []; const keptChildren: IRChild[] = [];
// Per element child, in original order: was it kept? (for track-transform re-anchoring below.)
const elemKept: Array<{ child: IRNode; kept: boolean }> = [];
let hasVisibleDescendant = false; let hasVisibleDescendant = false;
let hasText = false; let hasText = false;
for (const c of node.children) { for (const c of node.children) {
@@ -397,16 +730,81 @@ export function buildIR(sourceDir: string, viewports: number[], opts?: { motion?
if (keepAll) { if (keepAll) {
prune(c, true); prune(c, true);
keptChildren.push(c); keptChildren.push(c);
elemKept.push({ child: c, kept: true });
if (Object.values(c.visibleByVp).some(Boolean) || childHasVisible(c)) hasVisibleDescendant = true; if (Object.values(c.visibleByVp).some(Boolean) || childHasVisible(c)) hasVisibleDescendant = true;
} else if (prune(c, false) || keepSource) { } else if (prune(c, false) || keepSource) {
keptChildren.push(c); keptChildren.push(c);
elemKept.push({ child: c, kept: true });
if (Object.values(c.visibleByVp).some(Boolean) || childHasVisible(c)) hasVisibleDescendant = true; if (Object.values(c.visibleByVp).some(Boolean) || childHasVisible(c)) hasVisibleDescendant = true;
} else {
elemKept.push({ child: c, kept: false });
} }
} }
node.children = keptChildren; node.children = keptChildren;
reanchorTrackTransform(node, elemKept);
if (keepAll) return true; if (keepAll) return true;
const selfVisible = Object.values(node.visibleByVp).some(Boolean); const selfVisible = Object.values(node.visibleByVp).some(Boolean);
return selfVisible || hasVisibleDescendant || hasText || !!node.rawHTML; return selfVisible || hasVisibleDescendant || hasText || !!node.rawHTML || isSizedInvisibleSpacer(node);
};
// An in-flow `visibility:hidden` box with a nonzero border box is LOAD-BEARING geometry: it is
// invisible (so `visibleByVp` is false and the visibility prune would drop it), but it still takes
// up its captured width/height in normal flow — a spacer/ghost column that reserves the row height
// its absolutely-positioned siblings paint into. Dropping it collapses the column and shifts
// everything below it up. Keep it as an empty sized placeholder (generation emits its w/h with
// `visibility:hidden` and no content). `display:none`-everywhere nodes (never in flow, zero box)
// and out-of-flow probes stay pruned; font-metric probes are already dropped before pruning.
const isSizedInvisibleSpacer = (node: IRNode): boolean => {
for (const vp of Object.keys(node.computedByVp).map(Number)) {
const cs = node.computedByVp[vp]; const bb = node.bboxByVp[vp];
if (!cs || !bb) continue;
if ((cs.display || "") === "none") continue; // not in flow here
const vis = cs.visibility || "visible";
if (vis !== "hidden" && vis !== "collapse") continue; // only invisible-but-laid-out boxes
const pos = cs.position || "static";
if (pos !== "static" && pos !== "relative") continue; // in-flow geometry only (not absolute/fixed)
if (bb.width > 0 && bb.height > 0) return true; // reserves real space
}
return false;
};
// Re-anchor a horizontally-translated TRACK container after pruning removed leading in-flow
// children. A settled loop carousel (Splide/Swiper/Slick) parks its track with a baked
// `translateX(-N)` and prepends invisible clone slides that occupy exactly [-N, 0]; the first REAL
// slide then paints at x=0. The visibility prune drops the off-screen clones but the baked
// translateX survives verbatim, so every kept slide sits N px offscreen-left (an empty band). When
// emission drops the leading in-flow children of such a translated track, subtract their aggregate
// outer width from the baked translateX per viewport so the first KEPT child lands where it was
// captured. NON-animated baked offsets only — animation-owned transforms are already neutralized to
// `none` upstream (neutralizeAnimatedTransforms), so a still-present translate here is a static bake.
const reanchorTrackTransform = (node: IRNode, elemKept: Array<{ child: IRNode; kept: boolean }>): void => {
if (elemKept.length === 0) return;
// Only act when a leading RUN of element children was dropped (the clone slides precede the reals).
const firstKeptIdx = elemKept.findIndex((e) => e.kept);
if (firstKeptIdx <= 0) return; // nothing dropped ahead of the first kept child (or nothing kept)
for (const vp of Object.keys(node.computedByVp).map(Number)) {
const cs = node.computedByVp[vp];
if (!cs || !cs.transform) continue;
const tx = translateXOf(cs.transform);
if (tx === null || tx === 0) continue; // no baked horizontal offset to re-anchor at this vp
// Aggregate the outer (margin-box) width of the dropped leading in-flow siblings at this vp.
let droppedW = 0;
for (let i = 0; i < firstKeptIdx; i++) {
const { child } = elemKept[i]!;
const ccs = child.computedByVp[vp]; const cb = child.bboxByVp[vp];
if (!ccs || !cb) continue;
if ((ccs.display || "") === "none") continue;
const cpos = ccs.position || "static";
if (cpos !== "static" && cpos !== "relative") continue; // out-of-flow slides don't advance the track
droppedW += cb.width + pfLen(ccs.marginLeft) + pfLen(ccs.marginRight);
}
if (droppedW === 0) continue;
// translateX is negative (track pulled left); dropping the leading clones that filled [tx, 0]
// means the reals shift right by droppedW → add it back. Re-anchor toward 0.
const next = tx + droppedW;
// Only re-anchor when it moves the track TOWARD the origin and doesn't overshoot past it — a
// guard so a partial mismatch can't push content the wrong way. Round to match capture precision.
if (Math.abs(next) >= Math.abs(tx)) continue;
cs.transform = setTranslateX(cs.transform, Math.round(next * 100) / 100);
}
}; };
const childHasVisible = (n: IRNode): boolean => { const childHasVisible = (n: IRNode): boolean => {
for (const c of n.children) { for (const c of n.children) {
@@ -427,6 +825,16 @@ export function buildIR(sourceDir: string, viewports: number[], opts?: { motion?
}; };
renumber(root); renumber(root);
// Content-identity drift notes (containers whose children were a different set at some band
// viewport — see childDriftVps). Collected AFTER renumbering so the recorded ids are final;
// deterministic (pre-order walk of the pruned tree).
const contentDrift: NonNullable<IRDoc["contentDrift"]> = [];
const collectDrift = (n: IRNode): void => {
if (n.childDriftVps?.length) contentDrift.push({ id: n.id, tag: n.tag, viewports: [...n.childDriftVps] });
for (const c of n.children) if (!isTextChild(c)) collectDrift(c);
};
collectDrift(root);
const perViewport: IRDoc["perViewport"] = {}; const perViewport: IRDoc["perViewport"] = {};
for (const vw of viewports) { for (const vw of viewports) {
const d = snapshots[vw]!.doc; const d = snapshots[vw]!.doc;
@@ -456,6 +864,7 @@ export function buildIR(sourceDir: string, viewports: number[], opts?: { motion?
// benchmark + all multi-page, which don't capture motion) ⇒ none emitted, so the // benchmark + all multi-page, which don't capture motion) ⇒ none emitted, so the
// clone stays byte-identical to the pre-Stage-5 frozen output. // clone stays byte-identical to the pre-Stage-5 frozen output.
keyframes: opts?.motion ? [...new Set(canonSnap.keyframes ?? [])].sort() : [], keyframes: opts?.motion ? [...new Set(canonSnap.keyframes ?? [])].sort() : [],
...(contentDrift.length ? { contentDrift } : {}),
}; };
// Repair transient root scroll-locks. A site that locks scrolling during an intro // Repair transient root scroll-locks. A site that locks scrolling during an intro
+116 -117
View File
@@ -1,142 +1,141 @@
/** /**
* Code-quality audit count the "bad code" tells that make a generated clone read robotic, for any * Code-quality audit an honest, human-readable report over one or more generated app
* number of app source trees, side by side. Repeatable: point it at shipped deliverables and it * trees. Built directly on the dimension scorer in ./qualityScore, so the audit and the
* prints one column per tree. * shipped `code-quality.md` quality number never disagree.
* *
* npm run audit # auto: every output/<site>/app * npm run audit # auto: every runs/<site>/latest/generated/app
* npm run audit -- <dir> [<dir> ...] # explicit trees; .clone resolves to sibling app/ * npm run audit -- <appDir> [<appDir> ...] # explicit app trees
* npm run audit -- output/example/app * npm run audit -- runs/example/latest/generated/app
* npm run audit -- <dir> --json # machine-readable
* *
* Scans .tsx/.jsx/.ts/.css under each dir (skips node_modules/.next/out/dotfiles). Every metric is a * For each tree it prints:
* COUNT where lower is better, except the two "good" context rows (fluid width / standard scale). * the LETTER GRADE + numeric score (and any hard-cap reason),
* The decisive "decimal" tell is non-integer-PX: each arbitrary px/rem is converted to px and flagged * a per-DIMENSION table (payload / decomposition / duplication / semantics / hygiene
* if it isn't a whole pixel (a frozen measurement), so a clean 12.5rem (=200px) does NOT count. * / runtime) with the visible sub-metrics behind each score,
* the TOP OFFENDERS (file, metric, value) the worst individual tells.
* When several trees are passed it also prints a side-by-side grade comparison.
*
* Pure static analysis over generated source (.tsx/.jsx/.ts/.astro/.css). No builds, no
* browser. See qualityScore.ts for the rubric + the (calibration-guide) thresholds.
*/ */
import { readdirSync, statSync, readFileSync, existsSync } from "node:fs"; import { readdirSync, statSync, existsSync } from "node:fs";
import { join, resolve, basename, dirname, sep } from "node:path"; import { join, resolve, basename } from "node:path";
import { scoreApp, type QualityReport } from "./qualityScore.js";
type Row = { label: string; lowerBetter: boolean; values: number[] }; // ---------------------------------------------------------------------------
type Target = { inputDir: string; scanDir: string; validationDir?: string }; // Target resolution — accept an app dir directly, or discover deliverables.
// ---------------------------------------------------------------------------
function readTree(dir: string): { tsx: string; css: string; all: string } { /** Is `dir` a scannable app tree (has a src/ with code in it, or is itself full of code)? */
const tsxParts: string[] = [], cssParts: string[] = []; function isAppDir(dir: string): boolean {
const walk = (d: string): void => { if (!existsSync(dir)) return false;
for (const n of readdirSync(d)) { if (existsSync(join(dir, "src"))) return true;
if (n === "node_modules" || n === ".next" || n === "out" || n.startsWith(".")) continue; try { return readdirSync(dir).some((n) => /\.(tsx|jsx|ts|css|astro)$/.test(n)); } catch { return false; }
const p = join(d, n);
const st = statSync(p);
if (st.isDirectory()) walk(p);
else if (/\.(tsx|jsx|ts)$/.test(n)) tsxParts.push(readFileSync(p, "utf8"));
else if (/\.css$/.test(n)) cssParts.push(readFileSync(p, "utf8"));
}
};
walk(dir);
const tsx = tsxParts.join("\n"), css = cssParts.join("\n");
return { tsx, css, all: tsx + "\n" + css };
} }
/** All metric counts for one tree. */ /** Discover every runs/<site>/latest/generated/app deliverable, newest layout first.
function audit(target: Target): Record<string, number> { * Checks both the cwd and its parent, so `npm run audit` works whether invoked from the
const dir = target.scanDir; * repo root or from compiler/ (where runs/ lives one level up). */
const { tsx, css, all } = readTree(dir); function discoverTargets(): string[] {
const n = (re: RegExp, s = all): number => (s.match(re) || []).length; const out: string[] = [];
const validationDataCid = target.validationDir && existsSync(target.validationDir) const roots = ["runs", "output", "../runs", "../output"].map((r) => resolve(r)).filter(existsSync);
? (readTree(target.validationDir).all.match(/data-cid/g) || []).length for (const root of roots) {
: 0; let sites: string[];
try { sites = readdirSync(root); } catch { continue; }
// Arbitrary px/rem values + the non-integer-px ("decimal") subset, the decisive measurement tell. for (const site of sites) {
let arb = 0, decimal = 0; for (const app of [
for (const m of all.matchAll(/\[(-?[0-9]+\.?[0-9]*)(px|rem)\]/g)) { join(root, site, "latest", "generated", "app"),
arb++; join(root, site, "app"),
const px = parseFloat(m[1]!) * (m[2] === "rem" ? 16 : 1); ]) {
if (Math.abs(px - Math.round(px)) > 0.02) decimal++; if (isAppDir(app)) { out.push(app); break; }
} }
}
return { }
"files (tsx+css)": (tsx ? 1 : 0), // overwritten below with real file counts return out;
"── BAD (lower=better) ──": -1,
"fixed width w-[Npx/rem]": n(/\bw-\[-?[0-9.]+(?:px|rem)\]/g),
"fixed height h-[Npx/rem]": n(/\bh-\[-?[0-9.]+(?:px|rem)\]/g),
"breakpoint utilities": n(/\b(?:sm|md|lg|xl|2xl|max-sm|max-md|max-lg|max-xl):/g),
"arbitrary bands min/max-[Npx]:": n(/\b(?:min|max)-\[[0-9]+px\]:/g),
"arbitrary […px/rem] total": arb,
"decimal (non-integer px)": decimal,
"baked position top/left/inset-[N]": n(/\b(?:top|left|right|bottom|inset(?:-x|-y)?)-\[-?[0-9.]+(?:px|rem)\]/g),
"raw color literal [#hex/rgb]": n(/\[(?:#[0-9a-fA-F]{3,8}|rgba?\([^\]]*)\]/g),
"per-side border longhand": n(/\[border-(?:top|right|bottom|left)-(?:style|color):/g),
"data-cid (shipped)": n(/data-cid/g),
"data-cid (validation-only)": validationDataCid,
"dangerouslySetInnerHTML": n(/dangerouslySetInnerHTML/g),
"': any' props": n(/:\s*any\b/g),
"robotic 'Generated by' comments": n(/Generated by clone|clone-static/g),
"── GOOD (higher=better) ──": -1,
"fluid width (w-full/auto/fraction)": n(/\bw-(?:full|auto|fit|screen|\d{1,2}\/\d{1,2})\b/g),
"standard scale (gap-2/w-10/p-4…)": n(/\b(?:gap|p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|w|h)-(?:0|0\.5|1|1\.5|2|2\.5|3|3\.5|4|5|6|7|8|9|10|11|12|14|16|20|24|28|32|36|40|44|48|52|56|60|64|72|80|96|px)\b/g),
};
} }
function fileCount(dir: string): number { // ---------------------------------------------------------------------------
let c = 0; // Rendering
const walk = (d: string): void => { for (const x of readdirSync(d)) { if (x === "node_modules" || x === ".next" || x === "out" || x.startsWith(".")) continue; const p = join(d, x); statSync(p).isDirectory() ? walk(p) : (/\.(tsx|jsx|ts|css)$/.test(x) && c++); } }; // ---------------------------------------------------------------------------
walk(dir);
return c; /** A readable column label for a target (its site/run name where possible). */
function labelFor(dir: string): string {
const parts = resolve(dir).split("/");
const runsIdx = parts.lastIndexOf("runs");
const outIdx = parts.lastIndexOf("output");
const i = runsIdx >= 0 ? runsIdx : outIdx;
if (i >= 0 && parts[i + 1]) return parts[i + 1]!.slice(0, 22);
return (parts[parts.length - 2] ?? basename(dir)).slice(0, 22);
} }
function normalizeTarget(input: string): Target { const pad = (s: string, w: number): string => s + " ".repeat(Math.max(0, w - s.length));
const d = resolve(input); const padL = (s: string, w: number): string => " ".repeat(Math.max(0, w - s.length)) + s;
const asApp = (appDir: string, validationDir?: string): Target => ({ inputDir: d, scanDir: appDir, validationDir });
if (basename(d) === ".clone") { function renderReport(label: string, rep: QualityReport): string {
const app = join(dirname(d), "app"); const L: string[] = [];
const validation = join(d, "generated", "app"); L.push("");
if (existsSync(join(app, "src"))) return asApp(app, existsSync(validation) ? validation : undefined); L.push(`━━━ ${label} ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
L.push(` GRADE ${rep.grade} (${rep.total}/100)`);
if (rep.caps.length) {
L.push(` HARD CAP → grade limited to D-range:`);
for (const c of rep.caps) L.push(`${c}`);
} }
const generatedSuffix = `${sep}.clone${sep}generated${sep}app`; L.push("");
if (d.endsWith(generatedSuffix)) { L.push(` ${pad("dimension", 15)}${padL("score", 9)} sub-metrics`);
const cloneDir = d.slice(0, -`${sep}generated${sep}app`.length); L.push(` ${"─".repeat(66)}`);
const app = join(dirname(cloneDir), "app"); for (const [dim, cat] of Object.entries(rep.categories)) {
if (existsSync(join(app, "src"))) return asApp(app, d); const metrics = Object.entries(cat.metrics).map(([k, v]) => `${k}=${v}`).join(" ");
L.push(` ${pad(dim, 15)}${padL(`${cat.score}/${cat.max}`, 9)} ${metrics}`);
} }
if (basename(d) === "generated" && existsSync(join(d, "app", "src"))) { if (rep.offenders.length) {
const cloneDir = dirname(d); L.push("");
const app = join(dirname(cloneDir), "app"); L.push(` top offenders`);
if (basename(cloneDir) === ".clone" && existsSync(join(app, "src"))) return asApp(app, join(d, "app")); L.push(` ${pad("metric", 34)}${padL("value", 12)} file`);
L.push(` ${"─".repeat(66)}`);
for (const o of rep.offenders.slice(0, 10)) {
L.push(` ${pad(o.metric, 34)}${padL(String(o.value), 12)} ${o.file}`);
} }
return { inputDir: d, scanDir: d }; }
return L.join("\n");
} }
function renderComparison(labels: string[], reps: QualityReport[]): string {
const L: string[] = [];
const colW = Math.max(12, ...labels.map((l) => l.length + 2));
L.push("");
L.push(`━━━ comparison ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
L.push(" " + pad("dimension", 15) + labels.map((l) => padL(l, colW)).join(""));
const dims = Object.keys(reps[0]!.categories);
for (const d of dims) {
L.push(" " + pad(d, 15) + reps.map((r) => padL(String(r.categories[d]!.score), colW)).join(""));
}
L.push(" " + pad("GRADE", 15) + reps.map((r) => padL(`${r.grade} (${r.total})`, colW)).join(""));
return L.join("\n");
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
function main(): void { function main(): void {
const args = process.argv.slice(2).filter((a) => !a.startsWith("--")); const args = process.argv.slice(2).filter((a) => !a.startsWith("--"));
let targets: Target[]; const asJson = process.argv.includes("--json");
if (args.length) targets = args.map(normalizeTarget); const targets = (args.length ? args.map((a) => resolve(a)) : discoverTargets()).filter(isAppDir);
else { if (!targets.length) { console.error("no app trees found — pass app dirs explicitly (e.g. runs/<site>/latest/generated/app)"); process.exit(1); }
// Auto: every output/<site>/app deliverable.
const outRoot = resolve("output");
const sites = existsSync(outRoot)
? readdirSync(outRoot).map((s) => join(outRoot, s, "app")).filter((p) => existsSync(join(p, "src")))
: [];
targets = sites.map(normalizeTarget);
}
targets = targets.filter((t) => existsSync(t.scanDir));
if (!targets.length) { console.error("no app trees found — pass dirs explicitly"); process.exit(1); }
// A readable column label: the deliverable dir's parent name, or the scanned dir name. const labels = targets.map(labelFor);
const label = (t: Target): string => (basename(dirname(t.scanDir)) || basename(t.scanDir)).slice(0, 16); const reports = targets.map((t) => scoreApp(t));
const labels = targets.map(label);
const audits = targets.map(audit);
audits.forEach((a, i) => { a["files (tsx+css)"] = fileCount(targets[i]!.scanDir); });
const keys = Object.keys(audits[0]!); if (asJson) {
const w0 = Math.max(...keys.map((k) => k.length)); console.log(JSON.stringify(reports.map((r, i) => ({ label: labels[i], ...r })), null, 2));
const colW = Math.max(11, ...labels.map((l) => l.length)); return;
const pad = (s: string, w: number) => s + " ".repeat(Math.max(0, w - s.length));
console.log("\n" + pad("metric", w0) + " " + labels.map((l) => pad(l, colW)).join(""));
console.log("─".repeat(w0 + 2 + colW * labels.length));
for (const k of keys) {
if (audits[0]![k] === -1) { console.log(pad(k, w0)); continue; } // section header
const cells = audits.map((a) => pad(String(a[k] ?? 0), colW)).join("");
console.log(pad(k, w0) + " " + cells);
} }
console.log("\n(counts; lower is better in the BAD block, higher in the GOOD block. 'decimal' = the\n arbitrary value's px isn't a whole pixel — the frozen-measurement tell.)\n");
for (let i = 0; i < reports.length; i++) console.log(renderReport(labels[i]!, reports[i]!));
if (reports.length > 1) console.log(renderComparison(labels, reports));
console.log("\n(scores are out of each dimension's max; grade is the weighted blend, capped to\n D-range if any single file/line/blob is in catastrophic payload territory.)\n");
} }
main(); if (import.meta.url === `file://${process.argv[1]}`) {
main();
}
+493 -232
View File
@@ -1,23 +1,93 @@
/** /**
* Output-quality rubric (deterministic, framework-agnostic). * Output-quality rubric HONEST edition (deterministic, framework-agnostic).
* *
* The fidelity gates (validate/gates.ts) measure whether the clone *looks and * The fidelity gates (validate/gates.ts) measure whether the clone *looks and
* behaves* like the source. They say nothing about whether the generated CODE is * behaves* like the source. They say nothing about whether the generated CODE is
* good componentized, semantically named, styled through a reusable token/class * good small files, decomposed into real components, semantic and accessible,
* system, editable, and well organized. That "developer-facing quality" is exactly * free of capture artifacts, and safe at runtime. That "developer-facing quality"
* where a deterministic converter is judged once fidelity is a given. * is what a deterministic converter is judged on once fidelity is a given.
* *
* This module statically analyzes a generated app directory and scores it 0100 on * This module statically analyzes a generated app directory (source text only
* six categories. It reads only source text (`.tsx/.jsx/.ts/.astro/.css`). The * `.tsx/.jsx/.ts/.astro/.css`) and scores it 0100 across SIX dimensions, each
* metrics are chosen to be framework-agnostic: e.g. "style reuse" rewards shared * with visible subscores. The score is a weighted blend of the dimensions EXCEPT
* classes whether they're Tailwind utilities or semantic clone classes, and * that a single dimension in catastrophic territory (a multi-megabyte source file,
* penalizes per-node unique rules (`.c1{...} .c2{...}`) regardless of framework. * a 100KB+ single line) HARD-CAPS the overall grade into D-range no matter how
* clean everything else is because such a file is unopenable, un-diffable, and
* un-editable, which is the whole point of "good code".
*
* The signals are deliberately general (payload bytes, LOC distribution, repeated
* definitions, semantic-tag ratios, whitespace/capture artifacts, uncleaned
* listeners) so the scorer is not tuned to any specific site it measures the
* failure *modes* a robotic converter falls into, not any one output.
* *
* Pure + deterministic: same directory same score. * Pure + deterministic: same directory same score.
*/ */
import { readFileSync, readdirSync, statSync } from "node:fs"; import { readFileSync, readdirSync, statSync } from "node:fs";
import { join, extname, basename, relative } from "node:path"; import { join, extname, basename, relative } from "node:path";
// ===========================================================================
// CALIBRATION CONSTANTS
// ---------------------------------------------------------------------------
// These are CALIBRATION GUIDES, expected to be tuned as the generator and our
// taste evolve — they are NOT compliance targets or contractual thresholds. A
// number here answers "where does a human reviewer start wincing?", set from
// observed good vs. bad outputs, and should be re-fit (not litigated) whenever
// calibration drifts. Every knob lives in this one block on purpose.
// ===========================================================================
const K = {
// ---- payload discipline (a file/line so big it is not human-readable) ----
FILE_BYTES_GOOD: 30_000, // ≤30KB source file: comfortable to open
FILE_BYTES_BAD: 160_000, // ≥160KB: a reviewer scrolls forever; scores ~0
LINE_CHARS_GOOD: 2_000, // a formatted line, allowing for a dense data/JSX row
LINE_CHARS_BAD: 30_000, // a giant one-liner (minified / HTML-as-string prop)
INLINE_BYTES_GOOD: 50_000, // total base64 / inline-HTML bytes that's forgivable
INLINE_BYTES_BAD: 2_000_000, // 2MB+ of embedded blobs: catastrophic payload
// Catastrophe caps: any ONE of these forces the whole app into D-range.
CATASTROPHE_FILE_BYTES: 1_000_000, // a >1MB source file
CATASTROPHE_LINE_CHARS: 100_000, // a >100KB single line
CATASTROPHE_INLINE_BYTES: 5_000_000, // >5MB of embedded base64/HTML
CAP_GRADE_D: 68, // ceiling applied when a catastrophe fires — top of the D band, so a
// catastrophic payload can grade no better than D+ regardless of other dimensions
// Softer cap for "very bad but not unopenable" payloads.
WARN_FILE_BYTES: 350_000,
WARN_LINE_CHARS: 40_000,
CAP_GRADE_C: 78, // ceiling for the softer warning band (top of C+)
// ---- component decomposition (is the page a monolith?) ----
MONOLITH_DOMINANCE_GOOD: 0.3, // biggest file ≤30% of all tags → well spread
MONOLITH_DOMINANCE_BAD: 0.75, // one file holds ≥75% of the page → monolith
BIG_SECTION_LINES: 600, // a "section" this long is really a whole page
COMPONENTS_GOOD: 12, // this many real components → full decomposition credit
// ---- duplication ----
DUP_HELPER_RATIO_BAD: 0.4, // ≥40% of helper defs are copy-paste duplicates
DUP_SVG_PATH_RATIO_BAD: 0.85, // ≥85% of inline <path> strings are repeats (shared
// icon sets legitimately repeat, so only near-total duplication is a real tell)
NEAR_DUP_COMPONENT_BAD: 14, // this many near-identical NON-trivial component pairs → 0
NEAR_DUP_MIN_TAGS: 8, // ignore tiny components (icons/logos) in near-dup detection
// ---- semantics / a11y ----
DIV_RATIO_GOOD: 0.6, // ≤60% of elements are bare div/span → healthy
DIV_RATIO_BAD: 0.9, // ≥90% divs → div soup
ALT_COVERAGE_GOOD: 0.9, // ≥90% of <img> carry alt=
H1_REQUIRED: 1, // a page really should have exactly one <h1>
// ---- hygiene ----
// {" "} literals and sub-pixel arbitraries are a KNOWN baseline quirk of this
// converter present in even good output — only pathological volumes should bite,
// so these BADs sit well above what a "decent" tree emits.
WS_LITERAL_PER_KTAG_GOOD: 100, // per 1000 tags a converter routinely emits some
WS_LITERAL_PER_KTAG_BAD: 1200, // this many → capture-whitespace noise dominates
SUBPIXEL_PER_KTAG_GOOD: 60, // some frozen measurements are unavoidable
SUBPIXEL_PER_KTAG_BAD: 260, // a wall of frozen sub-pixels → machine replay
OPAQUE_TOKEN_RATIO_BAD: 0.6, // ≥60% of CSS custom props are opaque --clr-N / hashes
PROBE_ARTIFACTS_BAD: 8, // off-screen capture-probe text leaks
// ---- runtime discipline ----
LEAK_LISTENERS_GOOD: 8, // a small shared runtime bundle carries a few listeners
LEAK_LISTENERS_BAD: 30, // this many uncleaned adds → real leak territory
} as const;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// File collection // File collection
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -51,13 +121,43 @@ export function collectFiles(root: string): SrcFile[] {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Primitive extraction // Small helpers
// ---------------------------------------------------------------------------
const clamp01 = (x: number): number => Math.max(0, Math.min(1, x));
/** Linear ramp: v in [lo,hi] → [0,1]. Handles lo>hi (descending / "lower is better"). */
function ramp(v: number, lo: number, hi: number): number {
if (lo === hi) return v >= hi ? 1 : 0;
return clamp01((v - lo) / (hi - lo));
}
/** A value where HIGHER is worse: good at `good`, zero at `bad`. */
const penalize = (v: number, good: number, bad: number): number => 1 - ramp(v, good, bad);
const r1 = (n: number): number => Math.round(n * 10) / 10;
/** Longest single line (in chars) in a file. */
function maxLineLen(text: string): number {
let max = 0, start = 0;
for (let i = 0; i < text.length; i++) {
if (text.charCodeAt(i) === 10) { if (i - start > max) max = i - start; start = i + 1; }
}
if (text.length - start > max) max = text.length - start;
return max;
}
/** Count JSX/HTML opening element tags — a framework-agnostic page-size proxy. */
function countTags(text: string): number {
const m = text.match(/<[a-zA-Z][a-zA-Z0-9.]*(\s|\/|>)/g);
return m ? m.length : 0;
}
// ---------------------------------------------------------------------------
// Structural classification
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const CONFIG_NAME_RE = /(config|tsconfig|next-env|site-config|content\.config|\.config\.)/i; const CONFIG_NAME_RE = /(config|tsconfig|next-env|site-config|content\.config|\.config\.)/i;
const ROOT_NAME_RE = /(^|\/)(layout|_app|_document|root-layout)\.[jt]sx$/i; const ROOT_NAME_RE = /(^|\/)(layout|_app|_document|root-layout)\.[jt]sx$/i;
/** Files that compose the whole page (the "entry"): page.tsx (Next), index/page in a const SECTION_WORDS = ["hero", "footer", "navbar", "nav", "header", "about", "cta", "feature", "pricing", "faq", "logo", "logocloud", "testimonial", "gallery", "stories", "spotlight", "knowledge", "news", "apply", "contact", "banner", "team", "stats", "partners", "alumni", "founder", "section"];
* pages/ dir, home.tsx, or an .astro route shell. Used for the dominance metric. */
function isEntryFile(rel: string): boolean { function isEntryFile(rel: string): boolean {
const b = basename(rel).toLowerCase(); const b = basename(rel).toLowerCase();
if (/^(page|index|home|app)\.[jt]sx$/.test(b)) return true; if (/^(page|index|home|app)\.[jt]sx$/.test(b)) return true;
@@ -66,54 +166,8 @@ function isEntryFile(rel: string): boolean {
return false; return false;
} }
const SECTION_WORDS = ["hero", "footer", "navbar", "nav", "header", "about", "cta", "feature", "pricing", "faq", "logo", "logocloud", "testimonial", "gallery", "stories", "spotlight", "knowledge", "news", "apply", "contact", "banner", "team", "stats", "partners", "alumni", "founder", "section"];
/** Count JSX/HTML opening element tags a framework-agnostic proxy for page size
* (how many DOM nodes the output describes). Excludes React fragments + closing tags. */
function countTags(text: string): number {
const m = text.match(/<[a-zA-Z][a-zA-Z0-9.]*(\s|\/|>)/g);
return m ? m.length : 0;
}
/** All className / class string-literal tokens used on elements (space split).
* Catches JSX attribute form (className="…"), object/spread form (className: "…"
* what our generator emits), and hoisted className consts. A dynamic per-node class
* (className: "c" + d._cid / className={"c"+}) renders a UNIQUE opaque class per
* instance, so each occurrence is recorded as its own opaque token (keeping the
* reuse + semantic metrics honest for the per-node-CSS strategy). */
function classTokens(text: string): string[] {
const out: string[] = [];
// className="..." / className: "..." / "class": "..." (attr + object form)
const re = /\b(?:className|class)\s*(?:=|:)\s*"([^"]*)"/g;
let m: RegExpExecArray | null;
while ((m = re.exec(text)) !== null) {
for (const t of m[1]!.split(/\s+/)) if (t) out.push(t);
}
// hoisted className consts: const xClassName = "..."
const re2 = /\b(?:const|let)\s+\w*[Cc]lassName\w*\s*=\s*"([^"]*)"/g;
while ((m = re2.exec(text)) !== null) {
for (const t of m[1]!.split(/\s+/)) if (t) out.push(t);
}
// dynamic per-node class: className: "c" + d._cid / className={"c"+ ...}
const dyn = (text.match(/\bclassName\s*(?:=\s*\{|:)\s*"c[a-z]?"\s*\+/g) ?? []).length
+ (text.match(/\bclassName\s*(?:=\s*\{|:)\s*"c[a-z]?\d*"\s*\+/g) ?? []).length;
for (let i = 0; i < dyn; i++) out.push(`c__dyn${out.length}`); // unique opaque per occurrence
return out;
}
const CID_CLASS_RE = /^c[a-z]?\d+$/; // our legacy per-node class: c12 / cn12
/** A class token that carries no human meaning (pure id / hash). */
function isOpaqueClass(t: string): boolean {
if (CID_CLASS_RE.test(t)) return true;
if (/^[a-z]?\d+$/.test(t)) return true;
// hashed CSS-module-ish tokens: _foo_ab12 or 6-hex tails
if (/_[a-z0-9]{5,}$/i.test(t) && /\d/.test(t)) return true;
return false;
}
const GENERIC_COMP_RE = /^(Item|Card|List|ListItem|Link|LinkItem|Wrapper|Box|Div|El|Node|Group|Block|Comp|Component|Row|Col|Cell|Thing|Unit|Part|Chunk)\d*$/; const GENERIC_COMP_RE = /^(Item|Card|List|ListItem|Link|LinkItem|Wrapper|Box|Div|El|Node|Group|Block|Comp|Component|Row|Col|Cell|Thing|Unit|Part|Chunk)\d*$/;
/** Exported / defined component names in a file. */ export function componentNames(text: string): string[] {
function componentNames(text: string): string[] {
const names = new Set<string>(); const names = new Set<string>();
let m: RegExpExecArray | null; let m: RegExpExecArray | null;
const re1 = /export\s+default\s+function\s+([A-Z]\w*)/g; const re1 = /export\s+default\s+function\s+([A-Z]\w*)/g;
@@ -125,221 +179,428 @@ function componentNames(text: string): string[] {
return [...names]; return [...names];
} }
/** Data/content field names declared in a content or data module. */ // ===========================================================================
function contentFieldNames(text: string): string[] { // METRIC EXTRACTORS (exported for unit tests — each is a pure text→number/obj)
const out: string[] = []; // ===========================================================================
// type Foo = { a: string; b?: number } — capture keys
const typeBlocks = text.match(/(?:type|interface)\s+\w+\s*=?\s*\{([^}]*)\}/g) ?? []; /** Bytes of embedded base64 blobs + HTML passed as string props/dangerous html. */
for (const blk of typeBlocks) { export function inlineBlobBytes(text: string): number {
const re = /(\w+)\s*\??\s*:/g; let m: RegExpExecArray | null; let bytes = 0;
while ((m = re.exec(blk)) !== null) out.push(m[1]!); // base64 data URIs — the payload after the comma.
for (const m of text.matchAll(/data:[^;,'"`)\s]*;base64,([A-Za-z0-9+/=]+)/g)) bytes += m[1]!.length;
// dangerouslySetInnerHTML / html-string props: a big string literal handed to markup.
for (const m of text.matchAll(/dangerouslySetInnerHTML/g)) void m;
// A very long string literal that is actually markup ("<div ...</div>" as a prop).
for (const m of text.matchAll(/"((?:[^"\\]|\\.){2000,})"/g)) {
const s = m[1]!;
if (/<[a-z][a-z0-9]*[\s>]/i.test(s) && (s.match(/</g)?.length ?? 0) > 20) bytes += s.length;
} }
return out; return bytes;
}
/** Payload facts about one file. */
export function filePayload(f: SrcFile): { bytes: number; maxLine: number; inlineBytes: number } {
return { bytes: f.text.length, maxLine: maxLineLen(f.text), inlineBytes: inlineBlobBytes(f.text) };
}
/** {" "} / {' '} whitespace-only JSX expression literals (a capture artifact). */
export function whitespaceLiterals(text: string): number {
return (text.match(/\{\s*["'`]\s+["'`]\s*\}/g) ?? []).length
+ (text.match(/\{["'`]\\u00a0["'`]\}/gi) ?? []).length;
}
/** Frozen sub-pixel arbitrary values: `[12.5px]`, `w-[713.938px]` where px isn't whole. */
export function subpixelArbitraries(text: string): number {
let n = 0;
for (const m of text.matchAll(/\[(-?[0-9]+\.?[0-9]*)(px|rem)\]/g)) {
const px = parseFloat(m[1]!) * (m[2] === "rem" ? 16 : 1);
if (Math.abs(px - Math.round(px)) > 0.02) n++;
}
return n;
}
/** CSS custom-property definitions split into opaque (--clr-7, --c12, hashes) vs named. */
export function customPropTokens(text: string): { total: number; opaque: number } {
let total = 0, opaque = 0;
for (const m of text.matchAll(/(^|[^\w-])(--[\w-]+)\s*:/g)) {
const name = m[2]!.slice(2); // strip leading --
total++;
if (/^(clr|c|color|var|token|t|v|n|x)?-?\d+$/i.test(name) || /^[a-f0-9]{6,}$/i.test(name)) opaque++;
}
return { total, opaque };
}
/** Off-screen capture-probe text leaks (measurement scaffolding shipped into markup):
* probe data-attrs, __probe__ markers, and clip:rect(0) off-screen clipping. */
export function probeArtifacts(text: string): number {
return (text.match(/(?:data-(?:probe|measure|ditto-probe)|__probe__|offscreen-probe|clip:\s*["'`]?\s*rect\(\s*0)/g) ?? []).length;
}
/** addEventListener calls with no matching removeEventListener / cleanup return. */
export function uncleanedListeners(text: string): number {
const adds = (text.match(/\.addEventListener\s*\(/g) ?? []).length;
const removes = (text.match(/\.removeEventListener\s*\(/g) ?? []).length;
// A cleanup return (useEffect teardown / AbortController) neutralizes some adds.
const hasCleanupReturn = /return\s*\(\s*\)\s*=>/.test(text) || /new AbortController/.test(text) || /\{\s*signal\s*\}/.test(text);
const covered = hasCleanupReturn ? Math.max(removes, adds) : removes;
return Math.max(0, adds - covered);
}
/** Element-tag histogram for a whole tree of markup text. */
export function tagHistogram(text: string): Record<string, number> {
const h: Record<string, number> = {};
for (const m of text.matchAll(/<([a-z][a-z0-9]*)(?:\s|\/|>)/g)) {
const t = m[1]!;
h[t] = (h[t] ?? 0) + 1;
}
return h;
}
/** aria-hidden applied to a focusable/interactive element in the same tag. */
export function ariaHiddenFocusables(text: string): number {
let n = 0;
for (const m of text.matchAll(/<(a|button|input|select|textarea)\b[^>]*aria-hidden\s*=\s*["'{]?\s*true/gi)) void m;
n += (text.match(/<(?:a|button|input|select|textarea)\b[^>]*aria-hidden\s*=\s*["'{]?\s*true/gi) ?? []).length;
return n;
}
/** Repeated helper/function definitions with identical bodies (copy-paste, not shared). */
export function duplicateHelpers(text: string): { defs: number; dups: number } {
const bodies = new Map<string, number>();
// named function / arrow-const helper definitions with a brace body.
const re = /(?:function\s+\w+\s*\([^)]*\)|const\s+\w+\s*=\s*(?:\([^)]*\)|\w+)\s*=>)\s*\{([\s\S]{40,600}?)\}/g;
let m: RegExpExecArray | null;
let defs = 0;
while ((m = re.exec(text)) !== null) {
defs++;
const key = m[1]!.replace(/\s+/g, " ").trim();
bodies.set(key, (bodies.get(key) ?? 0) + 1);
}
let dups = 0;
for (const c of bodies.values()) if (c > 1) dups += c - 1;
return { defs, dups };
}
/** Inline SVG <path d="…"> strings, and how many are exact repeats. */
export function svgPathDuplication(text: string): { total: number; repeats: number } {
const seen = new Map<string, number>();
let total = 0;
for (const m of text.matchAll(/\bd\s*=\s*["']([Mm][^"']{20,})["']/g)) {
total++;
const key = m[1]!;
seen.set(key, (seen.get(key) ?? 0) + 1);
}
let repeats = 0;
for (const c of seen.values()) if (c > 1) repeats += c - 1;
return { total, repeats };
}
/** Near-identical component pairs: same tag-histogram signature across component files. */
export function nearDuplicateComponents(sigs: string[]): number {
const counts = new Map<string, number>();
for (const s of sigs) counts.set(s, (counts.get(s) ?? 0) + 1);
let pairs = 0;
for (const c of counts.values()) if (c > 1) pairs += c - 1;
return pairs;
} }
const PLUMBING_FIELD_RE = /^(_cid\d*|cid\d*|value\d*|val\d*|f\d+|d\d+|field\d+|key\d*|prop\d+|arg\d+)$/;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Scoring // Grading
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export type LetterGrade = "A" | "A-" | "B+" | "B" | "B-" | "C+" | "C" | "C-" | "D+" | "D" | "D-" | "F";
export function toLetter(total: number): LetterGrade {
if (total >= 93) return "A";
if (total >= 90) return "A-";
if (total >= 87) return "B+";
if (total >= 83) return "B";
if (total >= 80) return "B-";
if (total >= 77) return "C+";
if (total >= 73) return "C";
if (total >= 70) return "C-";
if (total >= 66) return "D+";
if (total >= 63) return "D";
if (total >= 60) return "D-";
return "F";
}
export type CategoryScore = { score: number; max: number; metrics: Record<string, number | string> }; export type CategoryScore = { score: number; max: number; metrics: Record<string, number | string> };
export type Offender = { file: string; metric: string; value: number };
export type QualityReport = { export type QualityReport = {
dir: string; dir: string;
total: number; total: number;
grade: LetterGrade;
caps: string[];
categories: Record<string, CategoryScore>; categories: Record<string, CategoryScore>;
offenders: Offender[];
raw: Record<string, number>; raw: Record<string, number>;
}; };
const clamp01 = (x: number): number => Math.max(0, Math.min(1, x)); /** Dimension weights (sum = 100). Guiding, not exact see CALIBRATION note.
/** Linear ramp: value v mapped from [lo,hi] → [0,1]. */ * Payload + decomposition carry the most weight because an unopenable, monolithic
const ramp = (v: number, lo: number, hi: number): number => clamp01((v - lo) / (hi - lo)); * file is the defect a reviewer notices first; hygiene/duplication are lighter
const r1 = (n: number): number => Math.round(n * 10) / 10; * because this converter's baseline (some {" "}, shared icons) is tolerable. */
const WEIGHTS = {
payload: 26,
decomposition: 18,
duplication: 12,
semantics: 18,
hygiene: 14,
runtime: 12,
} as const;
export function scoreApp(root: string): QualityReport { export function scoreApp(root: string): QualityReport {
const files = collectFiles(root); const files = collectFiles(root);
const tsx = files.filter((f) => f.ext === ".tsx" || f.ext === ".jsx" || f.ext === ".astro"); const markup = files.filter((f) => f.ext === ".tsx" || f.ext === ".jsx" || f.ext === ".astro");
const css = files.filter((f) => f.ext === ".css"); const css = files.filter((f) => f.ext === ".css");
const runtimeFiles = files.filter((f) => f.ext === ".ts" || f.ext === ".tsx" || f.ext === ".jsx");
// Component modules = JSX-bearing files that aren't config/root-layout. const componentModules = markup.filter((f) =>
const componentModules = tsx.filter((f) =>
!CONFIG_NAME_RE.test(f.rel) && !ROOT_NAME_RE.test(f.rel) && countTags(f.text) > 0); !CONFIG_NAME_RE.test(f.rel) && !ROOT_NAME_RE.test(f.rel) && countTags(f.text) > 0);
const nonEntry = componentModules.filter((f) => !isEntryFile(f.rel)); const nonEntry = componentModules.filter((f) => !isEntryFile(f.rel));
const allText = files.map((f) => f.text).join("\n");
const markupText = markup.map((f) => f.text).join("\n");
const totalTags = componentModules.reduce((s, f) => s + countTags(f.text), 0) || 1; const totalTags = componentModules.reduce((s, f) => s + countTags(f.text), 0) || 1;
const entryFiles = componentModules.filter((f) => isEntryFile(f.rel));
const maxFileTags = Math.max(0, ...componentModules.map((f) => countTags(f.text))); const maxFileTags = Math.max(0, ...componentModules.map((f) => countTags(f.text)));
const dominance = maxFileTags / totalTags; // 1.0 = one giant file const kTags = totalTags / 1000;
// Section-named components (semantic top-level blocks). Tokenize the file path + const offenders: Offender[] = [];
// component names into words (splitting camelCase, kebab, snake, slashes) so both const caps: string[] = [];
// `hero-section.tsx` and `HeroSection` are credited.
const isSectionFile = (f: SrcFile): boolean => { // =========================================================================
const raw = f.rel + " " + componentNames(f.text).join(" "); // 1. PAYLOAD DISCIPLINE — no file/line/blob a human cannot open.
const words = new Set(raw.replace(/([a-z0-9])([A-Z])/g, "$1 $2").toLowerCase().split(/[^a-z0-9]+/).filter(Boolean)); // =========================================================================
return SECTION_WORDS.some((w) => words.has(w)); let worstFileBytes = 0, worstFileRel = "";
let worstLine = 0, worstLineRel = "";
let totalInlineBytes = 0, worstInlineRel = "", worstInlineBytes = 0;
for (const f of files) {
const p = filePayload(f);
if (p.bytes > worstFileBytes) { worstFileBytes = p.bytes; worstFileRel = f.rel; }
if (p.maxLine > worstLine) { worstLine = p.maxLine; worstLineRel = f.rel; }
totalInlineBytes += p.inlineBytes;
if (p.inlineBytes > worstInlineBytes) { worstInlineBytes = p.inlineBytes; worstInlineRel = f.rel; }
}
if (worstFileRel) offenders.push({ file: worstFileRel, metric: "file bytes", value: worstFileBytes });
if (worstLineRel) offenders.push({ file: worstLineRel, metric: "max line chars", value: worstLine });
if (worstInlineBytes > 0) offenders.push({ file: worstInlineRel, metric: "inline blob bytes", value: worstInlineBytes });
// Worst-file penalty, tempered by how WIDESPREAD oversized files are: one big section
// in an otherwise-small tree is forgivable; many oversized files is systemic bloat.
const oversized = files.filter((f) => f.text.length >= K.FILE_BYTES_GOOD * 2).length;
const pFileMax = penalize(worstFileBytes, K.FILE_BYTES_GOOD, K.FILE_BYTES_BAD);
const pFileSpread = penalize(oversized, 0, Math.max(3, files.length * 0.25));
const pFile = 0.6 * pFileMax + 0.4 * pFileSpread;
const pLine = penalize(worstLine, K.LINE_CHARS_GOOD, K.LINE_CHARS_BAD);
const pInline = penalize(totalInlineBytes, K.INLINE_BYTES_GOOD, K.INLINE_BYTES_BAD);
const payloadSub = 0.4 * pFile + 0.35 * pLine + 0.25 * pInline;
// =========================================================================
// 2. COMPONENT DECOMPOSITION — is the page a monolith?
// =========================================================================
const dominance = maxFileTags / totalTags; // 1.0 = one giant file holds the page
const bigSections = componentModules.filter((f) => f.lines >= K.BIG_SECTION_LINES && !isEntryFile(f.rel)).length;
for (const f of componentModules) {
if (f.lines >= K.BIG_SECTION_LINES && !isEntryFile(f.rel)) offenders.push({ file: f.rel, metric: "section LOC", value: f.lines });
}
const dMono = penalize(dominance, K.MONOLITH_DOMINANCE_GOOD, K.MONOLITH_DOMINANCE_BAD);
const dCount = ramp(nonEntry.length, 0, K.COMPONENTS_GOOD);
const dBig = penalize(bigSections, 0, 3);
const decompositionSub = 0.45 * dMono + 0.3 * dCount + 0.25 * dBig;
// =========================================================================
// 3. DUPLICATION — repeated helpers, near-identical components, repeated SVG paths.
// =========================================================================
let helperDefs = 0, helperDups = 0;
for (const f of runtimeFiles) { const d = duplicateHelpers(f.text); helperDefs += d.defs; helperDups += d.dups; }
const svgDup = svgPathDuplication(markupText);
// Near-dup signatures over NON-trivial CONTENT components only: vectorized assets
// (icons, logos, illustration frames) legitimately share a tag shape, so counting
// them as "duplicates" would punish healthy asset sets rather than real copy-paste.
const isAssetFile = (rel: string): boolean => /(^|\/)svgs?\//i.test(rel) || /(icon|illustration|logo)\d*\.[jt]sx$/i.test(basename(rel));
const compSigs = nonEntry.filter((f) => !isAssetFile(f.rel)).map((f) => {
const h = tagHistogram(f.text);
const tags = Object.values(h).reduce((a, b) => a + b, 0);
if (tags < K.NEAR_DUP_MIN_TAGS) return "";
return Object.keys(h).sort().map((k) => `${k}:${h[k]}`).join(",");
}).filter((s) => s.length > 0);
const nearDup = nearDuplicateComponents(compSigs);
const helperDupRatio = helperDefs ? helperDups / helperDefs : 0;
const svgDupRatio = svgDup.total ? svgDup.repeats / svgDup.total : 0;
if (helperDups > 0) offenders.push({ file: "(helpers)", metric: "duplicate helper defs", value: helperDups });
if (svgDup.repeats > 0) offenders.push({ file: "(inline svg)", metric: "repeated <path> strings", value: svgDup.repeats });
if (nearDup > 0) offenders.push({ file: "(components)", metric: "near-identical component pairs", value: nearDup });
const uHelper = penalize(helperDupRatio, 0, K.DUP_HELPER_RATIO_BAD);
const uSvg = penalize(svgDupRatio, 0, K.DUP_SVG_PATH_RATIO_BAD);
const uNear = penalize(nearDup, 0, K.NEAR_DUP_COMPONENT_BAD);
// Helper duplication is the strongest tell; near-dup components second; inline-SVG
// path repeats are the weakest (shared icon sets repeat legitimately).
const duplicationSub = 0.5 * uHelper + 0.35 * uNear + 0.15 * uSvg;
// =========================================================================
// 4. SEMANTICS / A11Y — real tags over div soup, one h1, alt coverage, no aria traps.
// =========================================================================
const hist = tagHistogram(markupText);
const totalEls = Object.values(hist).reduce((a, b) => a + b, 0) || 1;
const divLike = (hist["div"] ?? 0) + (hist["span"] ?? 0);
const semanticTags = (hist["section"] ?? 0) + (hist["nav"] ?? 0) + (hist["header"] ?? 0) + (hist["footer"] ?? 0)
+ (hist["main"] ?? 0) + (hist["article"] ?? 0) + (hist["aside"] ?? 0) + (hist["button"] ?? 0)
+ (hist["h1"] ?? 0) + (hist["h2"] ?? 0) + (hist["h3"] ?? 0) + (hist["ul"] ?? 0) + (hist["nav"] ?? 0);
const h1Count = hist["h1"] ?? 0;
const imgCount = hist["img"] ?? 0;
const altCount = (markupText.match(/\balt\s*=/g) ?? []).length;
const ariaHidden = ariaHiddenFocusables(markupText);
const divRatio = divLike / totalEls;
const altCoverage = imgCount ? clamp01(altCount / imgCount) : 1;
if (h1Count === 0) offenders.push({ file: "(page)", metric: "h1 count", value: 0 });
if (ariaHidden > 0) offenders.push({ file: "(markup)", metric: "aria-hidden on focusables", value: ariaHidden });
if (imgCount && altCoverage < K.ALT_COVERAGE_GOOD) offenders.push({ file: "(markup)", metric: "imgs missing alt", value: imgCount - altCount });
const aDiv = penalize(divRatio, K.DIV_RATIO_GOOD, K.DIV_RATIO_BAD);
const aSem = clamp01(semanticTags / (totalEls * 0.12)); // ~12% semantic tags → full credit
const aH1 = h1Count === K.H1_REQUIRED ? 1 : h1Count > K.H1_REQUIRED ? 0.8 : 0.1; // missing h1 is a heavy penalty; multiple h1 a minor ding
const aAlt = altCoverage;
const aAria = penalize(ariaHidden, 0, 6);
const semanticsSub = 0.3 * aDiv + 0.2 * aSem + 0.25 * aH1 + 0.15 * aAlt + 0.1 * aAria;
// =========================================================================
// 5. HYGIENE — whitespace literals, frozen sub-pixels, opaque tokens, probe leaks.
// =========================================================================
const wsLiterals = whitespaceLiterals(markupText);
const subpixel = subpixelArbitraries(allText);
let propTotal = 0, propOpaque = 0;
for (const f of css) { const t = customPropTokens(f.text); propTotal += t.total; propOpaque += t.opaque; }
const probes = probeArtifacts(allText);
const opaqueRatio = propTotal ? propOpaque / propTotal : 0;
const wsPerK = wsLiterals / Math.max(1, kTags);
const subpixelPerK = subpixel / Math.max(1, kTags);
if (wsLiterals > 0) offenders.push({ file: "(markup)", metric: "{\" \"} whitespace literals", value: wsLiterals });
if (subpixel > 0) offenders.push({ file: "(styles)", metric: "frozen sub-pixel arbitraries", value: subpixel });
if (propOpaque > 0) offenders.push({ file: "(css)", metric: "opaque --token defs", value: propOpaque });
if (probes > 0) offenders.push({ file: "(markup)", metric: "capture-probe artifacts", value: probes });
const hWs = penalize(wsPerK, K.WS_LITERAL_PER_KTAG_GOOD, K.WS_LITERAL_PER_KTAG_BAD);
const hSub = penalize(subpixelPerK, K.SUBPIXEL_PER_KTAG_GOOD, K.SUBPIXEL_PER_KTAG_BAD);
const hTok = penalize(opaqueRatio, 0, K.OPAQUE_TOKEN_RATIO_BAD);
const hProbe = penalize(probes, 0, K.PROBE_ARTIFACTS_BAD);
const hygieneSub = 0.28 * hWs + 0.28 * hSub + 0.28 * hTok + 0.16 * hProbe;
// =========================================================================
// 6. RUNTIME DISCIPLINE — no leaked listeners, no undeclared imports.
// =========================================================================
let leaks = 0;
for (const f of runtimeFiles) leaks += uncleanedListeners(f.text);
const undeclaredImports = countUndeclaredImports(runtimeFiles);
if (leaks > 0) offenders.push({ file: "(runtime)", metric: "uncleaned addEventListener", value: leaks });
if (undeclaredImports > 0) offenders.push({ file: "(runtime)", metric: "undeclared imports", value: undeclaredImports });
const rLeak = penalize(leaks, K.LEAK_LISTENERS_GOOD, K.LEAK_LISTENERS_BAD);
const rImp = penalize(undeclaredImports, 0, 6);
const runtimeSub = 0.7 * rLeak + 0.3 * rImp;
// =========================================================================
// BLEND + HARD CAPS
// =========================================================================
const dims = {
payload: payloadSub,
decomposition: decompositionSub,
duplication: duplicationSub,
semantics: semanticsSub,
hygiene: hygieneSub,
runtime: runtimeSub,
}; };
const sectionComponents = nonEntry.filter(isSectionFile).length; let total = 0;
const svgFiles = componentModules.filter((f) => /(^|\/)svgs?\//i.test(f.rel) || /icon/i.test(basename(f.rel)) || (countTags(f.text) > 0 && /^[^<]*<svg/m.test(f.text.replace(/import[^\n]*\n/g, "")))).length; for (const [k, sub] of Object.entries(dims)) total += WEIGHTS[k as keyof typeof WEIGHTS] * sub;
// ---- naming ---- // Catastrophe caps: any ONE unopenable payload drags the whole grade into D-range.
const allComponentNames = new Set<string>(); if (worstFileBytes >= K.CATASTROPHE_FILE_BYTES) caps.push(`file ${worstFileRel} is ${(worstFileBytes / 1e6).toFixed(1)}MB (>1MB)`);
for (const f of componentModules) for (const n of componentNames(f.text)) allComponentNames.add(n); if (worstLine >= K.CATASTROPHE_LINE_CHARS) caps.push(`line in ${worstLineRel} is ${(worstLine / 1000).toFixed(0)}KB (>100KB)`);
// exclude the page/Page entry symbol from the semantic judgement if (totalInlineBytes >= K.CATASTROPHE_INLINE_BYTES) caps.push(`${(totalInlineBytes / 1e6).toFixed(1)}MB of embedded base64/HTML (>5MB)`);
const judgedNames = [...allComponentNames].filter((n) => n !== "Page" && n !== "default" && n !== "RootLayout" && n !== "Layout"); if (caps.length) total = Math.min(total, K.CAP_GRADE_D);
const genericNames = judgedNames.filter((n) => GENERIC_COMP_RE.test(n)).length; else {
const compNameSemanticRatio = judgedNames.length ? 1 - genericNames / judgedNames.length : 0; // Softer warning band.
if (worstFileBytes >= K.WARN_FILE_BYTES) caps.push(`file ${worstFileRel} is ${(worstFileBytes / 1000).toFixed(0)}KB (>${K.WARN_FILE_BYTES / 1000}KB)`);
const allClassToks: string[] = []; if (worstLine >= K.WARN_LINE_CHARS) caps.push(`line in ${worstLineRel} is ${(worstLine / 1000).toFixed(0)}KB (>${K.WARN_LINE_CHARS / 1000}KB)`);
for (const f of tsx) allClassToks.push(...classTokens(f.text)); if (caps.length) total = Math.min(total, K.CAP_GRADE_C);
const classTotal = allClassToks.length || 1;
const opaqueClasses = allClassToks.filter(isOpaqueClass).length;
const classSemanticRatio = 1 - opaqueClasses / classTotal;
const distinctClasses = new Set(allClassToks).size || 1;
const classReuse = 1 - distinctClasses / classTotal; // 0 = every class used once (per-node), →1 = heavy reuse
// content field naming + editability
const contentFiles = files.filter((f) => /(content|data)\.[jt]s$/i.test(basename(f.rel)) || /(^|\/)(content|lib)\//i.test(f.rel));
const fieldNames: string[] = [];
for (const f of contentFiles) fieldNames.push(...contentFieldNames(f.text));
const plumbingFields = fieldNames.filter((n) => PLUMBING_FIELD_RE.test(n)).length;
const fieldSemanticRatio = fieldNames.length ? 1 - plumbingFields / fieldNames.length : 0.5;
// ---- styling system ----
const cssBytes = css.reduce((s, f) => s + f.text.length, 0);
const cssBytesPerNode = cssBytes / totalTags;
let tokenRefs = 0, tokenDefs = 0;
for (const f of css) {
tokenRefs += (f.text.match(/var\(--/g) ?? []).length;
tokenDefs += (f.text.match(/^\s*--[\w-]+\s*:/gm) ?? []).length;
} }
// Tailwind-style theme tokens count as token usage too (utility classes referencing roles) total = r1(Math.max(0, Math.min(100, total)));
const tailwindTokenClasses = allClassToks.filter((t) => /(^|[-:])(bg|text|border|fill|stroke|ring|from|to|via)-/.test(t)).length;
// per-node-unique-rule penalty: count `.cNNN{` selectors in CSS
let perNodeRules = 0;
for (const f of css) perNodeRules += (f.text.match(/\.c[a-z]?\d+\s*[\{,]/g) ?? []).length;
const perNodeRuleRatio = perNodeRules / totalTags; // 1.0 = a unique rule per node
// magic literals in component markup (raw hex/rgb/px in className/style strings) // Order offenders by severity so the report's "top offenders" is meaningful.
let magicLiterals = 0; offenders.sort((a, b) => severity(b) - severity(a));
for (const f of componentModules) {
magicLiterals += (f.text.match(/#[0-9a-fA-F]{3,8}\b/g) ?? []).length;
magicLiterals += (f.text.match(/\brgba?\([^)]*\)/g) ?? []).length;
}
const magicPerNode = magicLiterals / totalTags;
// ---- idiomatic styling (the axes the old rubric was blind to) ----
// A px ARBITRARY value (`w-[713.938px]`, `py-[64px]`) is a measurement, not a design choice —
// the single biggest "machine-generated" tell. Count them across markup + hoisted class
// consts, vs the standard scale / rem a human writes.
let arbPx = 0, arbBands = 0, stdBreakpoints = 0, dataCidCount = 0;
for (const f of [...componentModules, ...css]) {
arbPx += (f.text.match(/\[[0-9]+(?:\.[0-9]+)?px\]/g) ?? []).length;
arbBands += (f.text.match(/(?:min|max)-\[[0-9]+px\]:/g) ?? []).length; // arbitrary midpoint media variants
stdBreakpoints += (f.text.match(/(?:^|[\s"'`])(?:max-)?(?:sm|md|lg|xl|2xl):/g) ?? []).length;
dataCidCount += (f.text.match(/data-cid/g) ?? []).length;
}
const arbPxPerNode = arbPx / totalTags; // low is hand-authored; high is machine replay
const bandShare = arbBands + stdBreakpoints > 0 ? arbBands / (arbBands + stdBreakpoints) : 0; // 1 = all arbitrary
const dataCidPerNode = dataCidCount / totalTags;
// Robotic, never-hand-written comment phrases.
let roboticComments = 0;
for (const f of [...componentModules, ...css, ...files.filter((x) => x.ext === ".ts")]) {
roboticComments += (f.text.match(/Generated by clone-static|render-identical to the source|Do not edit by hand/g) ?? []).length;
}
// ---- editability ----
const hasContentModule = contentFiles.some((f) => /export\s+(const|type)/.test(f.text)) ? 1 : 0;
// Destructured props with a default value, in any function/arrow param block — counts
// string, array, and identifier defaults alike (a default is a default).
let propsWithDefaults = 0;
for (const f of componentModules) {
for (const blk of f.text.match(/\(\s*\{[^{}]*\}/g) ?? []) {
propsWithDefaults += (blk.match(/\b\w+\s*=\s*(?![=>])/g) ?? []).length;
}
}
const defaultsSignal = clamp01(propsWithDefaults / Math.max(6, nonEntry.length));
// ---- file org ----
const dirSet = new Set(componentModules.map((f) => f.rel.split("/").slice(0, -1).join("/")));
const hasSectionsDir = [...dirSet].some((d) => /sections?$/i.test(d)) ? 1 : 0;
const hasComponentsDir = [...dirSet].some((d) => /(components|ui)$/i.test(d)) ? 1 : 0;
const hasLayoutDir = [...dirSet].some((d) => /(layout|svgs?)$/i.test(d)) ? 1 : 0;
const orgDirs = hasSectionsDir + hasComponentsDir + hasLayoutDir;
// ---- metadata ----
let docHeaders = 0, annotations = 0;
for (const f of componentModules) {
docHeaders += (f.text.match(/\/\*\*[\s\S]*?\*\//g) ?? []).length;
// component-type annotations in attribute, object, or doc-tag form.
annotations += (f.text.match(/@component|data-component/g) ?? []).length;
}
// =========================================================================
// Category scoring
// =========================================================================
// 1. Componentization (25): many components, low single-file dominance, sections present
const cCount = ramp(nonEntry.length, 0, 12); // 0 → 12+ components
const cDom = 1 - ramp(dominance, 0.3, 0.95); // penalize one giant file
const cSections = ramp(sectionComponents, 0, 6); // semantic sections
const componentization = 25 * (0.4 * cCount + 0.35 * cDom + 0.25 * cSections);
// 2. Semantic naming (25): component names, class names, content fields
const naming = 25 * (0.4 * compNameSemanticRatio + 0.4 * classSemanticRatio + 0.2 * fieldSemanticRatio);
// 3. Styling system (20): class reuse, tokens, low per-node verbosity, few magic literals AND
// — the axes the old rubric missed — idiomatic values (standard scale/rem, not a wall of
// px arbitraries) and standard breakpoints (not arbitrary midpoint bands).
const sReuse = clamp01(classReuse / 0.6); // 0.6+ reuse → full marks
const sTokens = ramp(tokenRefs + tailwindTokenClasses, 0, Math.max(40, totalTags * 0.5));
const sVerbosity = 1 - clamp01(perNodeRuleRatio); // per-node rules are bad
const sMagic = 1 - clamp01(magicPerNode / 0.25);
const sArb = 1 - clamp01(arbPxPerNode / 2); // ~0 arb-px/node → 1; 2+/node → 0
const sBp = 1 - bandShare; // 0 arbitrary bands → 1
const styling = 20 * (0.22 * sReuse + 0.13 * sTokens + 0.13 * sVerbosity + 0.07 * sMagic + 0.27 * sArb + 0.18 * sBp);
// 4. Editability (15): content module, typed fields, semantic fields, prop defaults
const editability = 15 * (0.4 * hasContentModule + 0.3 * fieldSemanticRatio + 0.3 * defaultsSignal);
// 5. File org (10): folder structure + svgs extracted
const fileOrg = 10 * (0.7 * (orgDirs / 3) + 0.3 * clamp01(svgFiles / 4));
// 6. Metadata (5): concise human doc headers + semantic component annotations — but a
// "Generated by clone-static / render-identical" robotic header is a NEGATIVE signal (no
// human writes it), and shipping a per-node data-cid on every element is markup noise.
const cleanComments = roboticComments === 0 ? 1 : clamp01(1 - roboticComments / Math.max(4, nonEntry.length));
const lowCidNoise = 1 - clamp01(dataCidPerNode); // ~0 data-cid/node → 1; 1/node → 0
const metadata = 5 * (0.35 * clamp01(docHeaders / Math.max(4, nonEntry.length)) * cleanComments
+ 0.35 * clamp01(annotations / Math.max(8, totalTags * 0.1))
+ 0.3 * lowCidNoise);
const total = componentization + naming + styling + editability + fileOrg + metadata;
return { return {
dir: root, dir: root,
total: r1(total), total,
grade: toLetter(total),
caps,
categories: { categories: {
componentization: { score: r1(componentization), max: 25, metrics: { components: nonEntry.length, dominancePct: r1(dominance * 100), sectionComponents } }, payload: { score: r1(WEIGHTS.payload * payloadSub), max: WEIGHTS.payload, metrics: { maxFileKB: r1(worstFileBytes / 1000), maxLineKB: r1(worstLine / 1000), inlineBlobKB: r1(totalInlineBytes / 1000) } },
naming: { score: r1(naming), max: 25, metrics: { compNameSemanticPct: r1(compNameSemanticRatio * 100), classSemanticPct: r1(classSemanticRatio * 100), fieldSemanticPct: r1(fieldSemanticRatio * 100), genericNames } }, decomposition: { score: r1(WEIGHTS.decomposition * decompositionSub), max: WEIGHTS.decomposition, metrics: { components: nonEntry.length, dominancePct: r1(dominance * 100), bigSections } },
styling: { score: r1(styling), max: 20, metrics: { classReusePct: r1(classReuse * 100), tokenRefs, perNodeRules, magicLiterals, arbPx, arbPxPerNode: r1(arbPxPerNode), arbBands, stdBreakpoints, bandSharePct: r1(bandShare * 100) } }, duplication: { score: r1(WEIGHTS.duplication * duplicationSub), max: WEIGHTS.duplication, metrics: { helperDups, helperDupPct: r1(helperDupRatio * 100), svgPathRepeats: svgDup.repeats, nearDupPairs: nearDup } },
editability: { score: r1(editability), max: 15, metrics: { hasContentModule, fieldSemanticPct: r1(fieldSemanticRatio * 100), propsWithDefaults } }, semantics: { score: r1(WEIGHTS.semantics * semanticsSub), max: WEIGHTS.semantics, metrics: { h1: h1Count, divRatioPct: r1(divRatio * 100), semanticTags, altCoveragePct: r1(altCoverage * 100), ariaHiddenFocusables: ariaHidden } },
fileOrg: { score: r1(fileOrg), max: 10, metrics: { orgDirs, svgFiles, hasSectionsDir, hasComponentsDir } }, hygiene: { score: r1(WEIGHTS.hygiene * hygieneSub), max: WEIGHTS.hygiene, metrics: { wsLiterals, subpixelArbitraries: subpixel, opaqueTokenPct: r1(opaqueRatio * 100), probeArtifacts: probes } },
metadata: { score: r1(metadata), max: 5, metrics: { docHeaders, annotations, roboticComments, dataCidPerNode: r1(dataCidPerNode) } }, runtime: { score: r1(WEIGHTS.runtime * runtimeSub), max: WEIGHTS.runtime, metrics: { uncleanedListeners: leaks, undeclaredImports } },
}, },
offenders: offenders.slice(0, 15),
raw: { raw: {
files: files.length, componentModules: componentModules.length, totalTags, maxFileTags, files: files.length, componentModules: componentModules.length, totalTags, maxFileTags,
classTokens: classTotal, distinctClasses, opaqueClasses, cssBytes, worstFileBytes, worstLineChars: worstLine, inlineBytes: totalInlineBytes,
}, },
}; };
} }
/** Undeclared imports: a bare-specifier import not present in any package.json/tsconfig
* alias and not a relative path a runtime crash waiting to happen. We approximate by
* flagging imports of local-looking modules that resolve nowhere is expensive, so instead
* we flag the cheap, robust tell: an import statement whose source is an empty string or a
* malformed specifier. (Kept conservative to avoid false positives across frameworks.) */
function countUndeclaredImports(files: SrcFile[]): number {
let n = 0;
for (const f of files) {
for (const m of f.text.matchAll(/\bimport\s+[^;]*?\bfrom\s*["'`]([^"'`]*)["'`]/g)) {
const spec = m[1]!;
if (spec.trim() === "" || /\s/.test(spec) || spec === "undefined" || spec === "null") n++;
}
}
return n;
}
/** Relative weight of an offender for ordering the top-offenders list. */
function severity(o: Offender): number {
switch (o.metric) {
case "file bytes": return o.value / 1000;
case "max line chars": return o.value / 1000;
case "inline blob bytes": return o.value / 2000;
case "section LOC": return o.value / 20;
case "opaque --token defs": return o.value * 0.5;
case "frozen sub-pixel arbitraries": return o.value * 0.3;
case "{\" \"} whitespace literals": return o.value * 0.1;
case "h1 count": return 300; // a missing h1 is always a headline offender
case "uncleaned addEventListener": return o.value * 3;
default: return o.value;
}
}
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
function fmt(rep: QualityReport): string { function fmt(rep: QualityReport): string {
const lines: string[] = []; const lines: string[] = [];
lines.push(`\n ${rep.dir}`); lines.push(`\n ${rep.dir}`);
lines.push(` TOTAL: ${rep.total}/100`); lines.push(` GRADE: ${rep.grade} (${rep.total}/100)`);
if (rep.caps.length) lines.push(` CAPS: ${rep.caps.join("; ")}`);
for (const [k, c] of Object.entries(rep.categories)) { for (const [k, c] of Object.entries(rep.categories)) {
const metrics = Object.entries(c.metrics).map(([mk, mv]) => `${mk}=${mv}`).join(" "); const metrics = Object.entries(c.metrics).map(([mk, mv]) => `${mk}=${mv}`).join(" ");
lines.push(` ${k.padEnd(16)} ${String(c.score).padStart(5)}/${c.max} ${metrics}`); lines.push(` ${k.padEnd(15)} ${String(c.score).padStart(5)}/${c.max} ${metrics}`);
}
if (rep.offenders.length) {
lines.push(` top offenders:`);
for (const o of rep.offenders.slice(0, 8)) lines.push(` ${o.metric.padEnd(34)} ${String(o.value).padStart(10)} ${o.file}`);
} }
return lines.join("\n"); return lines.join("\n");
} }
@@ -353,11 +614,11 @@ async function main(): Promise<void> {
for (const rep of reports) console.log(fmt(rep)); for (const rep of reports) console.log(fmt(rep));
if (reports.length > 1) { if (reports.length > 1) {
console.log("\n ── comparison ──"); console.log("\n ── comparison ──");
console.log(" " + "category".padEnd(16) + reports.map((_, i) => `app${i + 1}`.padStart(10)).join("")); console.log(" " + "dimension".padEnd(15) + reports.map((_, i) => `app${i + 1}`.padStart(10)).join(""));
for (const cat of Object.keys(reports[0]!.categories)) { for (const cat of Object.keys(reports[0]!.categories)) {
console.log(" " + cat.padEnd(16) + reports.map((r) => String(r.categories[cat]!.score).padStart(10)).join("")); console.log(" " + cat.padEnd(15) + reports.map((r) => String(r.categories[cat]!.score).padStart(10)).join(""));
} }
console.log(" " + "TOTAL".padEnd(16) + reports.map((r) => String(r.total).padStart(10)).join("")); console.log(" " + "GRADE".padEnd(15) + reports.map((r) => `${r.grade}(${r.total})`.padStart(10)).join(""));
} }
} }
+18 -9
View File
@@ -15,7 +15,7 @@ import { generateCss, RESET_CSS } from "../generate/css.js";
import { generateInteractionCss } from "../generate/interactionCss.js"; import { generateInteractionCss } from "../generate/interactionCss.js";
import { buildRuntimeSpecs, wiresJsx, dittoWireImportPath, DITTO_WIRE_TSX, interactionRejectedSet } from "../generate/interactive.js"; import { buildRuntimeSpecs, wiresJsx, dittoWireImportPath, DITTO_WIRE_TSX, interactionRejectedSet } from "../generate/interactive.js";
import { buildLottieSpec, lottieHasContent, lottieWireJsx, dittoLottieImportPath, DITTO_LOTTIE_TSX } from "../generate/lottie.js"; import { buildLottieSpec, lottieHasContent, lottieWireJsx, dittoLottieImportPath, DITTO_LOTTIE_TSX } from "../generate/lottie.js";
import { renderChildrenJsx, renderAttrs, buildComponentRegistry, componentPreamble, componentFiles, componentImports, componentDataDecls, summarizeComponents, fileBase, generateViteConfig, generateViteIndexHtml, viteGlobalsCss, PACKAGE_JSON, PACKAGE_JSON_TW, PACKAGE_JSON_VITE, PACKAGE_JSON_VITE_TW, TSCONFIG_JSON, TSCONFIG_JSON_VITE, NEXT_CONFIG, injectLottieDep, type AppFramework, type LinkRewrite, type ExtractedComponent, type RenderCtx } from "../generate/app.js"; import { renderChildrenJsx, renderAttrs, buildComponentRegistry, componentPreamble, componentFiles, componentImports, componentDataDecls, summarizeComponents, fileBase, generateViteConfig, generateViteIndexHtml, viteGlobalsCss, cnImportLine, resolveHtmlBg, htmlBgRule, CN_UTILS_MODULE, PACKAGE_JSON, PACKAGE_JSON_TW, PACKAGE_JSON_VITE, PACKAGE_JSON_VITE_TW, TSCONFIG_JSON, TSCONFIG_JSON_VITE, NEXT_CONFIG, injectLottieDep, type AppFramework, type LinkRewrite, type ExtractedComponent, type RenderCtx } from "../generate/app.js";
import { buildTailwind, tailwindGlobalsCss, createColorInterner, colorDefsCssOf, type TailwindOutput } from "../generate/tailwind.js"; import { buildTailwind, tailwindGlobalsCss, createColorInterner, colorDefsCssOf, type TailwindOutput } from "../generate/tailwind.js";
import type { InteractionCapture } from "../capture/interactions.js"; import type { InteractionCapture } from "../capture/interactions.js";
import type { IRChild } from "../normalize/ir.js"; import type { IRChild } from "../normalize/ir.js";
@@ -29,7 +29,7 @@ import type { CaptureResult } from "../capture/capture.js";
import { backfillLazyBackgrounds } from "../normalize/ir.js"; import { backfillLazyBackgrounds } from "../normalize/ir.js";
import { toRoutePath, segmentsOf } from "../crawl/url.js"; import { toRoutePath, segmentsOf } from "../crawl/url.js";
import { buildCanonicalChrome, chromeCssIr, middleChildren, middleIncludeFilter, CHROME_PREFIX, type ChromePlan } from "./sharedLayout.js"; import { buildCanonicalChrome, chromeCssIr, middleChildren, middleIncludeFilter, CHROME_PREFIX, type ChromePlan } from "./sharedLayout.js";
import { buildSeoInventory, emitSeoAssetFiles, emitSeoRoutes, jsonLdHeadMarkup, metadataExport, routeSummaryFromIr, seoStaticFiles, viewportExport, type SeoInventory, type SeoRouteSummary } from "../generate/seo.js"; import { buildSeoInventory, emitSeoAssetFiles, emitSeoRoutes, jsonLdHeadMarkup, metadataExport, routeSummaryFromIr, seoStaticFiles, SITE_ORIGIN_LAYOUT_IMPORT, SITE_ORIGIN_MODULE, viewportExport, type SeoInventory, type SeoRouteSummary } from "../generate/seo.js";
import { emitGeneratedDocs } from "../generate/docs.js"; import { emitGeneratedDocs } from "../generate/docs.js";
export type RouteArtifact = { export type RouteArtifact = {
@@ -91,10 +91,10 @@ function unionFontCss(routes: RouteArtifact[]): string {
/** Shared page-base bits (entry html background + overflow-x clip) same rationale as /** Shared page-base bits (entry html background + overflow-x clip) same rationale as
* single-page generation; used by both the plain-CSS and Tailwind globals. */ * single-page generation; used by both the plain-CSS and Tailwind globals. */
function pageBaseOf(entry: RouteArtifact): { htmlBg: string; clip: string } { function pageBaseOf(entry: RouteArtifact): { htmlBg: string | null; clip: string } {
const cw = entry.ir.doc.canonicalViewport; const cw = entry.ir.doc.canonicalViewport;
const pv = entry.ir.doc.perViewport[cw]; const pv = entry.ir.doc.perViewport[cw];
const htmlBg = pv?.htmlBg && pv.htmlBg !== "rgba(0, 0, 0, 0)" ? pv.htmlBg : (pv?.bodyBg ?? "#ffffff"); const htmlBg = resolveHtmlBg(pv);
const noHScroll = Object.entries(entry.ir.doc.perViewport).every(([vp, d]) => d.scrollWidth <= Number(vp) * 1.03); const noHScroll = Object.entries(entry.ir.doc.perViewport).every(([vp, d]) => d.scrollWidth <= Number(vp) * 1.03);
return { htmlBg, clip: noHScroll ? "\nhtml, body { overflow-x: clip; }" : "" }; return { htmlBg, clip: noHScroll ? "\nhtml, body { overflow-x: clip; }" : "" };
} }
@@ -112,8 +112,7 @@ ${paletteCss}
${tokensToCss(entry.tokens, true)} ${tokensToCss(entry.tokens, true)}
/* page base */ /* page base */
html { background: ${htmlBg}; } ${htmlBgRule(htmlBg)}body { font-family: ${SYSTEM_FALLBACK}; }${clip}
body { font-family: ${SYSTEM_FALLBACK}; }${clip}
`; `;
} }
@@ -132,9 +131,10 @@ function layoutTsx(entry: RouteArtifact, bodyClass: string | undefined, chrome?:
const viewport = seo ? viewportExport(seo) : `export const viewport = { width: "device-width", initialScale: 1 };\n`; const viewport = seo ? viewportExport(seo) : `export const viewport = { width: "device-width", initialScale: 1 };\n`;
const jsonLd = seo ? jsonLdHeadMarkup(seo, 8) : ""; const jsonLd = seo ? jsonLdHeadMarkup(seo, 8) : "";
const head = jsonLd ? ` <head>\n${jsonLd}\n </head>\n` : ""; const head = jsonLd ? ` <head>\n${jsonLd}\n </head>\n` : "";
const siteImport = seo ? SITE_ORIGIN_LAYOUT_IMPORT + "\n" : "";
return `import "./globals.css"; return `import "./globals.css";
${chromeImport}import type { ReactNode } from "react"; ${chromeImport}import type { ReactNode } from "react";
${siteImport}
${metadata}${viewport} ${metadata}${viewport}
${pre}export default function RootLayout({ children }: { children: ReactNode }) { ${pre}export default function RootLayout({ children }: { children: ReactNode }) {
@@ -271,6 +271,9 @@ export function generateSiteApp(opts: {
// package.json is written after the route loop (below) so the lottie-web dependency can be // package.json is written after the route loop (below) so the lottie-web dependency can be
// added when any route actually replays a Lottie animation. // added when any route actually replays a Lottie animation.
writeText(join(appDir, "tsconfig.json"), isVite ? TSCONFIG_JSON_VITE : TSCONFIG_JSON); writeText(join(appDir, "tsconfig.json"), isVite ? TSCONFIG_JSON_VITE : TSCONFIG_JSON);
writeText(join(appDir, "src", "lib", "utils.ts"), CN_UTILS_MODULE);
// SITE_ORIGIN constant for SEO/metadata routes (Next only — Vite ships static SEO files).
if (!isVite) writeText(join(appDir, "src", "lib", "site.ts"), SITE_ORIGIN_MODULE);
writeText(join(appDir, ".gitignore"), "node_modules\n.next\nout\ndist\n"); writeText(join(appDir, ".gitignore"), "node_modules\n.next\nout\ndist\n");
if (isVite) { if (isVite) {
rmSync(join(appDir, "next.config.mjs"), { force: true }); rmSync(join(appDir, "next.config.mjs"), { force: true });
@@ -384,11 +387,17 @@ export function generateSiteApp(opts: {
// Stage 6: split each route component into its own route-local `components/Name.tsx` // Stage 6: split each route component into its own route-local `components/Name.tsx`
// (page imports them); per-route data stays inline. Route-local dirs keep names from // (page imports them); per-route data stays inline. Route-local dirs keep names from
// colliding across routes, which each name their components independently. // colliding across routes, which each name their components independently.
for (const { name, module } of componentFiles(routeReg)) writeText(join(routeDir, "components", fileBase(name) + ".tsx"), module); // Depth of a route-local `components/Name.tsx` below `src`: src/app/<dir>/components
// (Next) or src/routes/<key>/components (Vite). +1 for the components dir itself.
const compDepth = (isVite ? 2 : (dir ? dir.split("/").length + 1 : 1)) + 1;
for (const { name, module } of componentFiles(routeReg, undefined, compDepth)) writeText(join(routeDir, "components", fileBase(name) + ".tsx"), module);
const compImport = componentImports(routeReg, 0); // route-local → ./components/Name const compImport = componentImports(routeReg, 0); // route-local → ./components/Name
const dataDecls = componentDataDecls(routeReg); const dataDecls = componentDataDecls(routeReg);
const preBlock = dataDecls ? dataDecls + "\n\n" : ""; const preBlock = dataDecls ? dataDecls + "\n\n" : "";
const importLines = [wireImport.trimEnd(), lottieImport.trimEnd(), compImport].filter(Boolean).join("\n"); // page.tsx sits at routeDir (src/app/<dir> or src/routes/<key>); depth below src.
const pageDepth = isVite ? 2 : (dir ? dir.split("/").length + 1 : 1);
const cnImport = /\bcn\(/.test(bodyJsx) ? cnImportLine(pageDepth) + "\n" : "";
const importLines = [wireImport.trimEnd(), lottieImport.trimEnd(), cnImport.trimEnd(), compImport].filter(Boolean).join("\n");
const pageTsx = `${isVite ? "" : 'import "./ditto.css";\n'}${importLines ? importLines + "\n" : ""}// Generated by clone-site. Do not edit by hand.\n${preBlock}export default function Page() {\n return (\n <>\n${bodyJsx}${wireBody}${lottieBody} const pageTsx = `${isVite ? "" : 'import "./ditto.css";\n'}${importLines ? importLines + "\n" : ""}// Generated by clone-site. Do not edit by hand.\n${preBlock}export default function Page() {\n return (\n <>\n${bodyJsx}${wireBody}${lottieBody}
</> </>
); );
+79 -5
View File
@@ -1,5 +1,5 @@
import type { IR, IRNode } from "../normalize/ir.js"; import type { IR, IRNode } from "../normalize/ir.js";
import { isTextChild } from "../normalize/ir.js"; import { isTextChild, irContentExtent } from "../normalize/ir.js";
import type { PageSnapshot } from "../capture/walker.js"; import type { PageSnapshot } from "../capture/walker.js";
import type { GenNode } from "./render.js"; import type { GenNode } from "./render.js";
import { indexByCid } from "./render.js"; import { indexByCid } from "./render.js";
@@ -86,6 +86,32 @@ function hasVisibleElementChild(node: IRNode, vp: number): boolean {
return false; 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<number, PageSnapshot>,
viewports: number[],
): number {
const hiddenCids = new Set<string>();
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? ---------- // ---------- Pollution gate (stage 2): is the captured page degenerate? ----------
// A clone can pass every structural gate while faithfully reproducing the WRONG // 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 // page: an egress/bot wall, a near-empty shell, or a cookie/consent modal that was
@@ -119,11 +145,30 @@ export function gatePollution(ir: IR, capture: CaptureResult, viewports: number[
let blocking = capture.dismissal?.blocking ?? false; let blocking = capture.dismissal?.blocking ?? false;
for (const pv of capture.perViewport) { overlaysRemaining = Math.max(overlaysRemaining, pv.overlaysRemaining ?? 0); blocking = blocking || !!pv.blocking; } for (const pv of capture.perViewport) { overlaysRemaining = Math.max(overlaysRemaining, pv.overlaysRemaining ?? 0); blocking = blocking || !!pv.blocking; }
let minHeightRatio = Infinity; let minHeightRatio = Infinity;
let maxHeightRatio = 0;
for (const pv of capture.perViewport) { for (const pv of capture.perViewport) {
if (pv.height > 0) minHeightRatio = Math.min(minHeightRatio, pv.scrollHeight / pv.height); if (pv.height > 0) {
const ratio = pv.scrollHeight / pv.height;
minHeightRatio = Math.min(minHeightRatio, ratio);
maxHeightRatio = Math.max(maxHeightRatio, ratio);
}
} }
if (!Number.isFinite(minHeightRatio)) minHeightRatio = 1; if (!Number.isFinite(minHeightRatio)) minHeightRatio = 1;
// Scroll-locked-capture contradiction: an email-capture/promo popup that sets
// body{overflow:hidden;height:100vh} collapses `document.scrollHeight` to EXACTLY the viewport
// height at EVERY width (ratio ~1.0 across the board) — yet the real page's IN-FLOW content
// (the IR's sections) still lays out several viewports tall. A genuine one-screen landing page
// has content extent ~= its scrollHeight, so this only fires when the two disagree: captured
// scrollHeight pinned to one viewport WHILE the IR content spans multiple. That is a
// scroll-locked, polluted capture — the overlay detector should have caught it, so fail loudly.
let maxContentRatio = 0;
for (const pv of capture.perViewport) {
if (pv.height > 0) maxContentRatio = Math.max(maxContentRatio, irContentExtent(ir.root, pv.viewport) / pv.height);
}
// scrollHeight never exceeds ~1 viewport at any width, but the IR content is 2+ viewports tall.
const scrollLockedContradiction = maxHeightRatio > 0 && maxHeightRatio < 1.15 && maxContentRatio >= 2;
// Degenerate signals. Calibrated against real captures: an egress/bot wall is // Degenerate signals. Calibrated against real captures: an egress/bot wall is
// ~3 nodes / ~24 chars; the most minimal legitimate page in the suite // ~3 nodes / ~24 chars; the most minimal legitimate page in the suite
// (michaelcole.me) is 26 nodes / 640 chars. Thresholds sit safely between. // (michaelcole.me) is 26 nodes / 640 chars. Thresholds sit safely between.
@@ -131,6 +176,7 @@ export function gatePollution(ir: IR, capture: CaptureResult, viewports: number[
if (wall && nodeCount < 220) issues.push("bot/egress wall text on a small page"); if (wall && nodeCount < 220) issues.push("bot/egress wall text on a small page");
if (textChars < 60 && nodeCount < 50) issues.push(`near-empty page: ${textChars} visible text chars, ${nodeCount} nodes`); if (textChars < 60 && nodeCount < 50) issues.push(`near-empty page: ${textChars} visible text chars, ${nodeCount} nodes`);
if (blocking) issues.push("a full-viewport modal still scroll-locks the page after dismissal"); if (blocking) issues.push("a full-viewport modal still scroll-locks the page after dismissal");
if (scrollLockedContradiction) issues.push(`scroll-locked capture: scrollHeight pinned to ~1 viewport at every width while IR content spans ${round2(maxContentRatio)} viewports`);
return { return {
gate: "pollution", gate: "pollution",
@@ -138,6 +184,7 @@ export function gatePollution(ir: IR, capture: CaptureResult, viewports: number[
metrics: { metrics: {
nodeCount, visibleTextChars: textChars, wallTextDetected: wall, nodeCount, visibleTextChars: textChars, wallTextDetected: wall,
overlaysRemaining, blocking, minScrollHeightRatio: round2(minHeightRatio), overlaysRemaining, blocking, minScrollHeightRatio: round2(minHeightRatio),
maxScrollHeightRatio: round2(maxHeightRatio), maxContentExtentRatio: round2(maxContentRatio),
dismissedCount: capture.dismissal?.dismissed.length ?? 0, dismissedCount: capture.dismissal?.dismissed.length ?? 0,
overlaysRemoved: capture.dismissal?.removed ?? 0, overlaysRemoved: capture.dismissal?.removed ?? 0,
videoStills: capture.dismissal?.videoStills ?? 0, videoStills: capture.dismissal?.videoStills ?? 0,
@@ -175,7 +222,7 @@ export function gate2Assets(assetGraph: AssetGraph, fontGraph: FontGraph, gen: {
if (zeroByte > 0) issues.push(`${zeroByte} zero-byte downloaded assets`); if (zeroByte > 0) issues.push(`${zeroByte} zero-byte downloaded assets`);
if (skippedNoReason > 0) issues.push(`${skippedNoReason} skipped assets without reason`); if (skippedNoReason > 0) issues.push(`${skippedNoReason} skipped assets without reason`);
if (gen.remoteRefs.length > 0) issues.push(`${gen.remoteRefs.length} generated refs point to remote origin`); if (gen.remoteRefs.length > 0) issues.push(`${gen.remoteRefs.length} generated refs point to remote origin`);
if (gen.failed404.length > 0) issues.push(`${gen.failed404.length} generated asset refs 404`); if (gen.failed404.length > 0) issues.push(`${gen.failed404.length} generated asset refs missing (HTTP >= 400 or file absent from export)`);
const fontsResolvedOrFallback = fontGraph.entries.every((f) => f.status === "resolved" || (f.status === "fallback" && f.reason)); const fontsResolvedOrFallback = fontGraph.entries.every((f) => f.status === "resolved" || (f.status === "fallback" && f.reason));
if (!fontsResolvedOrFallback) issues.push("font declarations not resolved/fallback-recorded"); if (!fontsResolvedOrFallback) issues.push("font declarations not resolved/fallback-recorded");
return { return {
@@ -257,6 +304,12 @@ export function gate3Dom(ir: IR, genSnaps: Record<number, PageSnapshot>, 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 matchPct = totalVisible ? matched / totalVisible : 1;
const textPct = textTotal ? textPresent / textTotal : 1; const textPct = textTotal ? textPresent / textTotal : 1;
const linkPct = linksTotal ? linksOk / linksTotal : 1; const linkPct = linksTotal ? linksOk / linksTotal : 1;
@@ -275,6 +328,7 @@ export function gate3Dom(ir: IR, genSnaps: Record<number, PageSnapshot>, viewpor
nodeMatchPct: round4(matchPct), textPresentPct: round4(textPct), nodeMatchPct: round4(matchPct), textPresentPct: round4(textPct),
linkPct: round4(linkPct), mediaPct: round4(mediaPct), linkPct: round4(linkPct), mediaPct: round4(mediaPct),
totalVisible, matched, textTotal, textPresent, linksTotal, mediaTotal, inventedText, totalVisible, matched, textTotal, textPresent, linksTotal, mediaTotal, inventedText,
textHiddenInClone,
}, },
issues, issues,
}; };
@@ -292,8 +346,12 @@ function isValidRetag(srcTag: string, genTag: string): boolean {
return genTag === "div" && /^(ul|ol|menu|dl|p|h[1-6])$/.test(srcTag); // content-model → div return genTag === "div" && /^(ul|ol|menu|dl|p|h[1-6])$/.test(srcTag); // content-model → div
} }
function normHref(href: string, origin: string): string { export function normHref(href: string, origin: string): string {
if (!href) return ""; if (!href) return "";
// A `javascript:*` href is a script trigger, not a navigable URL. Generation emits an inert `#`
// for it (React blocks the literal), so treat every javascript: href — on either side — as `#`
// and let the two sides match instead of failing on the un-reproducible script string.
if (/^\s*javascript:/i.test(href)) return "#";
if (href.startsWith("#")) return href; if (href.startsWith("#")) return href;
try { try {
const u = new URL(href, origin); const u = new URL(href, origin);
@@ -346,7 +404,10 @@ export function gate4Style(ir: IR, genSnaps: Record<number, PageSnapshot>, viewp
// equal, only numerically far apart. Treat any two such large radii as equivalent so the // equal, only numerically far apart. Treat any two such large radii as equivalent so the
// idiomatic `rounded-full` doesn't trip the exact-px compare; real radii stay ±2px. // idiomatic `rounded-full` doesn't trip the exact-px compare; real radii stay ±2px.
if (p === "borderTopLeftRadius" && pxNum(s.computed[p] ?? "") >= PILL_PX && pxNum(g.computed[p] ?? "") >= PILL_PX) continue; if (p === "borderTopLeftRadius" && pxNum(s.computed[p] ?? "") >= PILL_PX && pxNum(g.computed[p] ?? "") >= PILL_PX) continue;
if (!cmpNum(s.computed[p], g.computed[p], 2, 0)) { nodeOk = false; fails.push(p); } const eq = p === "letterSpacing"
? letterSpacingEquivalent(s.computed[p], g.computed[p])
: cmpNum(s.computed[p], g.computed[p], 2, 0);
if (!eq) { nodeOk = false; fails.push(p); }
} }
for (const p of PX4_PROPS) { for (const p of PX4_PROPS) {
// `gap: normal` is the initial value and resolves to 0 for flex/grid, so it // `gap: normal` is the initial value and resolves to 0 for flex/grid, so it
@@ -405,6 +466,19 @@ function cmpNum(a: string | undefined, b: string | undefined, abs: number, pct:
return withinAbs(na, nb, abs) || (pct > 0 && withinPct(na, nb, pct)); return withinAbs(na, nb, abs) || (pct > 0 && withinPct(na, nb, pct));
} }
/** Compare two computed `letter-spacing` values within the ±2px style tolerance, treating the keyword
* `normal` as `0px`. `letter-spacing: normal` is the initial value and adds no extra spacing (it
* computes to 0), so `normal` `0px`; crucially, Chromium serializes a computed `letter-spacing: 0`
* BACK as the keyword `normal`, so a genuinely-zero (or sub-0.1px, snapped-to-zero) tracking shows up
* as `normal` on one side and `0px` on the other a spelling difference that `cmpNum` alone reads as
* a NaN exact-string mismatch. Normalizing the keyword before the numeric compare removes that false
* failure while a real tracking delta (> 2px) still fails. Mirrors the `gap: normal → 0px` handling. */
export function letterSpacingEquivalent(a: string | undefined, b: string | undefined): boolean {
if (a === undefined) return true;
const norm = (v: string | undefined): string => (v ?? "").replace(/\bnormal\b/g, "0px");
return cmpNum(norm(a), norm(b), 2, 0);
}
// ---------- Gate 5: layout / section equivalence ---------- // ---------- Gate 5: layout / section equivalence ----------
export function gate5Layout(ir: IR, genSnaps: Record<number, PageSnapshot>, sections: Section[], viewports: number[], reflow = false): GateResult { export function gate5Layout(ir: IR, genSnaps: Record<number, PageSnapshot>, sections: Section[], viewports: number[], reflow = false): GateResult {
const issues: string[] = []; const issues: string[] = [];
+5 -1
View File
@@ -4,6 +4,7 @@ import type { InteractionCapture } from "../capture/interactions.js";
import { buildRuntimeSpecs, specKey } from "../generate/interactive.js"; import { buildRuntimeSpecs, specKey } from "../generate/interactive.js";
import { buildMenuSpecs } from "../generate/menu.js"; import { buildMenuSpecs } from "../generate/menu.js";
import type { GateResult } from "./gates.js"; import type { GateResult } from "./gates.js";
import { gotoAndSettle } from "./render.js";
/** /**
* Stage 4 interaction gate. Drives the SAME interactions in the built clone that * Stage 4 interaction gate. Drives the SAME interactions in the built clone that
@@ -61,7 +62,10 @@ export async function driveInteractionGate(opts: {
const ctx = await browser.newContext({ viewport: { width: vp, height: vh }, deviceScaleFactor: 1 }); const ctx = await browser.newContext({ viewport: { width: vp, height: vh }, deviceScaleFactor: 1 });
const page = await ctx.newPage(); const page = await ctx.newPage();
await page.addInitScript("globalThis.__name = globalThis.__name || ((fn) => fn);"); await page.addInitScript("globalThis.__name = globalThis.__name || ((fn) => fn);");
await page.goto(url, { waitUntil: "networkidle", timeout: 45000 }); // `load` + bounded network-quiet wait (not strict `networkidle`) so an autoplaying
// long hero video — which keeps issuing bounded range fetches — can't make navigation
// time out and fail the interaction gate before any interaction is even driven.
await gotoAndSettle(page, url);
await page.waitForTimeout(300); await page.waitForTimeout(300);
const styleOf = (cid: string, props: string[]): Promise<Record<string, string> | null> => const styleOf = (cid: string, props: string[]): Promise<Record<string, string> | null> =>
+255 -8
View File
@@ -5,6 +5,7 @@ import { join, extname, normalize } from "node:path";
import { spawnSync } from "node:child_process"; import { spawnSync } from "node:child_process";
import { chromium } from "playwright"; import { chromium } from "playwright";
import { collectPage, type PageSnapshot } from "../capture/walker.js"; import { collectPage, type PageSnapshot } from "../capture/walker.js";
import { captureFullPageViaCDP, normalizeVideoTime } from "../capture/capture.js";
import { ensureDir, writeJSONCompact } from "../util/fsx.js"; import { ensureDir, writeJSONCompact } from "../util/fsx.js";
const ESBUILD_SHIM = const ESBUILD_SHIM =
@@ -145,6 +146,50 @@ async function readLimited(p: string): Promise<Buffer> {
} }
} }
/** Result of interpreting a `Range` header against a known resource size.
* - `kind: "full"` no (usable) Range header; answer with a normal 200 full body.
* - `kind: "range"` a single satisfiable `bytes=start-end`; answer 206 with [start,end].
* - `kind: "unsatisfiable"` a syntactically valid range wholly past EOF; answer 416. */
export type RangeResolution =
| { kind: "full" }
| { kind: "range"; start: number; end: number }
| { kind: "unsatisfiable" };
/** Parse a single-range HTTP `Range` header ("bytes=start-end", "bytes=start-",
* "bytes=-suffix") against a resource of `size` bytes. Multi-range ("a-b,c-d") is
* intentionally collapsed to a full 200 (Chromium's media element only ever asks for a
* single range, and RFC 7233 lets a server ignore Range and reply 200). Anything the
* spec calls malformed (missing "bytes=", non-numeric, inverted, both bounds empty) is
* also treated as "full" the safe fallback. A well-formed range that starts at or past
* EOF is "unsatisfiable" 416. `end` is inclusive and clamped to size-1. */
export function parseRangeHeader(header: string | undefined, size: number): RangeResolution {
if (!header) return { kind: "full" };
const m = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
if (!m) return { kind: "full" }; // absent/malformed/multi-range → serve full body
const startStr = m[1]!;
const endStr = m[2]!;
if (startStr === "" && endStr === "") return { kind: "full" }; // "bytes=-" is malformed
if (size <= 0) return { kind: "unsatisfiable" };
let start: number;
let end: number;
if (startStr === "") {
// Suffix range: "bytes=-N" → last N bytes.
const suffix = Number(endStr);
if (!Number.isFinite(suffix) || suffix <= 0) return { kind: "full" };
start = Math.max(0, size - suffix);
end = size - 1;
} else {
start = Number(startStr);
if (!Number.isFinite(start)) return { kind: "full" };
if (start >= size) return { kind: "unsatisfiable" }; // start past EOF
end = endStr === "" ? size - 1 : Number(endStr);
if (!Number.isFinite(end)) return { kind: "full" };
if (end < start) return { kind: "full" }; // inverted → ignore Range
end = Math.min(end, size - 1);
}
return { kind: "range", start, end };
}
export function serveStatic(rootDir: string): Promise<{ url: string; close: () => Promise<void> }> { export function serveStatic(rootDir: string): Promise<{ url: string; close: () => Promise<void> }> {
const server: Server = createServer(async (req, res) => { const server: Server = createServer(async (req, res) => {
try { try {
@@ -167,8 +212,32 @@ export function serveStatic(rootDir: string): Promise<{ url: string; close: () =
if (existsSync(fallback)) { res.writeHead(404, { "content-type": "text/html" }); res.end(await readLimited(fallback)); return; } if (existsSync(fallback)) { res.writeHead(404, { "content-type": "text/html" }); res.end(await readLimited(fallback)); return; }
res.writeHead(404); res.end("not found"); return; res.writeHead(404); res.end("not found"); return;
} }
const contentType = CONTENT_TYPES[extname(filePath).toLowerCase()] ?? "application/octet-stream";
const size = statSync(filePath).size;
// HTTP Range support (RFC 7233). Chromium requests <video>/<audio> with `Range: bytes=0-`
// and, absent a 206, holds the single progressive stream open throttled to playback — a
// long autoplay hero (76s) then means `waitUntil:"networkidle"` can mathematically never
// fire. Answering 206 with bounded chunks lets Chromium fetch in pieces so the network
// goes idle between reads. A HEAD-style range far past EOF gets a 416.
const range = parseRangeHeader(req.headers.range as string | undefined, size);
if (range.kind === "unsatisfiable") {
res.writeHead(416, { "content-type": contentType, "content-range": `bytes */${size}`, "accept-ranges": "bytes" });
res.end();
return;
}
const data = await readLimited(filePath); const data = await readLimited(filePath);
res.writeHead(200, { "content-type": CONTENT_TYPES[extname(filePath).toLowerCase()] ?? "application/octet-stream" }); if (range.kind === "range") {
const chunk = data.subarray(range.start, range.end + 1);
res.writeHead(206, {
"content-type": contentType,
"content-range": `bytes ${range.start}-${range.end}/${size}`,
"accept-ranges": "bytes",
"content-length": String(chunk.length),
});
res.end(chunk);
return;
}
res.writeHead(200, { "content-type": contentType, "accept-ranges": "bytes", "content-length": String(data.length) });
res.end(data); res.end(data);
} catch { } catch {
res.writeHead(500); res.end("error"); res.writeHead(500); res.end("error");
@@ -191,8 +260,151 @@ export type RenderResult = {
runtimeErrors: string[]; runtimeErrors: string[];
httpStatus: number; httpStatus: number;
failedResources: string[]; failedResources: string[];
/** Non-fatal diagnostics: families the app declared via @font-face that never reached
* `status:"loaded"` within the wait bound (their text was measured with a fallback face). */
fontWarnings: string[];
}; };
/** A single entry of `document.fonts` as reported in-page, reduced to the fields the
* load decision needs. Serializable so the decision logic can be unit-tested outside a browser. */
export type FontFaceStatus = { family: string; weight: string; style: string; status: string };
/** Pure decision for the font-load wait: given the current `document.fonts` entries, return the
* families that are DECLARED (an @font-face exists for them) and still actively `"loading"`. Only
* `"loading"` is pending: browsers lazy-load faces, so after `document.fonts.ready` a face the
* rendered text actually needs is already fetching (`"loading"`), while a face left `"unloaded"`
* is by definition unreferenced by any rendered text and will never load on its own waiting on
* it just burns the cap. `"loaded"`/`"error"` are terminal. Empty return no face is still
* fetching and the DOM may be measured. The check is state-based (not time-based), so the caller's
* poll is deterministic: it ends the instant this returns empty, regardless of wall-clock timing. */
export function pendingFontFamilies(faces: FontFaceStatus[]): string[] {
const pending = new Set<string>();
for (const f of faces) {
const fam = f.family.replace(/^["']|["']$/g, "");
// Only a face still actively fetching keeps its family pending; "unloaded" (unreferenced),
// "loaded", and "error" are all non-blocking.
if (f.status === "loading") pending.add(fam);
}
return [...pending].sort();
}
/** Declared @font-face faces left `"unloaded"` after the wait bound: not fetched because no
* rendered text resolves to them (unreferenced weight/style/unicode-subset siblings). Reported
* per-face (family+weight+style) for an informational-only log these are benign, never a
* fidelity problem, since the text that IS measured renders in the face that actually loaded. */
export function unreferencedFontFaces(faces: FontFaceStatus[]): FontFaceStatus[] {
return faces
.filter((f) => f.status === "unloaded")
.map((f) => ({ family: f.family.replace(/^["']|["']$/g, ""), weight: f.weight, style: f.style, status: f.status }))
.sort((a, b) => `${a.family} ${a.weight} ${a.style}`.localeCompare(`${b.family} ${b.weight} ${b.style}`));
}
/** In-page: await `document.fonts.ready`, then poll (bounded) until no declared @font-face is still
* `"loading"`. Browsers lazy-load faces, so once ready has fired the faces the rendered text needs
* are already fetching; the poll only waits on those. Returns `{ pending, unreferenced }`: `pending`
* = families whose face was STILL `"loading"` when the bound expired (a genuinely stuck fetch, kept
* as a warning); `unreferenced` = faces left `"unloaded"` (declared but no rendered text resolves to
* them benign, surfaced per-face for an informational log only). Runs entirely inside the browser
* context so it reads the live FontFaceSet. Bounded + state-based deterministic (ends on state,
* never on a fixed sleep). `capMs` is a hard ceiling so a hung font request can't stall the render. */
async function awaitFontsLoaded(
page: import("playwright").Page,
opts?: { capMs?: number; pollMs?: number },
): Promise<{ pending: string[]; unreferenced: FontFaceStatus[] }> {
const capMs = opts?.capMs ?? 3000;
const pollMs = opts?.pollMs ?? 50;
try {
return await page.evaluate(
async ({ capMs, pollMs }) => {
const doc = document as Document;
const set = doc.fonts as unknown as { ready?: Promise<unknown>; forEach?: (cb: (f: FontFaceStatus) => void) => void } | undefined;
if (!set) return { pending: [], unreferenced: [] };
const snapshot = (): { family: string; weight: string; style: string; status: string }[] => {
const out: { family: string; weight: string; style: string; status: string }[] = [];
set.forEach?.((f) => out.push({ family: f.family, weight: f.weight, style: f.style, status: f.status }));
return out;
};
// Only a face still actively fetching is pending; "unloaded" is unreferenced (never fetched).
const pending = (faces: { family: string; weight: string; style: string; status: string }[]): string[] => {
const p = new Set<string>();
for (const f of faces) {
if (f.status === "loading") p.add(f.family.replace(/^["']|["']$/g, ""));
}
return [...p].sort();
};
// First give the browser's own aggregate signal a chance (bounded — a hung request must not
// hold ready forever). Then poll the per-face states until none are loading or the cap expires.
await Promise.race([set.ready ?? Promise.resolve(), new Promise((r) => setTimeout(r, capMs))]);
const deadline = Date.now() + capMs;
// eslint-disable-next-line no-constant-condition
while (true) {
const faces = snapshot();
const left = pending(faces);
const done = left.length === 0 || Date.now() >= deadline;
if (done) {
const unreferenced = faces
.filter((f) => f.status === "unloaded")
.map((f) => ({ family: f.family.replace(/^["']|["']$/g, ""), weight: f.weight, style: f.style, status: f.status }));
return { pending: left, unreferenced };
}
await new Promise((r) => setTimeout(r, pollMs));
}
},
{ capMs, pollMs },
) as { pending: string[]; unreferenced: FontFaceStatus[] };
} catch {
return { pending: [], unreferenced: [] }; // no FontFaceSet (or an evaluate fault) never blocks the walk
}
}
/** Navigate with `waitUntil:"load"` then wait for a bounded window of network quiet, so
* validation NEVER hard-fails on media that trickles requests. `waitUntil:"networkidle"`
* is a hard timeout: an autoplaying long video (even chunked via 206) keeps issuing bounded
* range fetches, so a strict networkidle can miss its 500ms-idle window and throw at 45s,
* leaving validation reportless. Instead we (1) `goto load` (fires on DOM+subresources, not
* on ongoing media), then (2) poll for `maxQuietMs` of 500ms network silence up to a
* `settleCapMs` ceiling, then proceed regardless. Normal pages reach quiet in well under a
* second, so total time stays comparable; only trickle-media pages spend the extra budget and
* then continue (the video is frame-0-normalized before the screenshot, so determinism holds).
* Returns the goto response (for httpStatus). */
export async function gotoAndSettle(
page: import("playwright").Page,
url: string,
opts?: { gotoTimeout?: number; settleCapMs?: number; quietMs?: number; pollMs?: number },
): Promise<import("playwright").Response | null> {
const gotoTimeout = opts?.gotoTimeout ?? 45000;
const settleCapMs = opts?.settleCapMs ?? 10000;
const quietMs = opts?.quietMs ?? 500;
const pollMs = opts?.pollMs ?? 500;
const resp = await page.goto(url, { waitUntil: "load", timeout: gotoTimeout });
// Count in-flight requests so we can detect network quiet without relying on the
// strict networkidle heuristic (which throws on trickle-media).
let inflight = 0;
const onReq = (): void => { inflight++; };
const onDone = (): void => { inflight = Math.max(0, inflight - 1); };
page.on("request", onReq);
page.on("requestfinished", onDone);
page.on("requestfailed", onDone);
try {
const deadline = Date.now() + settleCapMs;
let quietSince = inflight === 0 ? Date.now() : 0;
while (Date.now() < deadline) {
if (inflight === 0) {
if (quietSince === 0) quietSince = Date.now();
if (Date.now() - quietSince >= quietMs) break; // sustained quiet reached
} else {
quietSince = 0;
}
await new Promise((r) => setTimeout(r, pollMs));
}
} finally {
page.off("request", onReq);
page.off("requestfinished", onDone);
page.off("requestfailed", onDone);
}
return resp;
}
async function mapLimit<T, R>(items: T[], limit: number, fn: (item: T, i: number) => Promise<R>): Promise<R[]> { async function mapLimit<T, R>(items: T[], limit: number, fn: (item: T, i: number) => Promise<R>): Promise<R[]> {
const out: R[] = new Array(items.length); const out: R[] = new Array(items.length);
let next = 0; let next = 0;
@@ -218,6 +430,7 @@ export async function renderApp(opts: {
const vh = viewportHeight(vw); const vh = viewportHeight(vw);
const ctx = await browser.newContext({ ignoreHTTPSErrors: true, viewport: { width: vw, height: vh }, deviceScaleFactor: 1 }); const ctx = await browser.newContext({ ignoreHTTPSErrors: true, viewport: { width: vw, height: vh }, deviceScaleFactor: 1 });
const runtimeErrors: string[] = []; const runtimeErrors: string[] = [];
const fontWarnings: string[] = [];
const failedResources = new Set<string>(); const failedResources = new Set<string>();
let httpStatus = 0; let httpStatus = 0;
try { try {
@@ -226,9 +439,26 @@ export async function renderApp(opts: {
page.on("pageerror", (e) => runtimeErrors.push(String(e))); page.on("pageerror", (e) => runtimeErrors.push(String(e)));
page.on("response", (r) => { if (r.status() >= 400) failedResources.add(`${r.status()} ${r.url()}`); }); page.on("response", (r) => { if (r.status() >= 400) failedResources.add(`${r.status()} ${r.url()}`); });
page.on("requestfailed", (r) => failedResources.add(`failed ${r.url()}`)); page.on("requestfailed", (r) => failedResources.add(`failed ${r.url()}`));
const resp = await page.goto(opts.url, { waitUntil: "networkidle", timeout: 45000 }); const resp = await gotoAndSettle(page, opts.url);
if (resp) httpStatus = resp.status(); if (resp) httpStatus = resp.status();
try { await page.evaluate(() => (document as Document).fonts?.ready); } catch { /* ignore */ } // Webfonts must be APPLIED before the DOM walk and the screenshot: a rendered snapshot taken
// while the app's @font-face faces are still "unloaded" measures every text box in the
// fallback face (systematically narrower/wider glyphs → a bogus size delta attributed to the
// clone). Await document.fonts.ready AND poll (bounded, state-based) until every declared face
// is terminal, so this holds for both the walk below and the screenshot further down.
const { pending: pendingFonts, unreferenced: unrefFaces } = await awaitFontsLoaded(page);
if (pendingFonts.length) {
const msg = `font-wait: ${pendingFonts.length} @font-face families still loading after wait bound at ${vw}px: ${pendingFonts.join(", ")}`;
fontWarnings.push(msg);
console.warn(`[render] ${msg}`);
}
// Declared-but-unreferenced faces (no rendered text resolves to them) are benign — the
// browser never fetched them by design. Report them per-face (family+weight+style) as an
// informational log only; they are NOT a fidelity warning and never enter fontWarnings.
if (unrefFaces.length) {
const detail = unrefFaces.map((f) => `${f.family} ${f.weight} ${f.style}`).join(", ");
console.log(`[render] font-info: ${unrefFaces.length} declared-but-unreferenced faces at ${vw}px: ${detail}`);
}
// Scroll to settle any lazy effects, then back to top. // Scroll to settle any lazy effects, then back to top.
await page.evaluate(async () => { await page.evaluate(async () => {
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
@@ -255,21 +485,36 @@ export async function renderApp(opts: {
}); });
const snapshot = await page.evaluate(collectPage); const snapshot = await page.evaluate(collectPage);
writeJSONCompact(join(opts.renderedDir, "dom", `dom-${vw}.json`), snapshot); writeJSONCompact(join(opts.renderedDir, "dom", `dom-${vw}.json`), snapshot);
// Screenshot via CDP (captureBeyondViewport, no scroll-stitch) to match the SOURCE
// channel exactly — both sides must measure the same at-rest state. Fall back to
// Playwright's fullPage stitch if CDP fails. The clone is static so scroll-stitch
// matters less here, but symmetric capture is the requirement.
try { try {
await page.screenshot({ path: join(opts.renderedDir, "screenshots", `${vw}.png`), fullPage: true, timeout: 90_000, animations: "disabled" }); const shotPath = join(opts.renderedDir, "screenshots", `${vw}.png`);
// Pin every clone-side <video> to frame 0 (paused) before the shot — the SOURCE channel is
// now normalized to frame 0 at every viewport too, so both sides show the same frame and a
// video's playback time can't manufacture a perceptual diff. (The static clone rarely plays,
// but a replayed/autoplaying video would otherwise drift; symmetric normalization is the rule.)
await normalizeVideoTime(page);
try {
await captureFullPageViaCDP(page, shotPath);
} catch {
await page.screenshot({ path: shotPath, fullPage: true, timeout: 90_000, animations: "disabled" });
}
} catch { /* ignore */ } } catch { /* ignore */ }
return { viewport: vw, snapshot, runtimeErrors, failedResources: [...failedResources], httpStatus }; return { viewport: vw, snapshot, runtimeErrors, fontWarnings, failedResources: [...failedResources], httpStatus };
} finally { } finally {
await ctx.close(); await ctx.close();
} }
}); });
const runtimeErrors = results.flatMap((r) => r.runtimeErrors); const runtimeErrors = results.flatMap((r) => r.runtimeErrors);
const fontWarnings = results.flatMap((r) => r.fontWarnings);
const failedResources = new Set(results.flatMap((r) => r.failedResources)); const failedResources = new Set(results.flatMap((r) => r.failedResources));
for (const r of results) snapshots[r.viewport] = r.snapshot; for (const r of results) snapshots[r.viewport] = r.snapshot;
const non200 = results.find((r) => r.httpStatus && r.httpStatus !== 200); const non200 = results.find((r) => r.httpStatus && r.httpStatus !== 200);
const httpStatus = non200?.httpStatus ?? results.find((r) => r.httpStatus)?.httpStatus ?? 0; const httpStatus = non200?.httpStatus ?? results.find((r) => r.httpStatus)?.httpStatus ?? 0;
ensureDir(join(opts.renderedDir, "computed")); ensureDir(join(opts.renderedDir, "computed"));
return { snapshots, runtimeErrors, httpStatus, failedResources: [...failedResources] }; return { snapshots, runtimeErrors, httpStatus, failedResources: [...failedResources], fontWarnings };
} finally { } finally {
await browser.close(); await browser.close();
} }
@@ -293,8 +538,10 @@ export async function measureProbeWidths(opts: { url: string; widths: number[] }
const ctx = await browser.newContext({ ignoreHTTPSErrors: true, viewport: { width: w, height: viewportHeight(w) }, deviceScaleFactor: 1 }); const ctx = await browser.newContext({ ignoreHTTPSErrors: true, viewport: { width: w, height: viewportHeight(w) }, deviceScaleFactor: 1 });
const page = await ctx.newPage(); const page = await ctx.newPage();
await page.addInitScript(ESBUILD_SHIM); await page.addInitScript(ESBUILD_SHIM);
await page.goto(opts.url, { waitUntil: "networkidle", timeout: 45000 }); await gotoAndSettle(page, opts.url);
try { await page.evaluate(() => (document as Document).fonts?.ready); } catch { /* ignore */ } // This probe walks the DOM and measures text boxes, so — exactly as renderApp does — webfonts
// must be applied first, else the probe reads fallback-font widths at the off-band widths.
await awaitFontsLoaded(page);
await page.evaluate(() => { await page.evaluate(() => {
const win = window as unknown as { __dittoMotionStopped?: boolean; __dittoMotionStop?: () => void }; const win = window as unknown as { __dittoMotionStopped?: boolean; __dittoMotionStop?: () => void };
try { win.__dittoMotionStopped = true; } catch { /* ignore */ } try { win.__dittoMotionStopped = true; } catch { /* ignore */ }
+40 -6
View File
@@ -28,6 +28,21 @@ export function probeWidthsFor(viewports: number[]): number[] {
const widest = sorted[sorted.length - 1] ?? 1920; const widest = sorted[sorted.length - 1] ?? 1920;
return [...mids, Math.round(widest * 4 / 3)]; return [...mids, Math.round(widest * 4 / 3)];
} }
/** True if the URL of an aborted `/assets/` fetch resolves to a real file under the served export.
* Mirrors `serveStatic`'s path mapping (path segment joined onto the served root, `..` stripped) so
* an aborted srcset candidate whose file is present on disk is not mistaken for a 404. A URL we
* cannot parse, or one whose file is absent, is treated as a genuine miss (returns false). */
export function servedAssetExists(servedRoot: string, url: string): boolean {
let pathname: string;
try {
pathname = new URL(url).pathname;
} catch {
return false;
}
const rel = decodeURIComponent(pathname).replace(/^(\.\.[/\\])+/, "").replace(/^\/+/, "");
return existsSync(join(servedRoot, rel));
}
import { buildReport, reportToMarkdown, type Report } from "./report.js"; import { buildReport, reportToMarkdown, type Report } from "./report.js";
import { readJSON, writeJSON, writeText, ensureDir, fileExists } from "../util/fsx.js"; import { readJSON, writeJSON, writeText, ensureDir, fileExists } from "../util/fsx.js";
import type { CaptureResult } from "../capture/capture.js"; import type { CaptureResult } from "../capture/capture.js";
@@ -108,6 +123,9 @@ export async function validateRun(runDir: string, opts?: { harnessDir?: string;
try { try {
const r = await renderApp({ url: server.url + "/", viewports, renderedDir }); const r = await renderApp({ url: server.url + "/", viewports, renderedDir });
snapshots = r.snapshots; runtimeErrors = r.runtimeErrors; httpStatus = r.httpStatus; failedResources = r.failedResources; snapshots = r.snapshots; runtimeErrors = r.runtimeErrors; httpStatus = r.httpStatus; failedResources = r.failedResources;
// Non-fatal: webfonts that never loaded before the render walk (text was measured in a fallback
// face). Surfaced as a warning event, NOT a runtime error, so it never fails gate 0.
if (r.fontWarnings.length) log({ event: "font_wait_warning", families: r.fontWarnings });
probeSnaps = await measureProbeWidths({ url: server.url + "/", widths: probeWidthsFor(viewports) }); probeSnaps = await measureProbeWidths({ url: server.url + "/", widths: probeWidthsFor(viewports) });
if (capture.interaction) { if (capture.interaction) {
interactionGate = await driveInteractionGate({ url: server.url + "/", viewports, ir, interaction: capture.interaction }); interactionGate = await driveInteractionGate({ url: server.url + "/", viewports, ir, interaction: capture.interaction });
@@ -122,16 +140,32 @@ export async function validateRun(runDir: string, opts?: { harnessDir?: string;
motionGate = await driveMotionGate({ url: server.url + "/", viewports, ir, motion: capture.motion }); motionGate = await driveMotionGate({ url: server.url + "/", viewports, ir, motion: capture.motion });
log({ event: "motion_gate", pass: motionGate.pass, metrics: motionGate.metrics }); log({ event: "motion_gate", pass: motionGate.pass, metrics: motionGate.metrics });
} }
} catch (e) {
// A render crash (e.g. a page.goto timeout on trickling media, or a Playwright fault)
// must NOT escape reportless — otherwise validation/ stays empty and the run looks
// un-validated. Record the error as a runtime error so httpStatus stays 0, gate 0 fails
// with a visible issue, and the normal downstream path writes report.json as usual.
runtimeErrors.push(`render error: ${e instanceof Error ? e.message : String(e)}`);
log({ event: "render_error", error: e instanceof Error ? e.message : String(e) });
} finally { } finally {
await server.close(); await server.close();
} }
} }
// Video/audio elements stream and their requests are aborted ("failed") when the // A generated asset ref is only a real failure if the file it points to is genuinely
// render snapshot is taken before the stream finishes — the file is materialized, // missing from the served export. Two aborts are NOT failures even though Chromium logs
// so this is not an asset failure. Only count non-streaming assets. // them as "failed": (a) video/audio streams aborted when the render snapshot is taken
const assetFailed = failedResources.filter( // before the stream finishes; (b) responsive-image <source srcset> candidates whose
(f) => f.includes("/assets/") && !/\.(mp4|webm|mov|m4v|ogv|ogg|mp3|wav|m3u8)(\?|$)/i.test(f), // in-flight low-priority fetch is aborted when the page closes or the candidate is
); // re-chosen — the image analogue of (a). So count an /assets/ entry only when it is a
// real HTTP >= 400 response, OR a "failed " (aborted) entry whose target file is actually
// absent from the served out/ dir. Genuine 404s (file missing) still fail.
const assetFailed = failedResources.filter((f) => {
if (!f.includes("/assets/")) return false;
if (/\.(mp4|webm|mov|m4v|ogv|ogg|mp3|wav|m3u8)(\?|$)/i.test(f)) return false; // streaming media (a)
if (!f.startsWith("failed ")) return true; // "<status> <url>" — a real >= 400 response, always count
// Aborted fetch (b): excuse it if the file exists under the served export, else it is a real miss.
return build.outDir ? !servedAssetExists(build.outDir, f.slice("failed ".length)) : true;
});
const artifactsPresent = ["manifest.json", "sections.json", "tokens.json", "assets.json", "fonts.json"].every((f) => fileExists(join(generatedDir, f))); const artifactsPresent = ["manifest.json", "sections.json", "tokens.json", "assets.json", "fonts.json"].every((f) => fileExists(join(generatedDir, f)));
const gate0Issues: string[] = []; const gate0Issues: string[] = [];
if (!build.ok) gate0Issues.push("build failed: " + build.stderr.split("\n").filter(Boolean).slice(-3).join(" | ")); if (!build.ok) gate0Issues.push("build failed: " + build.stderr.split("\n").filter(Boolean).slice(-3).join(" | "));
+232
View File
@@ -0,0 +1,232 @@
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 { buildTailwind } from "../src/generate/tailwind.js";
const VPS = [375, 1280];
const CANONICAL = 1280;
function computed(over: StyleMap = {}): StyleMap {
return { display: "block", position: "static", visibility: "visible", listStyleType: "disc", listStylePosition: "outside", ...over };
}
/** Per-viewport node: distinct computed style per width so a band delta is produced. */
function pvNode(id: string, tag: string, byVp: Record<number, StyleMap>, children: IRChild[] = [], 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(byVp[vp]);
bboxByVp[vp] = { x: 0, y: 0, width: vp, height: 100 };
visibleByVp[vp] = true;
}
const n: IRNode = { id, tag, attrs: {}, visibleByVp, bboxByVp, computedByVp, children };
if (srcClass) n.srcClass = srcClass;
return n;
}
function irWith(root: IRNode): IR {
return {
doc: {
sourceUrl: "https://example.test/banding",
title: "Banding Fixture",
lang: "en",
charset: "UTF-8",
metaViewport: "width=device-width, initial-scale=1",
viewports: VPS,
sampleViewports: VPS,
canonicalViewport: CANONICAL,
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: 3,
keyframes: [],
},
root,
};
}
// C3 — a base RAW value (gradient) with a band that RESETS the same prop to a NON-raw value
// (`background-image: none` → utility `bg-[none]`) must keep the base gradient in ditto.css, not
// inline. An inline style out-specifies the @media reset, painting the gradient where the source
// turned it off (the mobile shimmer-blob defect). The banded-props set must count ALL band-touched
// props, not just the raw ones.
describe("buildTailwind banded non-raw override keeps base gradient in ditto.css (C3)", () => {
it("does not inline a base gradient when a band resets background-image to none", () => {
const grad = "linear-gradient(90deg, rgb(1, 2, 3), rgb(4, 5, 6))";
// 1280 (canonical/base): gradient painted; 375 (mobile band): background-image none.
const el = pvNode("n1", "span", {
375: { backgroundImage: "none" },
1280: { backgroundImage: grad },
}, [{ text: "Grepping" } as IRChild]);
const root = pvNode("n0", "body", { 375: {}, 1280: {} }, [el]);
const tw = buildTailwind(irWith(root), new Map());
const inline = tw.styleOf.get("n1");
const inlineHasGrad = !!inline && [...inline.values()].some((v) => v.includes("gradient("));
assert.ok(!inlineHasGrad, `banded gradient must NOT be inlined, got inline: ${inline ? JSON.stringify([...inline]) : "none"}`);
// It must instead live in ditto.css (extraCss folded into pseudoCss) as a [data-cid] rule.
assert.ok(/\[data-cid="n1"\][\s\S]*gradient\(/.test(tw.pseudoCss), `base gradient must be a ditto.css rule, got:\n${tw.pseudoCss}`);
});
it("still inlines a base gradient with NO band touching background-image (unchanged path)", () => {
const grad = "linear-gradient(90deg, rgb(1, 2, 3), rgb(4, 5, 6))";
const el = pvNode("n1", "span", {
375: { backgroundImage: grad },
1280: { backgroundImage: grad },
}, [{ text: "static gradient" } as IRChild]);
const root = pvNode("n0", "body", { 375: {}, 1280: {} }, [el]);
const tw = buildTailwind(irWith(root), new Map());
const inline = tw.styleOf.get("n1");
const inlineHasGrad = !!inline && [...inline.values()].some((v) => v.includes("gradient("));
assert.ok(inlineHasGrad, `a truly static (un-banded) gradient is still inlined, got inline: ${inline ? JSON.stringify([...inline]) : "none"}`);
});
});
// C1 (tailwind intent side) — a variant-only `max-lg:grid-rows-subgrid` covers only a subset of
// viewports (the axis resolves to explicit tracks at ≥lg). The partial-coverage bail must let subgrid
// through as a banded variant instead of discarding it.
describe("buildTailwind partial-coverage subgrid intent (C1)", () => {
it("emits grid-rows-subgrid from a variant-only source class", () => {
// Grid at both widths; subgrid computed at the mobile band (375) and explicit tracks at 1280.
// Source authored `max-lg:grid-rows-subgrid`.
const el = pvNode("n1", "div", {
375: { display: "grid", gridTemplateRows: "subgrid" },
1280: { display: "grid", gridTemplateRows: "340px 340px" },
}, [{ text: "card" } as IRChild], "max-lg:grid-rows-subgrid");
const root = pvNode("n0", "body", { 375: {}, 1280: {} }, [el]);
const tw = buildTailwind(irWith(root), new Map());
const cls = tw.classOf.get("n1") || "";
assert.ok(/grid-rows-subgrid/.test(cls), `subgrid must survive the partial-intent path, got class: "${cls}"`);
});
});
// FIX 1 (source-intent side) — an authored banded FIXED height (`h-[4rem] md:h-[6.25rem]`) must be
// recoverable through source intent even though sourceFluidLengthSuffix rejects fixed rem/px lengths.
// The recovered value is re-emitted as the CAPTURED computed px (root-font-size independent), and the
// generated `h-full`/`h-auto` on that axis is dropped in its favour.
describe("buildTailwind recovers authored banded fixed height as computed px (FIX 1)", () => {
// A per-vp node with distinct computed height + matching bbox on each axis, so geometry corroborates.
function fixedHNode(id: string, tag: string, hByVp: Record<number, 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({ display: "flex", height: `${hByVp[vp]}px` });
bboxByVp[vp] = { x: 0, y: 0, width: vp, height: hByVp[vp]! };
visibleByVp[vp] = true;
}
const n: IRNode = { id, tag, attrs: {}, visibleByVp, bboxByVp, computedByVp, children: [] };
n.srcClass = srcClass;
// Probe reads the fill↔content cycle (a grid/flex item whose child fills it): hAuto:false but
// hFill:true, so the generator would otherwise emit h-full. Source intent must still recover.
n.sizingByVp = Object.fromEntries(VPS.map((vp) => [vp, { wAuto: false, wFill: true, hAuto: false, hFill: true }]));
return n;
}
it("emits banded computed px (h-[60px]/h-[100px]) and no h-full", () => {
// Source authors `h-[4rem]` (base) and `md:h-[6.25rem]`; the source root is 15px so 4rem→60px @375
// and 6.25rem→100px @1280. Emitted as px so the clone's root font-size can't mis-size it.
const el = fixedHNode("n1", "div", { 375: 60, 1280: 100 }, "flex h-[4rem] w-full md:h-[6.25rem]");
const root = pvNode("n0", "body", { 375: {}, 1280: {} }, [el]);
const tw = buildTailwind(irWith(root), new Map());
const cls = tw.classOf.get("n1") || "";
assert.ok(!/\bh-full\b/.test(cls), `authored fixed height must not stay h-full, got class: "${cls}"`);
// The captured px must appear (as an arbitrary px value or a clean rem token equivalent). Accept the
// px form or its 16px-root rem equivalent (100px→6.25rem, 60px→3.75rem) since the pretty-printer may
// fold clean px back to rem — both resolve to the captured px at the clone's 16px root.
assert.ok(/h-\[100px\]|h-\[6\.25rem\]/.test(cls), `desktop height (100px/6.25rem@16root) must appear, got class: "${cls}"`);
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}"`);
});
});
+108
View File
@@ -0,0 +1,108 @@
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import { chromium, type Browser, type Page } from "playwright";
import { captureCanvasStillsInPage } from "../src/capture/capture.js";
import type { IRNode, IRChild, StyleMap, BBox } from "../src/normalize/ir.js";
import { propsList, resolveTag, renderChildrenJsx } from "../src/generate/app.js";
// tsx/esbuild wraps functions with a __name() helper for stack traces; the serialized
// page functions carry those calls, so shim it (same as capture.ts's init script).
const ESBUILD_SHIM =
"globalThis.__name = globalThis.__name || ((fn) => fn);" +
"globalThis.__defProp = globalThis.__defProp || Object.defineProperty;";
describe("capture: canvas raster fallback (in-page)", () => {
let browser: Browser;
let page: Page;
before(async () => {
browser = await chromium.launch();
page = await browser.newPage();
await page.addInitScript(ESBUILD_SHIM);
await page.setContent(`
<canvas id="big" width="200" height="100"></canvas>
<canvas id="tiny" width="20" height="20"></canvas>
<canvas id="ghost" width="300" height="150" style="visibility:hidden"></canvas>
<script>
const ctx = document.getElementById("big").getContext("2d");
ctx.fillStyle = "#3b82f6";
ctx.fillRect(10, 10, 180, 80);
</script>
`);
// setContent replaces the document without a navigation, so the init script may
// not have applied — evaluate the shim directly (same as capture.ts's frame path).
await page.evaluate(ESBUILD_SHIM);
});
after(async () => {
await browser.close();
});
it("rasterizes a meaningful 2D canvas to a PNG data URL under a synthetic clone-canvas URL", async () => {
const plan = await page.evaluate(captureCanvasStillsInPage);
assert.equal(plan.stills.length, 1, "exactly one canvas qualifies");
assert.equal(plan.shots.length, 0, "readable 2D canvas needs no element-screenshot fallback");
const still = plan.stills[0]!;
assert.ok(still.dataUrl.startsWith("data:image/png"), `PNG data URL (got ${still.dataUrl.slice(0, 30)})`);
assert.match(still.url, /^https:\/\/clone-canvas\.local\/0-[0-9a-z]+\.png$/);
assert.equal(still.sel, 'canvas[data-clone-canvas="0"]');
const bytes = Buffer.from(still.dataUrl.slice(still.dataUrl.indexOf(",") + 1), "base64");
assert.ok(bytes.length > 0, "still decodes to non-empty bytes");
const stamped = await page.evaluate(() => ({
big: document.getElementById("big")?.getAttribute("data-clone-canvas") ?? null,
tiny: document.getElementById("tiny")?.getAttribute("data-clone-canvas") ?? null,
ghost: document.getElementById("ghost")?.getAttribute("data-clone-canvas") ?? null,
}));
assert.equal(stamped.big, "0", "qualifying canvas gets the stable marker");
assert.equal(stamped.tiny, null, "a 20x20 canvas is below the meaningful-size gate");
assert.equal(stamped.ghost, null, "a hidden canvas is skipped");
});
});
const VPS = [375, 1280];
const SOURCE = "https://example.test/page";
const GIF = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
function el(id: string, tag: string, attrs: Record<string, string> = {}, children: IRChild[] = [], visible = true): IRNode {
const computedByVp: Record<number, StyleMap> = {};
const bboxByVp: Record<number, BBox> = {};
const visibleByVp: Record<number, boolean> = {};
for (const vp of VPS) {
computedByVp[vp] = { display: "block", position: "static", visibility: "visible" };
bboxByVp[vp] = { x: 0, y: 0, width: visible ? 640 : 0, height: visible ? 360 : 0 };
visibleByVp[vp] = visible;
}
return { id, tag, attrs, visibleByVp, bboxByVp, computedByVp, children };
}
describe("generate: canvas-still emission", () => {
const STILL_URL = "https://clone-canvas.local/0-abc.png";
const LOCAL = "/assets/cloned/images/ab.png";
const assetMap = new Map([[STILL_URL, LOCAL]]);
const canvas = () => el("7", "canvas", { src: STILL_URL, width: "200", height: "100" });
it("retags a canvas carrying a captured still to <img>; a bare canvas stays canvas", () => {
assert.equal(resolveTag(canvas(), false), "img");
assert.equal(resolveTag(el("8", "canvas", { width: "200", height: "100" }), false), "canvas");
});
it("resolves the synthetic still URL through the asset map and emits data-cid + alt", () => {
const p = new Map(propsList(canvas(), assetMap, SOURCE));
assert.equal(p.get("src"), JSON.stringify(LOCAL));
assert.equal(p.get('"data-cid"'), JSON.stringify("7"));
assert.equal(p.get("alt"), JSON.stringify(""), "decorative alt is injected for the raster still");
assert.equal(p.get("width"), JSON.stringify("200"));
assert.equal(p.get("height"), JSON.stringify("100"));
});
it("renders a self-closing <img> in place of the canvas", () => {
const jsx = renderChildrenJsx([canvas()], assetMap, SOURCE, 0);
assert.match(jsx, /<img[^>]*data-cid="7"[^>]*\/>/);
assert.match(jsx, new RegExp(`src="${LOCAL.replace(/[/]/g, "\\/")}"`));
assert.ok(!jsx.includes("<canvas"), "no canvas element remains");
});
it("falls back to the transparent GIF when the still missed the asset map (never a remote URL)", () => {
const p = new Map(propsList(canvas(), new Map(), SOURCE));
assert.equal(p.get("src"), JSON.stringify(GIF));
});
});
+84
View File
@@ -0,0 +1,84 @@
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { chromium, type Browser, type Page } from "playwright";
import { PNG } from "pngjs";
import pixelmatch from "pixelmatch";
import { captureFullPageViaCDP } from "../src/capture/capture.js";
const ESBUILD_SHIM = "globalThis.__name = globalThis.__name || ((fn) => fn);";
// A STATIC page (no scroll-linked animation) several viewports tall: a few full-width
// colored bands. Both capture paths should render the identical at-rest picture, so any
// difference is purely a capture-mechanism artifact (scrollbar gutter, off-by-one), which
// is exactly what this test measures.
const BANDS_HTML =
"<!doctype html><html><head><style>" +
"*{margin:0;padding:0;box-sizing:border-box}" +
"html,body{width:100%}" +
".band{width:100%;height:600px;display:flex;align-items:center;justify-content:center;" +
"font:bold 48px sans-serif;color:#fff}" +
".b0{background:#c0392b}.b1{background:#27ae60}.b2{background:#2980b9}" +
".b3{background:#8e44ad}.b4{background:#d35400}" +
"</style></head><body>" +
"<div class='band b0'>1</div><div class='band b1'>2</div>" +
"<div class='band b2'>3</div><div class='band b3'>4</div>" +
"<div class='band b4'>5</div>" +
"</body></html>";
describe("CDP full-page capture vs Playwright fullPage stitch (static fixture)", () => {
let browser: Browser;
let page: Page;
let tmp: string;
before(async () => {
browser = await chromium.launch({ args: ["--disable-dev-shm-usage"] });
// A viewport SHORTER than the content so fullPage must span multiple bands: this is
// where Playwright would scroll-stitch and CDP renders in one shot.
const context = await browser.newContext({ viewport: { width: 800, height: 700 }, deviceScaleFactor: 1 });
page = await context.newPage();
await page.addInitScript(ESBUILD_SHIM);
await page.setContent(BANDS_HTML, { waitUntil: "load" });
tmp = mkdtempSync(join(tmpdir(), "cdp-shot-"));
});
after(async () => {
await browser.close();
if (tmp) rmSync(tmp, { recursive: true, force: true });
});
it("produces a valid PNG of the full content height (not just the viewport)", async () => {
const cdpPath = join(tmp, "cdp.png");
await captureFullPageViaCDP(page, cdpPath);
const cdp = PNG.sync.read(readFileSync(cdpPath));
// 5 bands * 600px = 3000px tall, well beyond the 700px viewport.
assert.ok(cdp.height >= 2900, `CDP shot spans full content (got ${cdp.height})`);
assert.ok(cdp.width >= 780 && cdp.width <= 800, `CDP width ~= content width (got ${cdp.width})`);
assert.ok(readFileSync(cdpPath).length > 1000, "CDP PNG is non-trivial in size");
});
it("is pixel-equivalent to Playwright's fullPage screenshot on a static page", async () => {
const cdpPath = join(tmp, "cdp2.png");
const pwPath = join(tmp, "pw.png");
await captureFullPageViaCDP(page, cdpPath);
await page.screenshot({ path: pwPath, fullPage: true, animations: "disabled" });
const cdp = PNG.sync.read(readFileSync(cdpPath));
const pw = PNG.sync.read(readFileSync(pwPath));
// Dimensions must match (a scrollbar-gutter difference would show up here first).
assert.equal(cdp.width, pw.width, `width match (cdp ${cdp.width} vs pw ${pw.width})`);
assert.equal(cdp.height, pw.height, `height match (cdp ${cdp.height} vs pw ${pw.height})`);
const { width, height } = cdp;
const diff = new PNG({ width, height });
const mismatched = pixelmatch(cdp.data, pw.data, diff.data, width, height, { threshold: 0.1 });
const pct = (mismatched / (width * height)) * 100;
// On a static page the two mechanisms should be near-identical. Allow a tiny tolerance
// for antialiasing at band seams; assert well under 0.5% differing pixels.
assert.ok(pct < 0.5, `pixel delta ${pct.toFixed(4)}% (${mismatched} px) must be < 0.5%`);
});
});
+199
View File
@@ -0,0 +1,199 @@
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 {
CN_UTILS_MODULE,
cnImportLine,
componentFiles,
generatePageTsx,
injectLottieDep,
PACKAGE_JSON,
PACKAGE_JSON_TW,
PACKAGE_JSON_VITE,
PACKAGE_JSON_VITE_TW,
propsList,
type ComponentRegistry,
} from "../src/generate/app.js";
import { DITTO_WIRE_TSX, ACCORDION_TSX } from "../src/generate/interactive.js";
import { DROPDOWN_MENU_TSX } from "../src/generate/menu.js";
import { declToUtil, snapLen } from "../src/generate/tailwind.js";
const VPS = [1280];
const CANONICAL = 1280;
function computed(over: StyleMap = {}): StyleMap {
return { display: "block", position: "static", visibility: "visible", whiteSpace: "normal", ...over };
}
function node(id: string, tag: string, cs: 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] = { ...cs };
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: CANONICAL,
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,
};
}
// ---- Fix 1: emitted runtime components clean up their listeners ----
describe("runtime components abort their listeners on unmount (fix 1)", () => {
const templates: Array<[string, string]> = [
["DittoWire", DITTO_WIRE_TSX],
["Accordion", ACCORDION_TSX],
["DropdownMenu", DROPDOWN_MENU_TSX],
];
for (const [name, tsx] of templates) {
it(`${name}: one AbortController, every addEventListener signalled, cleanup returns abort`, () => {
assert.ok(tsx.includes("new AbortController()"), "creates an AbortController");
assert.ok(tsx.includes("ac.abort()"), "effect cleanup aborts");
// Every addEventListener must carry a listener-options object with the signal (either
// `, { signal })` or `, { passive: true, signal })`). Count-match guarantees none is missed.
const adds = (tsx.match(/addEventListener\(/g) ?? []).length;
const signalled = (tsx.match(/, \{ signal \}\)/g) ?? []).length
+ (tsx.match(/, \{ passive: true, signal \}\)/g) ?? []).length;
assert.ok(adds > 0, "has listeners");
assert.equal(signalled, adds, "every addEventListener passes the abort signal");
// The stale re-wire guard is gone; effects are idempotent + cleaned up instead.
assert.ok(!tsx.includes("wired.current"), "no wired.current guard");
});
}
it("DropdownMenu removes any still-open panels on unmount", () => {
assert.ok(DROPDOWN_MENU_TSX.includes("openPanels"), "tracks open panels");
assert.ok(DROPDOWN_MENU_TSX.includes("openPanels.splice(0)) p.remove()"), "removes panels on cleanup");
});
});
// ---- Fix 2: cn() is a single shared module, imported (not copied per file) ----
describe("cn() is deduplicated into src/lib/utils (fix 2)", () => {
it("exports a single cn from the shared utils module", () => {
assert.ok(CN_UTILS_MODULE.includes("export function cn("), "utils module exports cn");
});
it("cnImportLine resolves to the shared module at the right depth", () => {
assert.equal(cnImportLine(1), 'import { cn } from "../lib/utils";');
assert.equal(cnImportLine(2), 'import { cn } from "../../lib/utils";');
});
it("a component module that uses cn() imports it rather than redefining it", () => {
// componentFiles reads only funcDefs / byName / fieldTypes / dataDecls / styleDecls.
const reg = {
byName: new Map([["Card", { runs: 1, instances: 2, cids: ["n1"] }]]),
funcDefs: new Map([["Card", 'function Card({ styles }: { styles: string }) {\n return <div className={cn("p-4", styles)} />;\n}']]),
fieldTypes: new Map(),
dataDecls: [],
cidDecls: [],
styleDecls: [],
} as unknown as ComponentRegistry;
const files = componentFiles(reg);
const card = files.find((f) => f.name === "Card")!.module;
assert.ok(card.includes('import { cn } from "../../lib/utils";'), "imports shared cn");
assert.ok(!card.includes("function cn("), "no inline cn definition");
});
});
// ---- Fix 3: JSX whitespace is collapsed under white-space: normal ----
describe("JSX whitespace collapses under white-space:normal (fix 3)", () => {
it("collapses captured \\n\\t indentation and multi-space runs to single spaces", () => {
const p = node("n1", "p", computed(), [{ text: "\n\t\tSkip to content\n\t\t" }]);
const root = node("n0", "body", computed(), [p]);
const tsx = generatePageTsx(irWith(root), new Map(), "https://example.test/");
assert.ok(!/\{"[^"]*\\n[^"]*"\}/.test(tsx), "no literal \\n frozen in text");
assert.ok(!/\{"\s{2,}"\}/.test(tsx), "no multi-space whitespace literal");
assert.ok(tsx.includes("Skip to content"), "content preserved");
});
it("preserves whitespace verbatim inside a <pre> (white-space: pre)", () => {
const pre = node("n1", "pre", computed({ whiteSpace: "pre" }), [{ text: "line1\n\tline2" }]);
const root = node("n0", "body", computed(), [pre]);
const tsx = generatePageTsx(irWith(root), new Map(), "https://example.test/");
assert.ok(tsx.includes("line1\\n\\tline2"), "pre keeps raw newlines/tabs");
});
});
// ---- Fix 5: sub-pixel arbitrary values snap ----
describe("sub-pixel lengths snap (fix 5)", () => {
it("snapLen keeps genuine fractions at 1 decimal, snaps near-integer jitter to an integer", () => {
assert.equal(snapLen("204.797px"), "204.8px");
assert.equal(snapLen("627.188px"), "627.2px");
assert.equal(snapLen("100.3px"), "100.3px");
assert.equal(snapLen("204.98px"), "205px"); // within 0.1px of an integer → snap
assert.equal(snapLen("2.996px"), "3px"); // rem/px jitter snaps
assert.equal(snapLen("627px"), "627px"); // already clean
});
it("snapLen leaves non-simple values (calc/percent/multi-token) untouched", () => {
assert.equal(snapLen("calc(100% - 3.333px)"), "calc(100% - 3.333px)");
assert.equal(snapLen("50.5%"), "50.5%");
assert.equal(snapLen("0px 2.5px"), "0px 2.5px");
});
it("declToUtil snaps a width arbitrary value but keeps border-width exact", () => {
assert.equal(declToUtil("width", "204.797px"), "w-[204.8px]");
// Border width sub-pixel precision is load-bearing — left untouched.
assert.equal(declToUtil("border-width", "0.667px"), "border-[0.667px]");
});
});
// ---- Fix 7: every emitted runtime import is a declared dependency ----
describe("lottie-web is declared when its import is emitted (fix 7)", () => {
it("injectLottieDep pins lottie-web into every package.json template", () => {
for (const pkg of [PACKAGE_JSON, PACKAGE_JSON_TW, PACKAGE_JSON_VITE, PACKAGE_JSON_VITE_TW]) {
const injected = injectLottieDep(pkg);
const deps = JSON.parse(injected).dependencies as Record<string, string>;
assert.equal(deps["lottie-web"], "5.12.2", "lottie-web pinned to the harness version");
}
});
});
// ---- FIX 5: javascript: hrefs are sanitized to an inert '#' ----
// React refuses to render a `javascript:*` href verbatim — it rewrites it to a long
// `javascript:throw new Error('React has blocked a javascript: URL…')` string that no longer matches
// the source href in the link gate. Emit an inert `#` instead (the script behaviour isn't reproduced).
describe("javascript: hrefs are emitted as an inert '#' (FIX 5)", () => {
const hrefOf = (n: IRNode): string | undefined => {
const p = propsList(n, new Map(), "https://example.test/").find(([k]) => k === "href");
return p ? JSON.parse(p[1]) : undefined;
};
it("rewrites a javascript: href to #", () => {
const a = node("n1", "a", computed());
a.attrs = { href: "Javascript:{}" };
assert.equal(hrefOf(a), "#", "a javascript: href is sanitized to #");
});
it("rewrites javascript:void(0) too (case-insensitive, with args)", () => {
const a = node("n1", "a", computed());
a.attrs = { href: "javascript:void(0)" };
assert.equal(hrefOf(a), "#");
});
it("leaves a normal in-page anchor href untouched", () => {
const a = node("n1", "a", computed());
a.attrs = { href: "#section" };
assert.equal(hrefOf(a), "#section", "a real fragment link is preserved");
});
it("leaves an ordinary external href resolved (not collapsed to #)", () => {
const a = node("n1", "a", computed());
a.attrs = { href: "https://example.test/products" };
assert.equal(hrefOf(a), "https://example.test/products");
});
});
+1156 -1
View File
File diff suppressed because it is too large Load Diff
+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
});
});
+152
View File
@@ -0,0 +1,152 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { looksLikeFontFile } from "../src/capture/capture.js";
import { buildFontGraph } from "../src/infer/fonts.js";
import type { FontFace } from "../src/capture/walker.js";
import type { AssetGraph, AssetEntry } from "../src/infer/assets.js";
// --- Magic-byte validator table -------------------------------------------------------------
function head(tag: string, len = 64): Buffer {
return Buffer.concat([Buffer.from(tag, "latin1"), Buffer.alloc(Math.max(0, len - tag.length))]);
}
describe("looksLikeFontFile (font container magic)", () => {
it("accepts every real font-container signature", () => {
assert.equal(looksLikeFontFile(head("wOF2")), true, "woff2");
assert.equal(looksLikeFontFile(head("wOFF")), true, "woff");
assert.equal(looksLikeFontFile(head("OTTO")), true, "CFF OpenType (otf)");
assert.equal(looksLikeFontFile(head("true")), true, "TrueType 'true' sfnt");
assert.equal(looksLikeFontFile(head("ttcf")), true, "TrueType collection");
assert.equal(
looksLikeFontFile(Buffer.concat([Buffer.from([0x00, 0x01, 0x00, 0x00]), Buffer.alloc(60)])),
true,
"TrueType \\x00\\x01\\x00\\x00 sfnt (ttf)",
);
// EOT: 0x504C ("LP") marker at byte offset 34.
const eot = Buffer.alloc(80);
eot[34] = 0x4c;
eot[35] = 0x50;
assert.equal(looksLikeFontFile(eot), true, "eot");
});
it("rejects HTML/text impostor bodies (the SPA-router 200 shell)", () => {
assert.equal(looksLikeFontFile(Buffer.from("<!DOCTYPE html><html><head></head></html>")), false);
assert.equal(looksLikeFontFile(Buffer.from("<!doctype html>")), false);
assert.equal(looksLikeFontFile(Buffer.from("<html><body>not a font</body></html>")), false);
assert.equal(looksLikeFontFile(Buffer.from("<?xml version=\"1.0\"?>")), false);
assert.equal(looksLikeFontFile(Buffer.from("just some plain text body")), false);
});
it("rejects truncated/empty buffers", () => {
assert.equal(looksLikeFontFile(Buffer.alloc(0)), false);
assert.equal(looksLikeFontFile(Buffer.from([0x77, 0x4f])), false); // 2 bytes of "wO"
});
});
// --- Validity-aware first-insert preference in buildFontGraph -------------------------------
function fontEntry(sourceUrl: string, localPath: string | null): AssetEntry {
return {
sourceUrl,
type: "font",
classification: localPath ? "downloaded" : "skipped",
localPath,
storedFile: localPath ? localPath.split("/").pop()! : null,
bytes: localPath ? 45_000 : 0,
reason: localPath ? null : "font_file_unavailable",
impact: null,
via: [],
};
}
function graphOf(entries: AssetEntry[]): AssetGraph {
const byUrl = new Map<string, AssetEntry>();
for (const e of entries) byUrl.set(e.sourceUrl, e);
return { entries, byUrl };
}
const DOC = "https://host.example/page";
// The genuine subset lives under the css file's sibling media dir; only THIS url is downloaded.
const GOOD_URL = "https://host.example/marketing-static/_next/static/media/good.woff2";
// The wrongly-based (document-relative) url that the SPA router answered with HTML and that was
// therefore rejected at store time — it never becomes a downloaded asset.
const BAD_URL = "https://host.example/media/impostor.woff2";
describe("buildFontGraph validity-aware preference (first-insert race)", () => {
it("prefers the face whose src resolves to a downloaded file even when it was inserted SECOND", () => {
const graph = graphOf([fontEntry(GOOD_URL, "/assets/cloned/fonts/good.woff2")]);
// Insertion order mirrors the real race: the mis-based CSSOM face lands first, the correctly
// based css-text face second. Only the good url is a downloaded asset.
const faces: FontFace[] = [
{ family: "Gothic", weight: "400", style: "normal", src: `url("${BAD_URL}") format("woff2")` },
{ family: "Gothic", weight: "400", style: "normal", src: `url("${GOOD_URL}") format("woff2")` },
];
const { entries, css } = buildFontGraph(faces, graph, DOC);
const gothic = entries.filter((e) => e.family === "Gothic");
assert.equal(gothic.length, 1, "deduped to a single face");
assert.equal(gothic[0]!.status, "resolved");
assert.deepEqual(gothic[0]!.localPaths, ["/assets/cloned/fonts/good.woff2"]);
assert.match(css, /good\.woff2/);
assert.doesNotMatch(css, /impostor/);
});
it("keeps the FIRST face when neither resolves (fallback, ties hold order)", () => {
const graph = graphOf([]); // nothing downloaded
const faces: FontFace[] = [
{ family: "Gothic", weight: "400", style: "normal", src: `url("${BAD_URL}") format("woff2")` },
{ family: "Gothic", weight: "400", style: "normal", src: `url("${GOOD_URL}") format("woff2")` },
];
const { entries } = buildFontGraph(faces, graph, DOC);
const gothic = entries.filter((e) => e.family === "Gothic");
assert.equal(gothic.length, 1);
assert.equal(gothic[0]!.status, "fallback");
});
it("keeps the FIRST resolving face when BOTH resolve (ties hold insertion order)", () => {
const alt = "https://host.example/marketing-static/_next/static/media/alt.woff2";
const graph = graphOf([
fontEntry(GOOD_URL, "/assets/cloned/fonts/good.woff2"),
fontEntry(alt, "/assets/cloned/fonts/alt.woff2"),
]);
const faces: FontFace[] = [
{ family: "Gothic", weight: "400", style: "normal", src: `url("${GOOD_URL}") format("woff2")` },
{ family: "Gothic", weight: "400", style: "normal", src: `url("${alt}") format("woff2")` },
];
const { entries } = buildFontGraph(faces, graph, DOC);
const gothic = entries.filter((e) => e.family === "Gothic");
assert.equal(gothic.length, 1);
assert.deepEqual(gothic[0]!.localPaths, ["/assets/cloned/fonts/good.woff2"], "first resolving wins");
});
});
// --- baseHref-driven relative resolution in buildFontGraph ----------------------------------
describe("buildFontGraph resolves a face's relative src against its owning sheet (baseHref)", () => {
it("uses baseHref, not the document url, so ../media climbs from the css file", () => {
// The sheet lives at /marketing-static/_next/static/css/sheet.css; `../media/x` from THERE is
// /marketing-static/_next/static/media/x.woff2, which is what actually downloaded. Resolved
// against the document (/page) it would clamp to /marketing-static/_next/static/media only if
// the css path were the base — the point of baseHref.
const sheetUrl = "https://host.example/marketing-static/_next/static/css/sheet.css";
const resolved = "https://host.example/marketing-static/_next/static/media/x.woff2";
const graph = graphOf([fontEntry(resolved, "/assets/cloned/fonts/x.woff2")]);
const faces: FontFace[] = [
{ family: "Gothic", weight: "400", style: "normal", src: 'url("../media/x.woff2") format("woff2")', baseHref: sheetUrl },
];
const { entries } = buildFontGraph(faces, graph, DOC);
assert.equal(entries[0]!.status, "resolved");
assert.deepEqual(entries[0]!.localPaths, ["/assets/cloned/fonts/x.woff2"]);
});
it("falls back to the document url when baseHref is absent (inline <style> / out-of-band parse)", () => {
// An out-of-band parse bakes the absolute url into src, so document fallback is harmless.
const abs = "https://host.example/fonts/y.woff2";
const graph = graphOf([fontEntry(abs, "/assets/cloned/fonts/y.woff2")]);
const faces: FontFace[] = [
{ family: "Mono", weight: "400", style: "normal", src: `url("${abs}") format("woff2")` },
];
const { entries } = buildFontGraph(faces, graph, DOC);
assert.equal(entries[0]!.status, "resolved");
});
});
+136
View File
@@ -0,0 +1,136 @@
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import { createServer, type Server } from "node:http";
import { chromium, type Browser, type Page } from "playwright";
import { collectPage, type PageSnapshot } from "../src/capture/walker.js";
// A relative url() inside a stylesheet must resolve against THAT sheet's url, not the document.
// These tests serve real CSS files at a NESTED path so `../media/x` climbs differently depending
// on the base: against the css file it lands under the nested dir, against the page it clamps to
// the site root. We assert the harvested url (cssUrls) and the face's baseHref carry the sheet
// base — the exact fix for the "HTML saved as woff2" font-materialization cluster.
// The nested layout mirrors a real bundler output:
// /page (document)
// /marketing-static/_next/static/css/x.css (external sheet)
// /marketing-static/_next/static/media/f.woff2 ← where ../media/f.woff2 SHOULD resolve
const CSS_PATH = "/marketing-static/_next/static/css/x.css";
const EXPECTED_MEDIA = "/marketing-static/_next/static/media/f.woff2";
// The WRONG (document-relative) resolution the old code produced:
const WRONG_MEDIA = "/media/f.woff2";
function serveText(body: string, type: string, res: import("node:http").ServerResponse): void {
res.writeHead(200, { "content-type": type });
res.end(body);
}
describe("font url() resolution against the owning stylesheet", () => {
let browser: Browser;
let page: Page;
let server: Server;
let origin = "";
before(async () => {
server = createServer((req, res) => {
const url = (req.url || "/").split("?")[0];
if (url === "/page") {
return serveText(
`<!doctype html><html><head><link rel="stylesheet" href="${CSS_PATH}"></head><body><p>hi</p></body></html>`,
"text/html",
res,
);
}
if (url === "/import-host") {
// A same-origin sheet that @imports the nested sheet; the imported sheet's own url must be
// the base for its faces, not the host sheet or the page.
return serveText(
`<!doctype html><html><head><link rel="stylesheet" href="/top.css"></head><body><p>hi</p></body></html>`,
"text/html",
res,
);
}
if (url === "/top.css") {
return serveText(`@import url("${CSS_PATH}");`, "text/css", res);
}
if (url === CSS_PATH) {
return serveText(
`@font-face{font-family:"Gothic";font-style:normal;font-weight:400;` +
`src:url("../media/f.woff2") format("woff2");}` +
`.hero{background:url("../media/bg.png")}`,
"text/css",
res,
);
}
res.writeHead(404).end();
});
await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
const addr = server.address();
if (addr && typeof addr === "object") origin = `http://127.0.0.1:${addr.port}`;
browser = await chromium.launch();
page = await browser.newPage();
});
after(async () => {
await browser.close();
await new Promise<void>((r) => server.close(() => r()));
});
const snap = async (path: string): Promise<PageSnapshot> => {
await page.goto(`${origin}${path}`, { waitUntil: "networkidle" });
await page.evaluate("globalThis.__name = globalThis.__name || ((fn) => fn);");
return page.evaluate(collectPage) as Promise<PageSnapshot>;
};
it("resolves a font-face src against the external sheet's url, not the document", async () => {
const s = await snap("/page");
assert.ok(
s.cssUrls.includes(`${origin}${EXPECTED_MEDIA}`),
`expected sheet-relative ${EXPECTED_MEDIA} in cssUrls, got ${JSON.stringify(s.cssUrls)}`,
);
assert.ok(
!s.cssUrls.includes(`${origin}${WRONG_MEDIA}`),
"must NOT produce the document-relative (wrong) resolution",
);
const face = s.fontFaces.find((f) => f.family === "Gothic");
assert.ok(face, "font face captured");
assert.equal(face!.baseHref, `${origin}${CSS_PATH}`, "face carries its owning sheet's base");
});
it("resolves ordinary style-rule url() against the sheet too", async () => {
const s = await snap("/page");
assert.ok(
s.cssUrls.includes(`${origin}/marketing-static/_next/static/media/bg.png`),
`expected sheet-relative bg.png, got ${JSON.stringify(s.cssUrls)}`,
);
});
it("resolves an @import-nested sheet's face against the IMPORTED sheet's url", async () => {
const s = await snap("/import-host");
// top.css @imports x.css; the face lives in x.css, so ../media resolves from x.css's dir.
assert.ok(
s.cssUrls.includes(`${origin}${EXPECTED_MEDIA}`),
`@import base must be the imported sheet, got ${JSON.stringify(s.cssUrls)}`,
);
const face = s.fontFaces.find((f) => f.family === "Gothic");
assert.equal(face?.baseHref, `${origin}${CSS_PATH}`);
});
it("falls back to the document base for an inline <style> (no sheet href)", async () => {
await page.goto(`${origin}/page`, { waitUntil: "networkidle" });
await page.evaluate("globalThis.__name = globalThis.__name || ((fn) => fn);");
await page.evaluate(() => {
const st = document.createElement("style");
st.textContent =
'@font-face{font-family:"Inline";src:url("./inline/g.woff2") format("woff2")}';
document.head.appendChild(st);
});
const s = (await page.evaluate(collectPage)) as PageSnapshot;
// document base is /page → ./inline/g.woff2 resolves to /inline/g.woff2 (page-relative).
assert.ok(
s.cssUrls.includes(`${origin}/inline/g.woff2`),
`inline <style> resolves against the document, got ${JSON.stringify(s.cssUrls)}`,
);
const face = s.fontFaces.find((f) => f.family === "Inline");
assert.equal(face?.baseHref, undefined, "inline face has no sheet href");
});
});
+188
View File
@@ -0,0 +1,188 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { letterSpacingEquivalent, normHref, countVisibleInCaptureHiddenInClone, gate2Assets } from "../src/validate/gates.js";
import { servedAssetExists } from "../src/validate/validate.js";
import type { AssetGraph } from "../src/infer/assets.js";
import type { FontGraph } from "../src/infer/fonts.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
// collapses every javascript: href — on either side — to `#`, letting the two sides match.
describe("normHref collapses javascript: hrefs to # (FIX 5)", () => {
const origin = "https://example.test";
it("normalizes a javascript: source href to #", () => {
assert.equal(normHref("Javascript:{}", origin), "#");
assert.equal(normHref("javascript:void(0)", origin), "#");
});
it("makes a javascript: source match the emitted # value", () => {
assert.equal(normHref("javascript:{}", origin), normHref("#", origin), "source and clone agree");
});
it("still distinguishes a real fragment from a full URL", () => {
assert.equal(normHref("#top", origin), "#top");
assert.equal(normHref("https://example.test/x/", origin), "https://example.test/x");
});
});
// Chromium serializes a computed `letter-spacing: 0` back as the keyword `normal`. The emitter, after
// snapping a sub-0.1px authored tracking to 0, ships `letter-spacing: 0px` — which the CLONE then
// reports as `normal` too, but a source that authored an explicit near-zero px can land on either
// spelling. Gate 4 must treat `normal` and `0px` as equal, or every such node fails on spelling alone.
describe("gate4 letterSpacing normal ↔ 0px normalization", () => {
it("treats `normal` and `0px` as equivalent", () => {
assert.ok(letterSpacingEquivalent("normal", "0px"));
assert.ok(letterSpacingEquivalent("0px", "normal"));
});
it("treats `normal` and `normal` as equivalent", () => {
assert.ok(letterSpacingEquivalent("normal", "normal"));
});
it("treats a sub-2px authored tracking vs `normal` as equivalent (within ±2px)", () => {
// source computed `-0.08px`, clone serialized `normal` — a 0.08px delta, well within tolerance.
assert.ok(letterSpacingEquivalent("-0.08px", "normal"));
assert.ok(letterSpacingEquivalent("normal", "-0.0375px"));
});
it("still equates two close real px values (0.24px vs 0.2px)", () => {
assert.ok(letterSpacingEquivalent("-0.24px", "-0.2px"));
});
it("still FAILS a genuine tracking difference beyond ±2px", () => {
assert.ok(!letterSpacingEquivalent("4px", "normal"));
assert.ok(!letterSpacingEquivalent("normal", "-3px"));
assert.ok(!letterSpacingEquivalent("6px", "2px"));
});
it("passes when the source did not constrain the property (undefined)", () => {
assert.ok(letterSpacingEquivalent(undefined, "0px"));
});
});
// 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);
});
});
// FIX 3 — an aborted srcset-candidate image fetch (requestfailed, file present on disk) must not be
// counted as a "404" asset failure. servedAssetExists maps a failed URL to a file under the served
// export exactly as serveStatic does; the assetFailed filter in validate.ts uses it to excuse
// aborted fetches whose file exists, while genuine misses (>= 400, or file absent) still fail.
describe("servedAssetExists (FIX 3 aborted-image excuse)", () => {
const root = mkdtempSync(join(tmpdir(), "served-out-"));
mkdirSync(join(root, "assets", "cloned", "images"), { recursive: true });
writeFileSync(join(root, "assets", "cloned", "images", "present.webp"), "RIFFxxxxWEBP");
it("resolves a URL to a present file under the served root", () => {
assert.equal(servedAssetExists(root, "http://127.0.0.1:5000/assets/cloned/images/present.webp"), true);
});
it("returns false when the file is absent from the served root (a genuine miss)", () => {
assert.equal(servedAssetExists(root, "http://127.0.0.1:5000/assets/cloned/images/absent.webp"), false);
});
it("ignores query strings when resolving to disk", () => {
assert.equal(servedAssetExists(root, "http://127.0.0.1:5000/assets/cloned/images/present.webp?206w"), true);
});
it("does not escape the served root via .. traversal", () => {
assert.equal(servedAssetExists(root, "http://127.0.0.1:5000/../../../etc/passwd"), false);
});
it("an unparseable URL is treated as a genuine miss", () => {
assert.equal(servedAssetExists(root, "not-a-url"), false);
});
});
describe("gate2Assets failed-asset message (FIX 3)", () => {
const emptyAssets: AssetGraph = { entries: [], byUrl: new Map() };
const emptyFonts: FontGraph = { entries: [], css: "" };
it("passes when no generated asset refs are missing", () => {
const r = gate2Assets(emptyAssets, emptyFonts, { remoteRefs: [], failed404: [] });
assert.equal(r.pass, true);
assert.equal(r.metrics.failed404, 0);
});
it("fails and reports missing refs (not '404') when a genuine miss is passed", () => {
const r = gate2Assets(emptyAssets, emptyFonts, { remoteRefs: [], failed404: ["failed http://127.0.0.1:5000/assets/cloned/images/absent.webp"] });
assert.equal(r.pass, false);
assert.equal(r.metrics.failed404, 1);
assert.ok(r.issues.some((i) => i.includes("generated asset refs missing")), `expected 'missing' wording, got: ${r.issues.join(" | ")}`);
});
});
+44
View File
@@ -0,0 +1,44 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { resolveHtmlBg, htmlBgRule } from "../src/generate/app.js";
// Regression: when the source layers a full-bleed backdrop at z-index<0 behind a
// body-propagated canvas background, emitting an opaque `html { background }`
// flips CSS 2.1 §14.2 background propagation and buries that backdrop under the
// body box. `html` must only paint when the SOURCE html actually painted.
describe("resolveHtmlBg — source-faithful canvas propagation", () => {
it("transparent html + colored body → no html background (body propagates to canvas)", () => {
const htmlBg = resolveHtmlBg({ htmlBg: "rgba(0, 0, 0, 0)", bodyBg: "rgb(246, 244, 238)" });
assert.equal(htmlBg, null);
assert.equal(htmlBgRule(htmlBg), "");
});
it("colored html → html background kept", () => {
const htmlBg = resolveHtmlBg({ htmlBg: "rgb(10, 20, 30)", bodyBg: "rgb(246, 244, 238)" });
assert.equal(htmlBg, "rgb(10, 20, 30)");
assert.equal(htmlBgRule(htmlBg), "html { background: rgb(10, 20, 30); }\n");
});
it("both transparent → #ffffff fallback (never a UA-default canvas)", () => {
const htmlBg = resolveHtmlBg({ htmlBg: "rgba(0, 0, 0, 0)", bodyBg: "rgba(0, 0, 0, 0)" });
assert.equal(htmlBg, "#ffffff");
assert.equal(htmlBgRule(htmlBg), "html { background: #ffffff; }\n");
});
it("missing perViewport entry → #ffffff fallback", () => {
const htmlBg = resolveHtmlBg(undefined);
assert.equal(htmlBg, "#ffffff");
});
it("undefined html + colored body (only body captured) → no html rule", () => {
// Body-only styling is the common case the old fallback existed for; it must
// still leave html transparent so the body color reaches the canvas.
const htmlBg = resolveHtmlBg({ bodyBg: "rgb(0, 0, 0)" });
assert.equal(htmlBg, null);
});
it("deterministic: identical input yields identical output", () => {
const pv = { htmlBg: "rgba(0, 0, 0, 0)", bodyBg: "rgb(1, 2, 3)" };
assert.equal(resolveHtmlBg(pv), resolveHtmlBg({ ...pv }));
});
});
+65
View File
@@ -72,6 +72,23 @@ describe("planForFrameUrl", () => {
assert.equal(planForFrameUrl("https://static-forms.klaviyo.com/forms/abc"), "graft"); assert.equal(planForFrameUrl("https://static-forms.klaviyo.com/forms/abc"), "graft");
assert.equal(planForFrameUrl("http://127.0.0.1:4001/iframe-embed.html"), "graft"); assert.equal(planForFrameUrl("http://127.0.0.1:4001/iframe-embed.html"), "graft");
}); });
it("SKIPS full-viewport promo POPUP CREATIVE hosts (Attentive/Recart/Wunderkind overlays)", () => {
// These vendor hosts serve a full-viewport interstitial creative — grafting pours the
// popup's copy into the DOM/text channel and paints the modal over the real page.
assert.equal(planForFrameUrl("https://creatives.attn.tv/creative/12345"), "skip");
assert.equal(planForFrameUrl("https://creative.attn.tv/loader/x"), "skip");
assert.equal(planForFrameUrl("https://app.recart.com/popup/abc"), "skip");
assert.equal(planForFrameUrl("https://tag.wunderkind.co/creative"), "skip");
assert.equal(planForFrameUrl("https://api.bounceexchange.com/creative"), "skip");
});
it("STILL grafts INLINE embed hosts even for the same vendors (inline forms are a feature)", () => {
// Caution guard: a deliberately-grafted inline signup form must never be caught by the
// popup-creative skip list. Inline-form hosts differ from overlay-creative hosts.
assert.equal(planForFrameUrl("https://static-forms.klaviyo.com/forms/abc"), "graft");
assert.equal(planForFrameUrl("https://manage.kmail-lists.com/subscriptions/subscribe"), "graft");
});
}); });
describe("graftFrameIntoSnapshot", () => { describe("graftFrameIntoSnapshot", () => {
@@ -146,6 +163,54 @@ describe("graftFrameIntoSnapshot", () => {
graftFrameIntoSnapshot(host, { idx: 0, contentX: 40, contentY: 30 }, frame); graftFrameIntoSnapshot(host, { idx: 0, contentX: 40, contentY: 30 }, frame);
assert.ok(host.keyframes.includes("@keyframes spin { to { transform: rotate(360deg); } }")); assert.ok(host.keyframes.includes("@keyframes spin { to { transform: rotate(360deg); } }"));
}); });
// A frame's `position: fixed` chrome pins to the FRAME viewport, not the page's. Grafted
// as plain DOM, `fixed` would resolve against the main page viewport and escape the
// iframe's clip box — the observed regression: a gallery embed's
// `fixed inset-0 z-[-1]` dark backdrop painted the whole clone dark. It must be demoted
// to `absolute` so the host box contains and clips it.
it("demotes frame `position: fixed` to `absolute` so it stays inside the iframe box", () => {
const backdrop = raw("div", { class: "backdrop" }, [], {
computed: { display: "block", position: "fixed" },
bbox: { x: 0, y: 0, width: 320, height: 160 },
});
const frame = pageSnap(raw("body", {}, [backdrop]), "https://frame.test/embed");
const host = mkHost();
graftFrameIntoSnapshot(host, { idx: 0, contentX: 40, contentY: 30 }, frame);
const wrapper = findFrameNode(host.root, 0)!.children[0] as RawNode;
const grafted = wrapper.children[0] as RawNode;
assert.equal(grafted.computed.position, "absolute");
});
it("demotes frame `position: sticky` to `relative` (no sticking to the page scroll)", () => {
const bar = raw("div", { class: "sticky-bar" }, [], {
computed: { display: "block", position: "sticky" },
bbox: { x: 0, y: 0, width: 320, height: 40 },
});
const frame = pageSnap(raw("body", {}, [bar]), "https://frame.test/embed");
const host = mkHost();
graftFrameIntoSnapshot(host, { idx: 0, contentX: 40, contentY: 30 }, frame);
const wrapper = findFrameNode(host.root, 0)!.children[0] as RawNode;
const grafted = wrapper.children[0] as RawNode;
assert.equal(grafted.computed.position, "relative");
});
it("promotes a `static` host to `relative` so demoted absolutes anchor to the frame box", () => {
const host = mkHost(); // iframe host computed.position defaults to "static"
graftFrameIntoSnapshot(host, { idx: 0, contentX: 40, contentY: 30 }, mkFrame());
assert.equal(findFrameNode(host.root, 0)!.computed.position, "relative");
});
it("leaves an already-positioned host's position untouched", () => {
const host = pageSnap(raw("body", {}, [
raw("iframe", { "data-ditto-frame": "0", src: "https://frame.test/embed" }, [], {
computed: { display: "block", position: "absolute" },
bbox: { x: 40, y: 30, width: 320, height: 160 },
}),
]));
graftFrameIntoSnapshot(host, { idx: 0, contentX: 40, contentY: 30 }, mkFrame());
assert.equal(findFrameNode(host.root, 0)!.computed.position, "absolute");
});
}); });
// ---- Cross-origin integration: capture → snapshot graft → IR → generated tag ---- // ---- Cross-origin integration: capture → snapshot graft → IR → generated tag ----
+90
View File
@@ -0,0 +1,90 @@
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import { chromium, type Browser, type Page } from "playwright";
import { captureInteractions, tagElements, diffStyle } from "../src/capture/interactions.js";
describe("diffStyle", () => {
it("returns only the changed keys, with the b-side value", () => {
const a = { color: "rgb(0, 0, 0)", opacity: "1", transform: "none" };
const b = { color: "rgb(255, 0, 0)", opacity: "1", transform: "scale(1.1)" };
assert.deepEqual(diffStyle(a, b), { color: "rgb(255, 0, 0)", transform: "scale(1.1)" });
});
it("is empty when nothing changed", () => {
const a = { color: "rgb(0, 0, 0)", opacity: "1" };
assert.deepEqual(diffStyle(a, { ...a }), {});
});
it("only reports keys present in b (b drives the comparison)", () => {
// a resting style that carries an extra key does not fabricate a delta.
assert.deepEqual(diffStyle({ color: "red", extra: "x" }, { color: "red" }), {});
});
});
describe("captureInteractions hover capture (occlusion-immune)", () => {
let browser: Browser;
let page: Page;
before(async () => {
browser = await chromium.launch();
page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
});
after(async () => {
await browser.close();
});
const setup = async (html: string) => {
await page.setContent(html);
await page.evaluate("globalThis.__name = globalThis.__name || ((fn) => fn);");
await tagElements(page);
};
it("captures an authored :hover state even when a transparent full-viewport overlay covers the target", async () => {
// Exact regression for the empty-hover-capture bug: a modern builder stack parks a
// transparent fixed layer over the whole page. page.hover moves the real cursor to the
// link's centre, the point lands on the overlay, and `:hover` never reaches the link — so
// pointer-based probing captured ZERO hover states. Forcing the pseudo-class fixes it.
await setup(`
<style>
a.cta { color: rgb(0, 0, 0); background-color: rgb(255, 255, 255); }
a.cta:hover { color: rgb(255, 0, 0); background-color: rgb(0, 0, 255); }
.overlay { position: fixed; inset: 0; z-index: 9999; background: transparent; }
</style>
<a class="cta" href="#">Shop now</a>
<div class="overlay"></div>`);
const cap = await captureInteractions(page, { maxCandidates: 50 });
const hoverCaps = Object.keys(cap.hover);
assert.ok(hoverCaps.length >= 1, "at least one hover state is captured through the overlay");
const delta = cap.hover[hoverCaps[0]!]!;
assert.equal(delta.color, "rgb(255, 0, 0)", "hover color change is captured");
assert.equal(delta.backgroundColor, "rgb(0, 0, 255)", "hover background change is captured");
});
it("captures :hover on a cursor:pointer element that is not a native interactive", async () => {
await setup(`
<style>
.card { cursor: pointer; border: 2px solid rgb(1, 1, 1); }
.card:hover { border-color: rgb(9, 9, 9); }
</style>
<div class="card" style="width:200px;height:120px;">Card</div>`);
const cap = await captureInteractions(page, { maxCandidates: 50 });
const found = Object.values(cap.hover).some((d) => d.borderTopColor === "rgb(9, 9, 9)");
assert.ok(found, "a cursor:pointer card's authored :hover border change is captured");
});
it("records no hover delta for an element with no authored :hover (self-limiting)", async () => {
await setup(`
<a class="plain" href="#" style="color:rgb(0,0,0);">No hover</a>`);
const cap = await captureInteractions(page, { maxCandidates: 50 });
assert.equal(Object.keys(cap.hover).length, 0, "no authored hover -> empty hover map");
});
it("restores the resting state after probing (forced pseudo-state cleared)", async () => {
await setup(`
<style>
a.cta { color: rgb(0, 0, 0); }
a.cta:hover { color: rgb(255, 0, 0); }
</style>
<a class="cta" href="#">Link</a>`);
await captureInteractions(page, { maxCandidates: 50 });
const resting = await page.evaluate(() => getComputedStyle(document.querySelector("a.cta")!).color);
assert.equal(resting, "rgb(0, 0, 0)", "the page is left in its resting (non-hover) state");
});
});
+215 -1
View File
@@ -4,7 +4,7 @@ import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import type { RawNode, RawChild } from "../src/capture/walker.js"; import type { RawNode, RawChild } from "../src/capture/walker.js";
import { buildIR, isTextChild, type IRNode } from "../src/normalize/ir.js"; import { buildIR, isTextChild, neutralizeAnimatedTransforms, type IRNode } from "../src/normalize/ir.js";
const VPS = [375, 1280]; const VPS = [375, 1280];
@@ -98,3 +98,217 @@ describe("IR prune keeps <source> media candidates", () => {
assert.equal(findByTag(root, "picture"), null); assert.equal(findByTag(root, "picture"), null);
}); });
}); });
describe("IR drops font-metric probe nodes (fix 4)", () => {
it("excludes a walker-tagged probe node from the IR, keeping its siblings", () => {
const probe: RawNode = {
...raw("div", { class: "font-probe" }, [{ text: "Mgy" }]),
probe: true,
};
const real = raw("h1", { class: "title" }, [{ text: "Heading" }]);
const body = raw("body", {}, [real, probe]);
const root = buildFixtureIR(body);
const kept = root.children.filter((c) => !isTextChild(c)).map((c) => (c as IRNode).tag);
assert.deepEqual(kept, ["h1"], "probe div is dropped, the real heading survives");
// Its text must not leak into the tree either.
assert.equal(findByTag(root, "div"), null);
});
});
describe("IR drops popup-vendor OVERLAY containers, keeps inline embedded forms", () => {
it("drops an email-capture popup overlay container (vendor overlay id) but keeps a real section", () => {
const overlay = raw("div", { id: "attentive_overlay" }, [
raw("iframe", { id: "attentive_creative", src: "https://creatives.attn.tv/x" }),
]);
const real = raw("section", { class: "hero" }, [raw("h1", {}, [{ text: "Real content" }])]);
const body = raw("body", {}, [real, overlay]);
const root = buildFixtureIR(body);
const kept = root.children.filter((c) => !isTextChild(c)).map((c) => (c as IRNode).tag);
assert.deepEqual(kept, ["section"], "the attentive overlay subtree is dropped");
assert.equal(findByTag(root, "iframe"), null, "the popup creative iframe never reaches the IR");
});
it("does NOT drop an INLINE embedded signup form that merely carries a vendor name (feature, not popup)", () => {
// A deliberately-grafted inline Klaviyo form: a real, sized form embedded in page content. Its
// class names the vendor but is NOT an overlay-container marker, so it must survive.
const inlineForm = raw("div", { class: "klaviyo-form klaviyo-form-inline" }, [
raw("form", { id: "email-signup" }, [raw("input", { type: "email" })]),
]);
const body = raw("body", {}, [inlineForm]);
const root = buildFixtureIR(body);
assert.ok(findByTag(root, "form"), "the inline signup form survives the prune");
assert.ok(findByTag(root, "input"), "its input survives too");
});
});
// Defect C (normalize side) — an infinite CSS animation gated to a breakpoint (a Webflow `max-lg`
// marquee) is `animation:none` at the widths it does not run, but the browser still reports the last
// FROZEN translateX there. `neutralizeAnimatedTransforms` zeroes the transform at EVERY viewport
// once a genuine infinite animation is present at some width, so no frozen mid-scroll offset (the
// base residue or the animated phases) is banded and clips the strip offscreen at rest.
describe("neutralizeAnimatedTransforms (Defect C)", () => {
it("zeroes the transform at every viewport when an infinite animation runs at some width", () => {
const byVp = {
375: { animationName: "track", animationIterationCount: "infinite", transform: "matrix(1, 0, 0, 1, -284.025, 0)" } as any,
768: { animationName: "track", animationIterationCount: "infinite", transform: "matrix(1, 0, 0, 1, -280.982, 0)" } as any,
1280: { animationName: "none", animationIterationCount: "1", transform: "matrix(1, 0, 0, 1, -9.94731, 0)" } as any,
};
neutralizeAnimatedTransforms(byVp as any);
assert.equal(byVp[375].transform, "none");
assert.equal(byVp[768].transform, "none");
assert.equal(byVp[1280].transform, "none", "the non-animated base residue is neutralized too");
});
it("leaves a static transform untouched when NO viewport carries an infinite animation", () => {
const byVp = {
375: { animationName: "none", animationIterationCount: "1", transform: "matrix(1, 0, 0, 1, -40, 0)" } as any,
1280: { animationName: "none", animationIterationCount: "1", transform: "matrix(1, 0, 0, 1, -40, 0)" } as any,
};
neutralizeAnimatedTransforms(byVp as any);
assert.equal(byVp[375].transform, "matrix(1, 0, 0, 1, -40, 0)", "a deliberate static offset is preserved");
assert.equal(byVp[1280].transform, "matrix(1, 0, 0, 1, -40, 0)");
});
it("does not fire for a FINITE animation (a one-shot entrance, not a perpetual marquee)", () => {
const byVp = {
375: { animationName: "slideIn", animationIterationCount: "1", transform: "matrix(1, 0, 0, 1, -100, 0)" } as any,
1280: { animationName: "slideIn", animationIterationCount: "1", transform: "matrix(1, 0, 0, 1, -100, 0)" } as any,
};
neutralizeAnimatedTransforms(byVp as any);
assert.equal(byVp[375].transform, "matrix(1, 0, 0, 1, -100, 0)", "finite animations own a settled end state, not a perpetual phase");
});
});
/** A RawNode with an explicit computed style and bbox (same at every viewport). Unlike `raw()` this
* lets a test author invisible-but-laid-out boxes, transforms, margins, and positions directly. */
function rawS(
tag: string,
computed: Record<string, string>,
bbox: { x: number; y: number; width: number; height: number },
children: RawChild[] = [],
visible = true,
attrs: Record<string, string> = {},
): RawNode {
return { tag, attrs, computed, bbox, visible, children };
}
// FIX 2 — an in-flow `visibility:hidden` box with a nonzero border box is load-bearing geometry: it
// reserves the row height its absolutely-positioned siblings paint into. It is invisible (so the plain
// visibility prune would drop it), but dropping it collapses the column. It must survive as a sized
// placeholder; a genuinely empty display:none-everywhere box must still prune.
describe("IR prune keeps sized invisible (visibility:hidden) spacers (FIX 2)", () => {
it("keeps an in-flow visibility:hidden ghost column with a nonzero bbox", () => {
const ghost = rawS(
"div",
{ display: "flex", position: "static", visibility: "hidden" },
{ x: 0, y: 0, width: 650, height: 723 },
// its children are themselves invisible (they set no visibility:visible) — the box is a spacer
[rawS("img", { display: "block", position: "static", visibility: "hidden" }, { x: 0, y: 0, width: 650, height: 240 }, [], false)],
false,
);
// the real content is an absolutely-positioned sibling layered over the ghost's reserved space
const painted = rawS("img", { display: "block", position: "absolute", visibility: "visible" }, { x: 0, y: 0, width: 650, height: 723 }, [], true);
const column = rawS("div", { display: "block", position: "relative", visibility: "visible" }, { x: 0, y: 0, width: 650, height: 723 }, [ghost, painted], true);
const body = raw("body", {}, [column]);
const root = buildFixtureIR(body);
const kept = findByTag(root, "div");
assert.ok(kept, "the column survives");
// The ghost div (a second nested div) must still be present as a sized placeholder.
const divs: IRNode[] = [];
const collect = (n: IRNode): void => { if (n.tag === "div") divs.push(n); for (const c of n.children) if (!isTextChild(c)) collect(c as IRNode); };
collect(root);
// column + ghost = 2 divs (body is not a div)
assert.equal(divs.length, 2, "the visibility:hidden ghost column is retained, not pruned");
const ghostIR = divs.find((d) => d.computedByVp[1280]?.visibility === "hidden");
assert.ok(ghostIR, "the retained ghost carries visibility:hidden");
assert.ok(ghostIR!.bboxByVp[1280]!.height > 0, "the ghost keeps its load-bearing height");
});
it("still prunes a display:none-everywhere empty box (no resurrection)", () => {
const hidden = rawS("div", { display: "none", position: "static", visibility: "visible" }, { x: 0, y: 0, width: 0, height: 0 }, [], false);
const body = raw("body", {}, [rawS("section", { display: "block", position: "static", visibility: "visible" }, { x: 0, y: 0, width: 640, height: 100 }, [hidden], true)]);
const root = buildFixtureIR(body);
assert.equal(findByTag(root, "div"), null, "a display:none-everywhere box stays pruned");
});
it("does not keep an out-of-flow (absolute) visibility:hidden box as a spacer", () => {
// Absolute boxes take no space in flow, so an invisible absolute box is not load-bearing geometry.
const absHidden = rawS("div", { display: "block", position: "absolute", visibility: "hidden" }, { x: 0, y: 0, width: 300, height: 300 }, [], false);
const body = raw("body", {}, [rawS("section", { display: "block", position: "static", visibility: "visible" }, { x: 0, y: 0, width: 640, height: 100 }, [absHidden], true)]);
const root = buildFixtureIR(body);
assert.equal(findByTag(root, "div"), null, "an out-of-flow invisible box is not kept as a spacer");
});
});
// FIX 3 — a settled loop carousel parks its track with a baked translateX and prepends invisible clone
// slides that occupy exactly [translateX, 0]; the first REAL slide paints at x=0. Pruning drops the
// off-screen clones, but the baked translateX would survive verbatim and push every real slide
// offscreen-left. Re-anchor the track's translateX by the aggregate outer width of the dropped leading
// clones so the first kept slide lands at its captured position.
describe("IR prune re-anchors a translated track after dropping leading clones (FIX 3)", () => {
it("re-anchors translateX toward 0 by the dropped leading clone width", () => {
// Three off-screen leading loop clones, each 200px wide (translateX = -600 = -(3×200)); two reals.
// The clones are OFF-SCREEN (visible:false via the off-screen test), NOT visibility:hidden — so
// they prune and the FIX-2 sized-invisible-spacer carve-out (which requires visibility:hidden) does
// not resurrect them. This mirrors a real settled Splide loop's leading clones.
const clone = (i: number): RawNode =>
rawS("li", { display: "block", position: "static", visibility: "visible" }, { x: -600 + i * 200, y: 0, width: 200, height: 100 }, [], false);
const real = (i: number): RawNode =>
rawS("li", { display: "block", position: "static", visibility: "visible" }, { x: i * 200, y: 0, width: 200, height: 100 }, [{ text: `real${i}` }], true);
const track = rawS(
"ul",
{ display: "flex", position: "relative", visibility: "visible", transform: "matrix(1, 0, 0, 1, -600, 0)" },
{ x: 0, y: 0, width: 1000, height: 100 },
[clone(0), clone(1), clone(2), real(0), real(1)],
true,
);
const body = raw("body", {}, [track]);
const root = buildFixtureIR(body);
const ul = findByTag(root, "ul")!;
// The clones are pruned (invisible everywhere), leaving the two real slides.
const slides = ul.children.filter((c) => !isTextChild(c));
assert.equal(slides.length, 2, "only the real slides survive");
// The baked -600 translate is re-anchored to 0 (600 + 3×200).
assert.equal(ul.computedByVp[1280]!.transform, "matrix(1, 0, 0, 1, 0, 0)", "track translateX re-anchored to origin");
assert.equal(ul.computedByVp[375]!.transform, "matrix(1, 0, 0, 1, 0, 0)", "re-anchored at every viewport");
});
it("leaves a track untouched when no leading children are dropped", () => {
const real = (i: number): RawNode =>
rawS("li", { display: "block", position: "static", visibility: "visible" }, { x: i * 200, y: 0, width: 200, height: 100 }, [{ text: `real${i}` }], true);
const track = rawS(
"ul",
{ display: "flex", position: "relative", visibility: "visible", transform: "matrix(1, 0, 0, 1, -120, 0)" },
{ x: 0, y: 0, width: 400, height: 100 },
[real(0), real(1)],
true,
);
const body = raw("body", {}, [track]);
const root = buildFixtureIR(body);
const ul = findByTag(root, "ul")!;
assert.equal(ul.computedByVp[1280]!.transform, "matrix(1, 0, 0, 1, -120, 0)", "a track with no dropped leading children keeps its offset");
});
it("does not re-anchor when the dropped leading siblings are out of flow", () => {
const absClone = (i: number): RawNode =>
rawS("li", { display: "block", position: "absolute", visibility: "hidden" }, { x: -400 + i * 200, y: 0, width: 200, height: 100 }, [], false);
const real = (i: number): RawNode =>
rawS("li", { display: "block", position: "static", visibility: "visible" }, { x: i * 200, y: 0, width: 200, height: 100 }, [{ text: `real${i}` }], true);
const track = rawS(
"ul",
{ display: "flex", position: "relative", visibility: "visible", transform: "matrix(1, 0, 0, 1, -80, 0)" },
{ x: 0, y: 0, width: 400, height: 100 },
[absClone(0), absClone(1), real(0), real(1)],
true,
);
const body = raw("body", {}, [track]);
const root = buildFixtureIR(body);
const ul = findByTag(root, "ul")!;
assert.equal(ul.computedByVp[1280]!.transform, "matrix(1, 0, 0, 1, -80, 0)", "out-of-flow clones don't advance the track, so no re-anchor");
});
});
+118
View File
@@ -0,0 +1,118 @@
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/);
});
// Sizing: the host box carries the captured per-viewport height; only an absolutely-filled overlay
// inherits that definite box. If the reveal reverted the overlay to static flow, the player's svg
// (style height:100%) would collapse to auto and inflate to the source aspect (a portrait viewBox
// then oversizes far past the captured, letterboxed box). So the overlay must STAY absolute/inset
// for its whole life, and the reveal must NOT clear those styles.
it("mounts the overlay as an absolute, inset-0 layer", () => {
assert.match(DITTO_LOTTIE_TSX, /mount\.style\.position\s*=\s*["']absolute["']/);
assert.match(DITTO_LOTTIE_TSX, /mount\.style\.inset\s*=\s*["']0["']/);
});
it("keeps the overlay absolute after the swap (never reverts it to static flow)", () => {
// The reveal must not strip the overlay's positioning — a `mount.style.position = ""` (or inset)
// un-pins it from the host's captured height and lets the runtime svg inflate by aspect.
assert.ok(
!/mount\.style\.position\s*=\s*["']["']/.test(DITTO_LOTTIE_TSX),
"reveal must not clear mount.style.position (would un-pin the overlay → aspect inflation)",
);
assert.ok(
!/mount\.style\.inset\s*=\s*["']["']/.test(DITTO_LOTTIE_TSX),
"reveal must not clear mount.style.inset (would un-pin the overlay → aspect inflation)",
);
});
it("marks the host with data-ditto-lottie so emitted CSS can force the runtime svg to fit", () => {
assert.match(DITTO_LOTTIE_TSX, /setAttribute\(\s*["']data-ditto-lottie["']/);
});
// The player creates its <svg>/<canvas> at runtime WITHOUT a data-cid, so the DOM/media gate can't
// map it back to the captured node. The reveal must forward the discarded placeholder's data-cid
// onto the runtime-rendered element (validation-agnostic; no gate special-casing).
it("forwards the placeholder's data-cid onto the runtime-rendered svg/canvas before the swap", () => {
// reads the placeholder cid, then reassigns it to the rendered svg/canvas inside the mount
assert.match(DITTO_LOTTIE_TSX, /getAttribute\(\s*["']data-cid["']\s*\)/);
assert.match(DITTO_LOTTIE_TSX, /mount\.querySelector\(\s*["']svg,\s*canvas["']\s*\)/);
assert.match(DITTO_LOTTIE_TSX, /setAttribute\(\s*["']data-cid["']\s*,/);
// the cid capture must precede its removal so the placeholder is still in the DOM when read
const readIdx = DITTO_LOTTIE_TSX.indexOf('getAttribute("data-cid")');
const removeIdx = DITTO_LOTTIE_TSX.indexOf("removeChild");
assert.ok(readIdx >= 0 && removeIdx >= 0 && readIdx < removeIdx, "must read the placeholder cid before removing it");
});
});
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);
});
});
+311
View File
@@ -0,0 +1,311 @@
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import { chromium, type Browser, type Page } from "playwright";
import { finalizeOverlaysInPage, clickDismissInPage } from "../src/capture/capture.js";
import { irContentExtent, type IRNode, type BBox, type StyleMap } from "../src/normalize/ir.js";
import { gatePollution } from "../src/validate/gates.js";
import type { CaptureResult } from "../src/capture/capture.js";
import type { IR } from "../src/normalize/ir.js";
// tsx/esbuild wraps serialized page functions with __name()/__defProp; shim so page.evaluate works.
const ESBUILD_SHIM =
"globalThis.__name = globalThis.__name || ((fn) => fn);" +
"globalThis.__defProp = globalThis.__defProp || Object.defineProperty;";
const VW = 1280, VH = 800;
// ---------------------------------------------------------------------------
// finalizeOverlaysInPage — effective-z resolution, overlay-unit grouping,
// lock-relaxed z gate, shadow-root traversal, loud-failure blocking.
// ---------------------------------------------------------------------------
describe("capture: finalizeOverlaysInPage overlay detection", () => {
let browser: Browser;
before(async () => { browser = await chromium.launch(); });
after(async () => { await browser.close(); });
const newPage = async (html: string): Promise<Page> => {
const page = await browser.newPage();
await page.setViewportSize({ width: VW, height: VH });
await page.addInitScript(ESBUILD_SHIM);
await page.setContent(html);
await page.evaluate(ESBUILD_SHIM);
return page;
};
it("groups a 0-height max-z wrapper + its full-viewport z:auto iframe as ONE overlay and removes both (scroll-locked)", async () => {
// Mirrors the Attentive structure: body scroll-locked; a fixed z=INT_MAX wrapper of height 0
// whose child is a fixed full-viewport iframe with z-index:auto. Neither passes the naive
// per-element area+z gate alone (wrapper: area 0; iframe: z parses to 0).
const page = await newPage(`
<style>
html,body{margin:0}
body{position:absolute;top:0;left:0;right:0;height:${VH}px;overflow:hidden}
#wrap{position:fixed;top:${VH}px;left:0;width:${VW}px;height:0;z-index:2147483647}
#creative{position:fixed;top:0;left:0;width:${VW}px;height:${VH}px;z-index:auto;background:#000}
</style>
<main>real page content</main>
<div id="wrap"><iframe id="creative"></iframe></div>
`);
const res = await page.evaluate(finalizeOverlaysInPage);
assert.equal(res.removed, 1, "the wrapper+iframe unit is removed as one node");
assert.ok(res.removedLabels.some((l) => l.includes("wrap")), `removed the max-z wrapper (got ${JSON.stringify(res.removedLabels)})`);
const gone = await page.evaluate(() => !document.getElementById("wrap") && !document.getElementById("creative"));
assert.ok(gone, "both the wrapper and its inner iframe are gone");
await page.close();
});
it("detects a z=50 full-viewport fixed dialog ONLY because the page is scroll-locked (lock relaxes the z>=100 gate)", async () => {
const page = await newPage(`
<style>
html,body{margin:0}
body{overflow:hidden}
#ccpa{position:fixed;inset:0;width:${VW}px;height:${VH}px;z-index:50;background:rgba(0,0,0,.7)}
</style>
<main>content</main>
<div id="ccpa">California Residents Privacy Rights</div>
`);
const res = await page.evaluate(finalizeOverlaysInPage);
assert.equal(res.removed, 1, "the z=50 dialog is removed on a locked page");
await page.close();
});
it("does NOT remove a z=50 full-viewport fixed layer when the page is NOT scroll-locked (z floor preserved)", async () => {
const page = await newPage(`
<style>
html,body{margin:0}
#hero{position:fixed;inset:0;width:${VW}px;height:${VH}px;z-index:50;background:#eee}
</style>
<div id="hero">a legit fixed hero, page scrolls normally</div>
`);
const res = await page.evaluate(finalizeOverlaysInPage);
assert.equal(res.removed, 0, "no removal off a locked page — the z>=100 floor guards legit fixed content");
assert.equal(res.blocking, false);
await page.close();
});
it("removes a promo popup mounted inside an OPEN shadow root and reports the host label", async () => {
const page = await newPage(`
<style>html,body{margin:0}body{overflow:hidden}</style>
<main>content</main>
<div id="recart-popup-root"></div>
<script>
const host = document.getElementById("recart-popup-root");
const root = host.attachShadow({ mode: "open" });
const layer = document.createElement("div");
layer.style.cssText = "position:fixed;inset:0;width:${VW}px;height:${VH}px;z-index:999;background:rgba(0,0,0,.6)";
layer.textContent = "Enjoy 15% off";
root.appendChild(layer);
</script>
`);
const res = await page.evaluate(finalizeOverlaysInPage);
assert.equal(res.removed, 1, "the shadow-hosting popup element is removed as one unit");
const gone = await page.evaluate(() => !document.getElementById("recart-popup-root"));
assert.ok(gone, "the shadow host is gone from the light DOM");
await page.close();
});
it("reports blocking=true when the scroll-lock persists even with NO overlay detected (loud failure)", async () => {
// Body is scroll-locked but there is no fixed full-viewport layer to find (e.g. a closed
// shadow root we cannot pierce). A silently-locked capture is polluted → surface it.
const page = await newPage(`
<style>html,body{margin:0}body{overflow:hidden;position:absolute;height:${VH}px}</style>
<main>content</main>
`);
const res = await page.evaluate(finalizeOverlaysInPage);
assert.equal(res.removed, 0);
assert.equal(res.blocking, true, "a persistent lock with nothing removable still reports blocking");
await page.close();
});
it("reports blocking=false and removes nothing on an ordinary unlocked page", async () => {
const page = await newPage(`
<style>html,body{margin:0}</style>
<header>nav</header><main>lots of content</main><footer>footer</footer>
`);
const res = await page.evaluate(finalizeOverlaysInPage);
assert.equal(res.removed, 0);
assert.equal(res.blocking, false);
assert.equal(res.overlaysRemaining, 0);
await page.close();
});
it("protects real sticky page chrome (header/nav) from removal even when detected", async () => {
// A scroll-locked page with a sticky header that happens to be tall — the header is PROTECTED,
// and it isn't full-viewport, so it never even qualifies. Assert nothing is stripped.
const page = await newPage(`
<style>
html,body{margin:0}body{overflow:hidden}
#masthead{position:sticky;top:0;width:${VW}px;height:80px;z-index:1000;background:#fff}
</style>
<header id="masthead">site chrome</header>
<main>content</main>
`);
const res = await page.evaluate(finalizeOverlaysInPage);
const present = await page.evaluate(() => !!document.getElementById("masthead"));
assert.ok(present, "the header survives");
await page.close();
});
});
// ---------------------------------------------------------------------------
// clickDismissInPage — decline/close matchers, shadow-root traversal.
// ---------------------------------------------------------------------------
describe("capture: clickDismissInPage matchers", () => {
let browser: Browser;
before(async () => { browser = await chromium.launch(); });
after(async () => { await browser.close(); });
const run = async (html: string): Promise<string[]> => {
const page = await browser.newPage();
await page.addInitScript(ESBUILD_SHIM);
await page.setContent(html);
await page.evaluate(ESBUILD_SHIM);
const res = await page.evaluate(clickDismissInPage);
await page.close();
return res;
};
it("clicks a Decline button inside an overlay container (email-capture opt-out)", async () => {
const dismissed = await run(`
<div class="signup-overlay" style="position:fixed;inset:0;width:400px;height:400px">
<button onclick="window.__declined=true">Decline</button>
</div>
`);
assert.ok(dismissed.includes("text:decline"), `clicked Decline (got ${JSON.stringify(dismissed)})`);
});
it("clicks an aria-label close (×) inside an overlay container when no accept/decline text matches", async () => {
const dismissed = await run(`
<div id="promo-popup" style="position:fixed;inset:0;width:400px;height:400px">
<button aria-label="Close dialog">×</button>
</div>
`);
assert.ok(dismissed.some((d) => d.startsWith("close:")), `clicked the aria close (got ${JSON.stringify(dismissed)})`);
});
it("matches an overlay/ccpa/privacy container id/class (extended container selector)", async () => {
const dismissed = await run(`
<div id="ccpaPop" style="position:fixed;inset:0;width:400px;height:400px">
<button>Accept</button>
</div>
`);
assert.ok(dismissed.includes("text:accept"), `matched the ccpa container (got ${JSON.stringify(dismissed)})`);
});
it("traverses an open shadow root to find the close control", async () => {
const page = await browser.newPage();
await page.addInitScript(ESBUILD_SHIM);
await page.setContent(`<div id="host"></div>`);
await page.evaluate(ESBUILD_SHIM);
await page.evaluate(() => {
const host = document.getElementById("host")!;
const root = host.attachShadow({ mode: "open" });
root.innerHTML = `<div class="popup-modal" style="position:fixed;inset:0;width:400px;height:400px"><button aria-label="close">×</button></div>`;
});
const dismissed = await page.evaluate(clickDismissInPage);
await page.close();
assert.ok(dismissed.some((d) => d.startsWith("close:")), `found the shadow-root close (got ${JSON.stringify(dismissed)})`);
});
it("never clicks an ordinary page button outside any overlay container", async () => {
const dismissed = await run(`<main><button>Accept</button><a>Continue</a></main>`);
assert.deepEqual(dismissed, [], "no overlay container ⇒ no clicks");
});
});
// ---------------------------------------------------------------------------
// irContentExtent — pure geometry (unclamp / pollution trigger).
// ---------------------------------------------------------------------------
describe("normalize: irContentExtent", () => {
const bbox = (x: number, y: number, w: number, h: number): BBox => ({ x, y, width: w, height: h });
const style = (over: Partial<StyleMap> = {}): StyleMap => ({ position: "static", ...over } as StyleMap);
const node = (over: Partial<IRNode>): IRNode => ({
id: "n", tag: "div", attrs: {},
visibleByVp: { 1280: true }, bboxByVp: { 1280: bbox(0, 0, 100, 100) },
computedByVp: { 1280: style() }, children: [], ...over,
});
it("returns the max in-flow descendant border-box bottom", () => {
const root = node({
tag: "body",
children: [
node({ bboxByVp: { 1280: bbox(0, 0, 1280, 800) } }),
node({ bboxByVp: { 1280: bbox(0, 800, 1280, 4398) } }), // footer bottom = 5198
],
});
assert.equal(irContentExtent(root, 1280), 5198);
});
it("excludes out-of-flow (fixed/absolute) and floated boxes", () => {
const root = node({
tag: "body",
children: [
node({ bboxByVp: { 1280: bbox(0, 0, 1280, 900) } }),
node({ computedByVp: { 1280: style({ position: "fixed" }) }, bboxByVp: { 1280: bbox(0, 0, 1280, 9999) } }),
node({ computedByVp: { 1280: style({ float: "left" } as Partial<StyleMap>) }, bboxByVp: { 1280: bbox(0, 0, 1280, 8888) } }),
],
});
assert.equal(irContentExtent(root, 1280), 900, "fixed + float extents ignored");
});
it("descends into nested in-flow subtrees", () => {
const root = node({
tag: "body",
children: [node({ children: [node({ bboxByVp: { 1280: bbox(0, 0, 500, 3000) } })] })],
});
assert.equal(irContentExtent(root, 1280), 3000);
});
it("ignores nodes invisible at the queried viewport", () => {
const root = node({
tag: "body",
children: [node({ visibleByVp: { 1280: false }, bboxByVp: { 1280: bbox(0, 0, 100, 5000) } })],
});
assert.equal(irContentExtent(root, 1280), 0);
});
});
// ---------------------------------------------------------------------------
// gatePollution — scroll-locked-capture contradiction.
// ---------------------------------------------------------------------------
describe("validate: pollution gate scroll-locked contradiction", () => {
const bbox = (x: number, y: number, w: number, h: number): BBox => ({ x, y, width: w, height: h });
const st = (over: Partial<StyleMap> = {}): StyleMap => ({ position: "static", ...over } as StyleMap);
// A body with plenty of text and a footer at y=5198 (content extent ~6.4 viewports @800).
const mkIr = (): IR => ({
doc: {
sourceUrl: "https://x.test", title: "t", lang: "en", charset: "UTF-8", metaViewport: "",
viewports: [1280], sampleViewports: [1280], canonicalViewport: 1280,
perViewport: { 1280: { scrollHeight: 800, scrollWidth: 1280, htmlBg: "", bodyBg: "", bodyColor: "", bodyFont: "" } },
nodeCount: 200, keyframes: [],
},
root: {
id: "n0", tag: "body", attrs: {}, visibleByVp: { 1280: true },
bboxByVp: { 1280: bbox(0, 0, 1280, 800) }, computedByVp: { 1280: st() },
children: [
{ id: "n1", tag: "section", attrs: {}, visibleByVp: { 1280: true }, bboxByVp: { 1280: bbox(0, 0, 1280, 800) }, computedByVp: { 1280: st() },
children: [{ text: "hero copy ".repeat(20) }] },
{ id: "n2", tag: "footer", attrs: {}, visibleByVp: { 1280: true }, bboxByVp: { 1280: bbox(0, 4074, 1280, 1124) }, computedByVp: { 1280: st() },
children: [{ text: "footer copy ".repeat(20) }] },
],
},
});
const mkCapture = (scrollHeight: number): CaptureResult => ({
perViewport: [
{ viewport: 375, height: 812, scrollHeight: 812, nodeCount: 200, truncated: false },
{ viewport: 1280, height: 800, scrollHeight, nodeCount: 200, truncated: false },
],
dismissal: { dismissed: [], overlaysRemaining: 0, removed: 0, videoStills: 0, blocking: false },
} as unknown as CaptureResult);
it("FAILS when scrollHeight is pinned to ~1 viewport at every width but IR content spans multiple viewports", () => {
const res = gatePollution(mkIr(), mkCapture(800), [1280]);
assert.equal(res.pass, false, "the scroll-locked contradiction is caught");
assert.ok(res.issues.some((i) => i.includes("scroll-locked capture")), `issue reported (got ${JSON.stringify(res.issues)})`);
});
it("PASSES when scrollHeight matches the tall content (a genuinely scrollable page)", () => {
const res = gatePollution(mkIr(), mkCapture(5198), [1280]);
assert.ok(!res.issues.some((i) => i.includes("scroll-locked capture")), "no false positive when the page really scrolled");
});
});
+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 });
}
});
});
+193
View File
@@ -0,0 +1,193 @@
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("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", {
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");
});
});
+128
View File
@@ -0,0 +1,128 @@
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import { chromium, type Browser, type Page } from "playwright";
import type { IR, IRNode } from "../src/normalize/ir.js";
import type { MotionCapture } from "../src/capture/motion.js";
import { captureMotion } from "../src/capture/motion.js";
import { buildMotionSpec, DITTO_MOTION_TSX } from "../src/generate/motion.js";
// A rotator cycles the text of a single LEAF word/phrase. A container that bears element
// children must never be treated as a rotator: the runtime replaces its content on each swap,
// so classifying a structural panel as a rotator would flatten its whole subtree to one text
// node. This regression guards all three defense layers against that misclassification.
// ---- Minimal IR factory (only the fields buildMotionSpec reads: id, attrs, children) ----
function node(id: string, cap: string | null, children: IRNode[] = [], text?: string): IRNode {
const kids = text !== undefined ? ([{ text }] as unknown as IRNode[]) : children;
return {
id,
tag: "div",
attrs: cap !== null ? { "data-cid-cap": cap } : {},
visibleByVp: {},
bboxByVp: {},
computedByVp: {},
children: kids,
} as unknown as IRNode;
}
function ir(root: IRNode): IR {
return { doc: { canonicalViewport: 1280 } as unknown as IR["doc"], root };
}
function motionWith(rotators: MotionCapture["rotators"]): MotionCapture {
return { waapi: [], rotators, reveals: [], marquees: [] };
}
describe("rotator misclassification guard — layer 1 (emission)", () => {
it("emits a rotator whose target is a genuine text leaf (no element children)", () => {
const tree = ir(node("n0", null, [node("n1", "5", [], "Design")]));
const spec = buildMotionSpec(tree, motionWith([{ cap: "5", texts: ["Design", "Build"], intervalMs: 900 }]));
assert.equal(spec.rotators.length, 1, "leaf rotator survives emission");
assert.equal(spec.rotators[0]!.cid, "n1");
});
it("DROPS a rotator whose target IR node has element children (structural container)", () => {
// n1 is capped and was classified as a rotator, but it holds real element rows (n2, n3).
const container = node("n1", "5", [
node("n2", null, [], "row one"),
node("n3", null, [], "row two"),
]);
const tree = ir(node("n0", null, [container]));
const spec = buildMotionSpec(tree, motionWith([{ cap: "5", texts: ["a", "b"], intervalMs: 240 }]));
assert.equal(spec.rotators.length, 0, "element-bearing container is not emitted as a rotator");
});
it("keeps genuine leaf rotators while dropping a sibling container in the same spec", () => {
const tree = ir(node("n0", null, [
node("n1", "5", [], "Design"), // genuine leaf
node("n2", "6", [node("n3", null, [], "row")]), // container → dropped
]));
const spec = buildMotionSpec(tree, motionWith([
{ cap: "5", texts: ["Design", "Build"], intervalMs: 900 },
{ cap: "6", texts: ["x", "y"], intervalMs: 240 },
]));
assert.equal(spec.rotators.length, 1);
assert.equal(spec.rotators[0]!.cid, "n1");
});
});
describe("rotator misclassification guard — layer 3 (non-destructive runtime)", () => {
it("saves & restores the target's child NODES, never a flattened textContent string", () => {
// Save path: the original child nodes are cloned (structure preserved), not read as a string.
assert.match(DITTO_MOTION_TSX, /Array\.from\(el\.childNodes\)\.map\(\(n\) => n\.cloneNode\(true\)\)/);
// Restore path: rebuild via replaceChildren with cloned nodes — no `textContent = r.original`.
assert.match(DITTO_MOTION_TSX, /r\.el\.replaceChildren\(\.\.\.r\.original\.map\(\(n\) => n\.cloneNode\(true\)\)\)/);
assert.doesNotMatch(DITTO_MOTION_TSX, /r\.el\.textContent = r\.original/, "no lossy textContent restore remains");
});
it("refuses to install a rotator on an element that has element children at runtime", () => {
assert.match(DITTO_MOTION_TSX, /if \(el\.childElementCount > 0\) continue;/);
});
});
describe("rotator misclassification guard — layer 2 (capture leaf guard)", () => {
let browser: Browser;
let page: Page;
before(async () => {
browser = await chromium.launch();
page = await browser.newPage();
});
after(async () => {
await browser.close();
});
it("does not record an element-bearing container (runtime-injected uncapped rows) as a rotator", async () => {
// #panel is capped BEFORE its rows are injected (mirrors cid-cap tagging preceding the site's
// late row appends). Its rows are uncapped. #word is a genuine capped leaf cycling text.
await page.setContent(`<!doctype html><html><body>
<div id="panel" data-cid-cap="10"></div>
<span id="word" data-cid-cap="11">Design</span>
<script>
var glyphs = ["\\u2b22", "\\u2b21"], gi = 0;
var panel = document.getElementById("panel"), rows = 0;
// Append real (uncapped) element rows over time, and toggle a glyph inside them.
setInterval(function () {
if (rows < 4) {
var r = document.createElement("div");
r.className = "row";
r.innerHTML = '<span class="g">' + glyphs[gi % 2] + '</span> line ' + rows;
panel.appendChild(r); rows++;
} else {
gi++;
var gs = panel.querySelectorAll(".g");
for (var k = 0; k < gs.length; k++) gs[k].textContent = glyphs[gi % 2];
}
}, 120);
// #word: a genuine leaf whose text content cycles.
var words = ["Design", "Build", "Ship"], wi = 0, w = document.getElementById("word");
setInterval(function () { wi = (wi + 1) % words.length; w.textContent = words[wi]; }, 200);
</script>
</body></html>`);
// tsx/esbuild names the evaluated function; provide the helper it references in-page.
await page.evaluate("globalThis.__name = globalThis.__name || ((fn) => fn);");
const motion = await captureMotion(page, { observeMs: 1600 });
const caps = new Set(motion.rotators.map((r) => r.cap));
assert.ok(!caps.has("10"), "element-bearing panel is NOT recorded as a rotator");
assert.ok(caps.has("11"), "genuine leaf word IS recorded as a rotator");
});
});
+238
View File
@@ -0,0 +1,238 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { detectSections, detectSectionNodes } from "../src/infer/sections.js";
import { planSections } from "../src/generate/sectionSplit.js";
import type { IR, IRNode, IRChild } from "../src/normalize/ir.js";
const CW = 1280;
type Box = { x?: number; y: number; width?: number; height: number };
/** An element node at the canonical viewport (ids assigned later in pre-order). */
function el(tag: string, box: Box, children: IRChild[] = [], display = "block"): IRNode {
return {
id: "",
tag,
attrs: {},
visibleByVp: { [CW]: true },
bboxByVp: { [CW]: { x: box.x ?? 0, y: box.y, width: box.width ?? CW, height: box.height } },
computedByVp: { [CW]: { display } },
children,
};
}
function text(t: string): IRChild {
return { text: t };
}
/** Wrap children in a <body> root and assign stable pre-order ids (n0, n1, …). */
function page(children: IRNode[], pageH: number): IR {
const root = el("body", { y: 0, height: pageH }, children);
let i = 0;
const assign = (n: IRNode): void => {
n.id = `n${i++}`;
for (const c of n.children) if ((c as IRNode).tag) assign(c as IRNode);
};
assign(root);
return {
doc: {
sourceUrl: "https://example.test/", title: "Fixture", lang: "en", charset: "UTF-8",
metaViewport: "width=device-width, initial-scale=1",
viewports: [CW], sampleViewports: [CW], canonicalViewport: CW,
perViewport: { [CW]: { scrollHeight: pageH, scrollWidth: CW, htmlBg: "", bodyBg: "", bodyColor: "", bodyFont: "" } },
nodeCount: i, keyframes: [],
},
root,
};
}
/** A content band with a heading (so section naming has honest evidence). Bands get
* `variant` extra paragraphs so adjacent one-off sections differ structurally the
* repeated-run filter (3 identical signatures component cluster) must not fire. */
function band(y: number, height: number, heading: string, variant = 0): IRNode {
const extras: IRNode[] = [];
for (let k = 0; k <= variant % 7; k++) {
extras.push(el("p", { y: y + 80 + k * 30, height: 24, x: 120, width: 600 }, [text(`Body copy ${k}`)]));
}
return el("section", { y, height }, [
el("h2", { y: y + 24, height: 40, x: 120, width: 600 }, [text(heading)]),
...extras,
]);
}
/** navbar(62) + hero(800) + 3 bands + footer(400), flat under body. */
function sixBandPage(): IR {
const nav = el("nav", { y: 0, height: 62 });
const hero = el("section", { y: 62, height: 800 }, [
el("h1", { y: 120, height: 60, x: 120, width: 900 }, [text("Ship faster with widgets")]),
]);
const b1 = band(862, 600, "Trusted by teams", 0);
const b2 = band(1462, 700, "Everything you need", 1);
const b3 = band(2162, 500, "What customers say", 2);
const footer = el("footer", { y: 2662, height: 400 }, [
el("a", { y: 2700, height: 20, x: 120, width: 200, }, [text("Privacy")], "inline"),
]);
return page([nav, hero, b1, b2, b3, footer], 3062);
}
/** Same bands, but body > div#root > (nav + main(13 bands) + footer) — the real-site shape. */
function wrappedPage(bandCount = 13): IR {
const nav = el("nav", { y: 0, height: 62 });
const bandH = 900;
const bands: IRNode[] = [];
for (let k = 0; k < bandCount; k++) {
bands.push(k === 0
? el("section", { y: 62, height: bandH }, [el("h1", { y: 100, height: 60, x: 120, width: 900 }, [text("Design your future")])])
: band(62 + k * bandH, bandH, `Feature area ${k}`, k));
}
const mainH = bandCount * bandH;
const main = el("main", { y: 62, height: mainH }, bands);
const footer = el("footer", { y: 62 + mainH, height: 400 });
const pageH = 62 + mainH + 400;
const wrapper = el("div", { y: 0, height: pageH }, [nav, main, footer]);
return page([wrapper], pageH);
}
describe("section decomposition (recursive descent)", () => {
it("splits navbar + hero + 3 bands + footer into 6 sections", () => {
const ir = sixBandPage();
const sections = detectSections(ir);
assert.equal(sections.length, 6);
assert.equal(sections[0]!.role, "navbar");
assert.equal(sections[1]!.role, "hero");
assert.equal(sections[5]!.role, "footer");
// the 62px navbar (below the old 64px bar) is still the navbar
assert.equal(sections[0]!.bboxByVp[CW]!.height, 62);
});
it("descends body > div > main wrappers to the real bands", () => {
const ir = wrappedPage(13);
const sections = detectSections(ir);
assert.equal(sections.length, 15); // nav + 13 bands + footer
assert.equal(sections[0]!.role, "navbar");
assert.equal(sections[1]!.role, "hero");
assert.equal(sections[14]!.role, "footer");
// no wrapper survives as a section
const tags = new Set(detectSectionNodes(ir).map((n) => n.tag));
assert.ok(!tags.has("main") && !tags.has("body") && !tags.has("div"));
});
it("sections tile the page top-to-bottom without gaps or overlaps", () => {
const ir = wrappedPage(13);
const boxes = detectSections(ir).map((s) => s.bboxByVp[CW]!).sort((a, b) => a.y - b.y);
const pageH = ir.doc.perViewport[CW]!.scrollHeight;
let cursor = 0;
for (const b of boxes) {
assert.ok(Math.abs(b.y - cursor) <= 8, `gap/overlap at y=${b.y} (expected ~${cursor})`);
cursor = b.y + b.height;
}
assert.ok(pageH - cursor <= 8, `uncovered tail: ${pageH - cursor}px`);
});
it("keeps a single-band page as one section (degenerate stays legal)", () => {
const inner = el("div", { y: 100, height: 300, x: 320, width: 640 });
const only = el("div", { y: 0, height: 800 }, [inner]);
const ir = page([only], 800);
const sections = detectSections(ir);
assert.equal(sections.length, 1);
});
it("does not split side-by-side columns or overlaid layers", () => {
// two full-width overlapping layers inside a page-covering wrapper
const a = el("div", { y: 100, height: 1900 });
const b = el("div", { y: 100, height: 1900 });
const wrapper = el("div", { y: 100, height: 1900 }, [a, b]);
const nav = el("nav", { y: 0, height: 100 });
const ir = page([nav, wrapper], 2000);
const sections = detectSections(ir);
assert.equal(sections.length, 2); // nav + the wrapper, unsplit
});
it("does not descend when children leave large coverage gaps", () => {
// one small band inside a page-covering container: splitting would drop content
const lone = band(0, 300, "Only child");
const container = el("div", { y: 0, height: 2000 }, [lone]);
const ir = page([container], 2000);
assert.equal(detectSections(ir).length, 1);
});
it("keeps a 62px fixed div-wrapped navbar as its own band (nav evidence beats the 64px bar)", () => {
// real-site shape: hero at y=0, a thin fixed bar (styled div, real <nav> nested
// narrow inside) floating over it
const navInner = el("nav", { y: 12, height: 62, x: 700, width: 454 });
const bar = el("div", { y: 12, height: 62 }, [el("div", { y: 12, height: 62 }, [navInner])]);
const hero = el("section", { y: 0, height: 800 }, [
el("h1", { y: 200, height: 60, x: 120, width: 900 }, [text("We recruit designers")]),
]);
const b1 = band(800, 700, "Our mission", 1);
const b2 = band(1500, 900, "Services", 2);
const footer = el("footer", { y: 2400, height: 300 });
const ir = page([hero, bar, b1, b2, footer], 2700);
const sections = detectSections(ir);
assert.equal(sections.length, 5);
assert.equal(sections[0]!.role, "hero"); // y=0 sorts before the bar at y=12
assert.equal(sections[1]!.role, "navbar");
const names = [...planSections(ir).roots.values()];
assert.ok(names.includes("Navbar"), `expected Navbar in ${names.join(", ")}`);
});
it("is deterministic: same capture, byte-identical sections", () => {
const a = JSON.stringify(detectSections(wrappedPage(13)));
const b = JSON.stringify(detectSections(wrappedPage(13)));
assert.equal(a, b);
});
});
describe("section component planning (emission roots + names)", () => {
it("names one component per band: Navbar, HeroSection, content sections, Footer", () => {
const ir = sixBandPage();
const plan = planSections(ir);
const names = [...plan.roots.values()];
assert.equal(names.length, 6);
assert.equal(names[0], "Navbar");
assert.equal(names[1], "HeroSection");
assert.equal(names[5], "Footer");
assert.ok(names.includes("TrustedByTeamsSection"));
assert.ok(names.includes("WhatCustomersSaySection"));
assert.equal(new Set(names).size, 6, "names are unique");
});
it("plans a component per band through body > div > main wrappers", () => {
const plan = planSections(wrappedPage(13));
assert.equal(plan.roots.size, 15);
const names = [...plan.roots.values()];
assert.equal(names[0], "Navbar");
assert.equal(names[1], "HeroSection");
assert.equal(names[14], "Footer");
});
it("falls back to evidence names for bands without headings", () => {
const nav = el("nav", { y: 0, height: 62 });
const hero = el("section", { y: 62, height: 800 }, [
el("h1", { y: 120, height: 60, x: 120, width: 900 }, [text("Hello")]),
]);
const formBand = el("section", { y: 862, height: 500 }, [
el("form", { y: 900, height: 200, x: 320, width: 640 }),
]);
const mediaBand = el("section", { y: 1362, height: 500 }, [
el("video", { y: 1400, height: 400, x: 160, width: 960 }),
]);
const footer = el("footer", { y: 1862, height: 300 });
const ir = page([nav, hero, formBand, mediaBand, footer], 2162);
const names = [...planSections(ir).roots.values()];
assert.ok(names.includes("ContactSection"), `expected ContactSection in ${names.join(", ")}`);
assert.ok(names.includes("MediaSection"), `expected MediaSection in ${names.join(", ")}`);
});
it("leaves a page with too few bands unsplit (no monolithic 'hero' misnomer)", () => {
const only = el("div", { y: 0, height: 800 }, [el("div", { y: 100, height: 300, x: 320, width: 640 })]);
const plan = planSections(page([only], 800));
assert.equal(plan.roots.size, 0);
});
it("is deterministic across runs", () => {
const a = JSON.stringify([...planSections(wrappedPage(13)).roots.entries()]);
const b = JSON.stringify([...planSections(wrappedPage(13)).roots.entries()]);
assert.equal(a, b);
});
});
+177
View File
@@ -0,0 +1,177 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { buildColorPalette, colorClusterKey } from "../src/infer/semanticTokens.js";
import { planSections, nameFromSourceToken } from "../src/generate/sectionSplit.js";
import type { IR, IRNode, IRChild, StyleMap } from "../src/normalize/ir.js";
const CW = 1280;
type Box = { x?: number; y: number; width?: number; height: number };
function el(tag: string, box: Box, computed: StyleMap, children: IRChild[] = [], attrs: Record<string, string> = {}, srcClass?: string): IRNode {
const n: IRNode = {
id: "", tag, attrs,
visibleByVp: { [CW]: true },
bboxByVp: { [CW]: { x: box.x ?? 0, y: box.y, width: box.width ?? CW, height: box.height } },
computedByVp: { [CW]: { display: "block", ...computed } },
children,
};
if (srcClass) n.srcClass = srcClass;
return n;
}
function text(t: string): IRChild { return { text: t }; }
function page(children: IRNode[], pageH: number, body?: { bodyBg?: string; bodyColor?: string }): IR {
// The body carries its own bg/fg computed style (as a real capture does), so the palette's
// usage histogram actually sees the page background/foreground colours it will name.
const root = el("body", { y: 0, height: pageH }, {
...(body?.bodyBg ? { backgroundColor: body.bodyBg } : {}),
...(body?.bodyColor ? { color: body.bodyColor } : {}),
}, children);
let i = 0;
const assign = (n: IRNode): void => { n.id = `n${i++}`; for (const c of n.children) if ((c as IRNode).tag) assign(c as IRNode); };
assign(root);
return {
doc: {
sourceUrl: "https://example.test/", title: "Fixture", lang: "en", charset: "UTF-8",
metaViewport: "width=device-width, initial-scale=1",
viewports: [CW], sampleViewports: [CW], canonicalViewport: CW,
perViewport: { [CW]: { scrollHeight: pageH, scrollWidth: CW, htmlBg: "", bodyBg: body?.bodyBg ?? "", bodyColor: body?.bodyColor ?? "", bodyFont: "" } },
nodeCount: i, keyframes: [],
},
root,
} as IR;
}
// ----------------------------------------------------------------------------
// 1) Semantic role assignment — a fixture IR with known roles must produce the
// expected named tokens (deterministically), leftovers only for role-less colours.
// ----------------------------------------------------------------------------
describe("semantic color palette: role assignment", () => {
// page bg = cream; body text = near-black; a saturated red brand used on buttons/links;
// a light-gray surface used as several card backgrounds; a border gray; one odd accent.
function siteIr(): IR {
const kids: IRNode[] = [];
// Body text (near-black) x8
for (let k = 0; k < 8; k++) kids.push(el("p", { y: 100 + k * 30, height: 20 }, { color: "rgb(20, 20, 19)" }, [text("copy")]));
// Brand red on buttons/links x6 (interactive → --primary)
for (let k = 0; k < 6; k++) kids.push(el("a", { y: 400 + k * 30, height: 40 }, { backgroundColor: "rgb(188, 0, 0)", color: "rgb(255,255,255)" }, [text("Buy")]));
// Light-gray card surfaces x5 (bg, light, low-sat → --surface)
for (let k = 0; k < 5; k++) kids.push(el("div", { y: 800 + k * 60, height: 50 }, { backgroundColor: "rgb(240, 238, 230)" }));
// Border gray x4 (border only → --border)
for (let k = 0; k < 4; k++) kids.push(el("div", { y: 1200 + k * 30, height: 20 }, { borderTopColor: "rgb(200, 200, 200)", borderTopWidth: "1px" }));
return page(kids, 1600, { bodyBg: "rgb(252, 251, 246)", bodyColor: "rgb(20, 20, 19)" });
}
it("names background / foreground / primary / surface / border from usage evidence", () => {
const p = buildColorPalette(siteIr());
const byName = new Map(p.tokens.map((t) => [t.name, t.value]));
assert.equal(byName.get("--background"), "rgb(252, 251, 246)");
assert.equal(byName.get("--foreground"), "rgb(20, 20, 19)");
assert.equal(byName.get("--primary"), "rgb(188, 0, 0)");
assert.equal(byName.get("--surface"), "rgb(240, 238, 230)");
assert.equal(byName.get("--border"), "rgb(200, 200, 200)");
// Every named token resolves back to itself.
assert.equal(p.varForColor("rgb(188, 0, 0)"), "var(--primary)");
});
it("is deterministic: same IR → byte-identical token list", () => {
const a = buildColorPalette(siteIr()).css;
const b = buildColorPalette(siteIr()).css;
assert.equal(a, b);
});
it("resolves oklab/oklch forms of a named colour to the SAME semantic token (±2 sRGB)", () => {
// oklch(0.987… 97°) ≈ rgb(252,251,246) — the page background. Reached only via a
// gradient/decoration property, it must still map to --background, not a fresh --clr-N.
const p = buildColorPalette(page([
...Array.from({ length: 4 }, (_, k) => el("p", { y: 100 + k * 30, height: 20 }, { color: "rgb(20, 20, 19)" }, [text("x")])),
], 400, { bodyBg: "oklch(0.987472 0.00667657 97.3497)", bodyColor: "rgb(20, 20, 19)" }));
const bg = p.tokens.find((t) => t.name === "--background");
assert.ok(bg, "background named");
// The rgb() equivalent within tolerance resolves to --background via the ±2 fallback.
assert.equal(p.varForColor("rgb(252, 251, 246)"), "var(--background)");
});
});
// ----------------------------------------------------------------------------
// 2) colorClusterKey — visually identical literals share a key; distinct colours don't.
// ----------------------------------------------------------------------------
describe("colorClusterKey (interner visual dedup)", () => {
it("collapses oklab forms that round to the same sRGB", () => {
// Both oklab(0.988…) whites → rgb(251,251,251).
const a = colorClusterKey("oklab(0.988242 -0.0000812355 0.00000757745)");
const b = colorClusterKey("oklab(0.988371 -0.0000803481 0.00000749468)");
assert.ok(a);
assert.equal(a, b);
});
it("keeps genuinely different colours on different keys", () => {
assert.notEqual(colorClusterKey("rgb(0,0,0)"), colorClusterKey("rgb(255,255,255)"));
assert.notEqual(colorClusterKey("rgb(188,0,0)"), colorClusterKey("rgb(0,0,188)"));
});
it("separates alpha variants", () => {
assert.notEqual(colorClusterKey("rgba(0,0,0,0.5)"), colorClusterKey("rgba(0,0,0,0.75)"));
});
it("returns null for unparseable values (keeps raw-literal keying)", () => {
assert.equal(colorClusterKey("var(--x)"), null);
assert.equal(colorClusterKey("currentColor"), null);
});
});
// ----------------------------------------------------------------------------
// 3) Name sanitization — hashy-suffix stripping + generic-word filtering.
// ----------------------------------------------------------------------------
describe("nameFromSourceToken (source-id sanitization)", () => {
it("strips Shopify template prefix + trailing hash → semantic slug", () => {
assert.equal(nameFromSourceToken("shopify-section-template--19797275672650__split_callout_JtTWTt"), "SplitCallout");
// `grid` is a generic structural word (dropped); the hash `RbEALJ` is stripped.
assert.equal(nameFromSourceToken("shopify-section-template--19797275672650__media_card_grid_RbEALJ"), "MediaCard");
});
it("strips mixed-case build hashes (JtTWTt / dDMm2q / RbEALJ)", () => {
assert.equal(nameFromSourceToken("split_callout_JtTWTt"), "SplitCallout");
assert.equal(nameFromSourceToken("hero_hD9krx"), "Hero");
});
it("drops generic structural words entirely", () => {
assert.equal(nameFromSourceToken("g_section_wrap"), "");
assert.equal(nameFromSourceToken("section-inner-content"), "");
assert.equal(nameFromSourceToken("shopify-section-group-header"), "Header");
});
it("handles js-* hooks and long numeric ids", () => {
assert.equal(nameFromSourceToken("js-media-banner-section"), "MediaBanner");
assert.equal(nameFromSourceToken("template--19797275672650"), "");
});
});
// ----------------------------------------------------------------------------
// 4) planSections — a Shopify-id section beats a heading slug; noisy classes don't.
// ----------------------------------------------------------------------------
describe("planSections: source-id naming precedence", () => {
it("names a section from its CMS section id (hash stripped) over generic evidence", () => {
const nav = el("nav", { y: 0, height: 62 }, {});
const hero = el("section", { y: 62, height: 800 }, {}, [el("h1", { y: 120, height: 60, x: 120, width: 900 }, {}, [text("Welcome")])]);
// A one-off band with NO heading but a clean Shopify id → SplitCalloutSection.
const callout = el("div", { y: 862, height: 600 }, {}, [
el("p", { y: 900, height: 40, x: 120, width: 600 }, {}, [text("Some marketing copy here")]),
], { id: "shopify-section-template--19797275672650__split_callout_JtTWTt" }, "shopify-section js-split-callout-section");
const b2 = el("section", { y: 1462, height: 500 }, {}, [el("h2", { y: 1500, height: 40, x: 120, width: 600 }, {}, [text("Everything you need today")]), el("p", { y: 1560, height: 24, x: 120, width: 600 }, {}, [text("extra")])]);
const b3 = el("section", { y: 1962, height: 500 }, {}, [el("h2", { y: 2000, height: 40, x: 120, width: 600 }, {}, [text("What customers say now")]), el("p", { y: 2060, height: 24, x: 120, width: 600 }, {}, [text("a")]), el("p", { y: 2090, height: 24, x: 120, width: 600 }, {}, [text("b")])]);
const footer = el("footer", { y: 2462, height: 400 }, {}, [el("a", { y: 2500, height: 20, x: 120, width: 200 }, {}, [text("Privacy")], {}, undefined)]);
const ir = page([nav, hero, callout, b2, b3, footer], 2862);
const names = [...planSections(ir).roots.values()];
assert.ok(names.includes("SplitCalloutSection"), `expected SplitCalloutSection in ${names.join(", ")}`);
});
it("does NOT mine arbitrary utility classes (falls back to heading slug)", () => {
const nav = el("nav", { y: 0, height: 62 }, {});
const hero = el("section", { y: 62, height: 800 }, {}, [el("h1", { y: 120, height: 60, x: 120, width: 900 }, {}, [text("Welcome home")])]);
// Heading present, but the only source class is a noisy Webflow utility → use the heading.
const band = el("section", { y: 862, height: 600 }, {}, [
el("h2", { y: 900, height: 40, x: 120, width: 600 }, {}, [text("Latest releases from us")]),
], {}, "g_section_space duraldar-cta_section w-variant-60a7ad7d");
const b2 = el("section", { y: 1462, height: 500 }, {}, [el("h2", { y: 1500, height: 40, x: 120, width: 600 }, {}, [text("Everything you need")]), el("p", { y: 1560, height: 24, x: 120, width: 600 }, {}, [text("x")])]);
const footer = el("footer", { y: 1962, height: 400 }, {}, [el("a", { y: 2000, height: 20, x: 120, width: 200 }, {}, [text("Privacy")])]);
const ir = page([nav, hero, band, b2, footer], 2362);
const names = [...planSections(ir).roots.values()];
assert.ok(names.some((n) => /^LatestReleases/.test(n)), `expected heading slug, got ${names.join(", ")}`);
assert.ok(!names.some((n) => /Duraldar|Space/.test(n)), `must not mine utility classes: ${names.join(", ")}`);
});
});
+51
View File
@@ -195,3 +195,54 @@ describe("generated Next config", () => {
assert.ok(NEXT_CONFIG.includes("devIndicators: false")); assert.ok(NEXT_CONFIG.includes("devIndicators: false"));
}); });
}); });
describe("SEO origin references the clone, not the source domain (fix 6)", () => {
it("sitemap.ts / robots.ts resolve against SITE_ORIGIN, not the source origin", () => {
const ir = fixtureIr();
const report = buildSeoInventory(ir, fixtureAssets(), fixtureCapture());
const files = seoRouteFiles(report, [routeSummaryFromIr(ir, "/", "/", ir.doc.sourceUrl)]);
const robots = files.find(([p]) => p === "robots.ts")![1];
const sitemap = files.find(([p]) => p === "sitemap.ts")![1];
for (const body of [robots, sitemap]) {
assert.ok(body.includes('import { SITE_ORIGIN } from "../lib/site";'), "imports SITE_ORIGIN");
assert.ok(body.includes("SITE_ORIGIN +"), "builds URLs from SITE_ORIGIN");
assert.ok(!body.includes("example.test"), "never bakes the source domain");
}
// sitemap path is relativized off the source origin.
assert.ok(sitemap.includes('SITE_ORIGIN + "/seo"') || sitemap.includes('SITE_ORIGIN + "/"'));
});
it("metadata sets metadataBase from SITE_ORIGIN and relativizes the canonical", () => {
const ir = fixtureIr();
const report = buildSeoInventory(ir, fixtureAssets(), fixtureCapture());
const metadata = metadataExport(report);
assert.ok(metadata.includes('new URL(SITE_ORIGIN || "http://localhost:3000")'), "metadataBase from SITE_ORIGIN");
assert.ok(metadata.includes('"canonical": "/seo"'), "canonical relativized to a path");
// The source domain must not survive in canonical (og:image assets are localized elsewhere).
assert.ok(!metadata.includes('"canonical": "https://example.test'), "canonical is not absolute to source");
});
it("relativizes og:url off the source origin", () => {
const ir = fixtureIr();
ir.doc.head!.meta!.push({ property: "og:url", content: "https://example.test/seo" });
const report = buildSeoInventory(ir, fixtureAssets(), fixtureCapture());
const metadata = metadataExport(report);
assert.ok(metadata.includes('"url": "/seo"'), "og:url relativized to a path");
assert.ok(!metadata.includes('"url": "https://example.test/seo"'), "og:url not absolute to source");
});
it("rewrites on-origin JSON-LD @id/url off the source domain via SITE_ORIGIN", () => {
const ir = fixtureIr();
// JSON-LD carrying the source origin in @id/url (escaped-slash form, like WordPress emits).
ir.doc.head!.jsonLd = [{
id: "graph",
text: '{"@context":"https:\\/\\/schema.org","@id":"https:\\/\\/example.test\\/#website","url":"https:\\/\\/example.test\\/"}',
}];
const report = buildSeoInventory(ir, fixtureAssets(), fixtureCapture());
const markup = jsonLdHeadMarkup(report);
assert.ok(markup.includes(".join(SITE_ORIGIN)"), "rejoins segments with SITE_ORIGIN at runtime");
// The source origin must not survive as a literal (schema.org context is off-origin, kept).
assert.ok(!markup.includes("example.test"), "source origin removed from JSON-LD");
assert.ok(markup.includes("schema.org"), "off-origin @context left untouched");
});
});
+205
View File
@@ -0,0 +1,205 @@
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { serveStatic, parseRangeHeader, pendingFontFamilies, unreferencedFontFaces, type FontFaceStatus } from "../src/validate/render.js";
// ---- Pure helper: parseRangeHeader ----
describe("parseRangeHeader", () => {
const SIZE = 1000;
it("no header → full 200", () => {
assert.deepEqual(parseRangeHeader(undefined, SIZE), { kind: "full" });
assert.deepEqual(parseRangeHeader("", SIZE), { kind: "full" });
});
it("bytes=0- (Chromium's open-ended media probe) → whole file as a range", () => {
assert.deepEqual(parseRangeHeader("bytes=0-", SIZE), { kind: "range", start: 0, end: 999 });
});
it("bounded range bytes=100-199 → [100,199] inclusive", () => {
assert.deepEqual(parseRangeHeader("bytes=100-199", SIZE), { kind: "range", start: 100, end: 199 });
});
it("end past EOF is clamped to size-1", () => {
assert.deepEqual(parseRangeHeader("bytes=900-5000", SIZE), { kind: "range", start: 900, end: 999 });
});
it("suffix range bytes=-100 → last 100 bytes", () => {
assert.deepEqual(parseRangeHeader("bytes=-100", SIZE), { kind: "range", start: 900, end: 999 });
});
it("suffix larger than file → whole file", () => {
assert.deepEqual(parseRangeHeader("bytes=-5000", SIZE), { kind: "range", start: 0, end: 999 });
});
it("start at or past EOF → unsatisfiable (416)", () => {
assert.deepEqual(parseRangeHeader("bytes=1000-", SIZE), { kind: "unsatisfiable" });
assert.deepEqual(parseRangeHeader("bytes=2000-3000", SIZE), { kind: "unsatisfiable" });
});
it("zero-length resource → unsatisfiable for any range", () => {
assert.deepEqual(parseRangeHeader("bytes=0-", 0), { kind: "unsatisfiable" });
});
it("malformed / multi-range / inverted → full (safe fallback, never throws)", () => {
assert.deepEqual(parseRangeHeader("bytes=abc-def", SIZE), { kind: "full" });
assert.deepEqual(parseRangeHeader("items=0-10", SIZE), { kind: "full" });
assert.deepEqual(parseRangeHeader("bytes=0-10,20-30", SIZE), { kind: "full" }); // multi-range collapsed
assert.deepEqual(parseRangeHeader("bytes=-", SIZE), { kind: "full" });
assert.deepEqual(parseRangeHeader("bytes=500-100", SIZE), { kind: "full" }); // inverted
});
});
// ---- Pure helper: pendingFontFamilies (the font-load wait decision) ----
// The render walk must run after webfonts are APPLIED, else text boxes measure the fallback face
// (a systematic width delta wrongly attributed to the clone). This helper is the state-based (not
// time-based) predicate the in-page poll ends on: it returns the DECLARED families still actively
// fetching. Browsers lazy-load faces, so after document.fonts.ready a face the rendered text needs
// is already "loading"; a face left "unloaded" is unreferenced and will never load on its own —
// waiting on it only burns the cap, so ONLY "loading" is pending.
describe("pendingFontFamilies", () => {
const face = (family: string, status: string, weight = "400", style = "normal"): FontFaceStatus => ({ family, weight, style, status });
it("no faces declared → nothing pending", () => {
assert.deepEqual(pendingFontFamilies([]), []);
});
it("all faces loaded → nothing pending (walk may proceed)", () => {
assert.deepEqual(pendingFontFamilies([face("Avenir", "loaded"), face("Inter", "loaded")]), []);
});
it("an unloaded face is unreferenced (lazy-load) → NOT pending", () => {
assert.deepEqual(pendingFontFamilies([face("Avenir", "unloaded")]), []);
});
it("a loading face makes its family pending", () => {
assert.deepEqual(pendingFontFamilies([face("Avenir", "loading")]), ["Avenir"]);
});
it("errored faces are terminal — never reported pending (waiting longer is pointless)", () => {
assert.deepEqual(pendingFontFamilies([face("Avenir", "error")]), []);
});
it("a family is pending if ANY weight is still loading", () => {
const faces = [face("Avenir", "loaded", "400"), face("Avenir", "loading", "700")];
assert.deepEqual(pendingFontFamilies(faces), ["Avenir"]);
});
it("a partially-used family (used face loaded, sibling faces unloaded) is NOT pending", () => {
// The real-world false positive this fix removes: a matched face loaded, unreferenced
// weight/style siblings stay "unloaded" — the walk may proceed without waiting on them.
const faces = [face("Avenir", "loaded", "400"), face("Avenir", "unloaded", "700"), face("Avenir", "unloaded", "400", "italic")];
assert.deepEqual(pendingFontFamilies(faces), []);
});
it("a family whose faces are all terminal (loaded or errored) is not pending", () => {
const faces = [face("Avenir", "loaded", "400"), face("Avenir", "error", "700")];
assert.deepEqual(pendingFontFamilies(faces), []);
});
it("strips surrounding quotes from the reported family name and dedupes+sorts (loading only)", () => {
const faces = [face('"Source Serif"', "loading"), face("Avenir", "loading"), face("Avenir", "unloaded")];
assert.deepEqual(pendingFontFamilies(faces), ["Avenir", "Source Serif"]);
});
it("only the still-loading families are returned when some are loaded/unloaded", () => {
const faces = [face("Inter", "loaded"), face("Avenir", "loading"), face("JetBrains", "unloaded")];
assert.deepEqual(pendingFontFamilies(faces), ["Avenir"]);
});
});
// ---- Pure helper: unreferencedFontFaces (informational-only per-face report) ----
// Faces left "unloaded" after the wait bound: declared but no rendered text resolves to them.
// Reported per-face (family+weight+style) for an informational log; benign, never a fidelity issue.
describe("unreferencedFontFaces", () => {
const face = (family: string, status: string, weight = "400", style = "normal"): FontFaceStatus => ({ family, weight, style, status });
it("returns only the unloaded faces (not loaded/loading/error)", () => {
const faces = [face("Avenir", "loaded"), face("Merriweather", "unloaded", "700"), face("Inter", "loading"), face("Gotham", "error")];
assert.deepEqual(unreferencedFontFaces(faces), [{ family: "Merriweather", weight: "700", style: "normal", status: "unloaded" }]);
});
it("reports per-face (family+weight+style), not collapsed per-family, and strips quotes + sorts", () => {
const faces = [
face('"Merriweather"', "unloaded", "700", "normal"),
face("Merriweather", "unloaded", "300", "italic"),
face("Avenir", "unloaded", "400", "normal"),
];
assert.deepEqual(unreferencedFontFaces(faces), [
{ family: "Avenir", weight: "400", style: "normal", status: "unloaded" },
{ family: "Merriweather", weight: "300", style: "italic", status: "unloaded" },
{ family: "Merriweather", weight: "700", style: "normal", status: "unloaded" },
]);
});
it("no unloaded faces → empty", () => {
assert.deepEqual(unreferencedFontFaces([face("Avenir", "loaded"), face("Inter", "loading")]), []);
});
});
// ---- Integration: serveStatic answers real Range requests with 206/416 ----
describe("serveStatic Range support (integration)", () => {
let rootDir = "";
let base = "";
let close: (() => Promise<void>) | null = null;
const BODY = Buffer.alloc(5000, 0x41); // 5000 'A' bytes stands in for a media file
before(async () => {
rootDir = mkdtempSync(join(tmpdir(), "ditto-serve-"));
mkdirSync(join(rootDir, "assets"), { recursive: true });
writeFileSync(join(rootDir, "assets", "hero.webm"), BODY);
writeFileSync(join(rootDir, "index.html"), "<!doctype html><html><body>ok</body></html>");
const s = await serveStatic(rootDir);
base = s.url;
close = s.close;
});
after(async () => {
await close?.();
rmSync(rootDir, { recursive: true, force: true });
});
it("open-ended Range bytes=0- → 206 with a bounded body + Content-Range/Accept-Ranges", async () => {
const res = await fetch(base + "/assets/hero.webm", { headers: { Range: "bytes=0-" } });
assert.equal(res.status, 206);
assert.equal(res.headers.get("accept-ranges"), "bytes");
assert.equal(res.headers.get("content-range"), `bytes 0-4999/5000`);
assert.equal(res.headers.get("content-type"), "video/webm");
const buf = Buffer.from(await res.arrayBuffer());
assert.equal(buf.length, 5000);
});
it("bounded Range bytes=100-199 → 206 returning exactly those 100 bytes", async () => {
const res = await fetch(base + "/assets/hero.webm", { headers: { Range: "bytes=100-199" } });
assert.equal(res.status, 206);
assert.equal(res.headers.get("content-range"), `bytes 100-199/5000`);
const buf = Buffer.from(await res.arrayBuffer());
assert.equal(buf.length, 100);
assert.ok(buf.equals(BODY.subarray(100, 200)));
});
it("Range past EOF → 416 with Content-Range: bytes */size", async () => {
const res = await fetch(base + "/assets/hero.webm", { headers: { Range: "bytes=9000-" } });
assert.equal(res.status, 416);
assert.equal(res.headers.get("content-range"), `bytes */5000`);
// Drain the (empty) body so the socket is released.
await res.arrayBuffer();
});
it("no Range header → full 200 with Accept-Ranges advertised", async () => {
const res = await fetch(base + "/assets/hero.webm");
assert.equal(res.status, 200);
assert.equal(res.headers.get("accept-ranges"), "bytes");
const buf = Buffer.from(await res.arrayBuffer());
assert.equal(buf.length, 5000);
});
it("HTML routes still serve a normal 200 (Range logic doesn't disturb pages)", async () => {
const res = await fetch(base + "/");
assert.equal(res.status, 200);
const text = await res.text();
assert.match(text, /<body>ok<\/body>/);
});
});
+60
View File
@@ -0,0 +1,60 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { resolveSvgRootFill, isRealPaint } from "../src/generate/app.js";
describe("isRealPaint", () => {
it("treats none / empty / transparent as non-paint", () => {
for (const v of [undefined, null, "", " ", "none", "NONE", "transparent", "rgba(0, 0, 0, 0)", "rgba(0,0,0,0)"]) {
assert.equal(isRealPaint(v), false, `${String(v)} should not paint`);
}
});
it("treats a color / currentColor as a real paint", () => {
for (const v of ["rgb(255, 255, 255)", "#000", "currentColor", "red", "rgb(0, 0, 0)"]) {
assert.equal(isRealPaint(v), true, `${v} should paint`);
}
});
});
describe("resolveSvgRootFill", () => {
it("keeps a raw fill that is itself a real paint", () => {
assert.deepEqual(resolveSvgRootFill("#123456", { fill: "rgb(255,255,255)", color: "rgb(0,0,0)" }), { mode: "keep" });
assert.deepEqual(resolveSvgRootFill("red", null), { mode: "keep" });
});
it("falls back when no raw fill is declared (existing currentColor default)", () => {
assert.deepEqual(resolveSvgRootFill(undefined, { fill: "rgb(255,255,255)", color: "rgb(255,255,255)" }), { mode: "fallback" });
assert.deepEqual(resolveSvgRootFill(null, null), { mode: "fallback" });
});
it("recovers currentColor when fill=none but computed fill tracks the element color (wordmark case)", () => {
// The a16z logo case: fill="none" attribute, but CSS `fill: currentColor` with white color.
const r = resolveSvgRootFill("none", { fill: "rgb(255, 255, 255)", color: "rgb(255, 255, 255)" });
assert.equal(r.mode, "emit");
assert.equal(r.value, "currentColor");
assert.equal(r.emitColor, "rgb(255, 255, 255)");
});
it("emits the literal computed fill when it differs from the element color", () => {
const r = resolveSvgRootFill("none", { fill: "rgb(255, 0, 0)", color: "rgb(0, 0, 0)" });
assert.equal(r.mode, "emit");
assert.equal(r.value, "rgb(255, 0, 0)");
assert.equal(r.emitColor, undefined);
});
it("leaves a genuinely unfilled svg as none (computed fill also none)", () => {
assert.deepEqual(resolveSvgRootFill("none", { fill: "none", color: "rgb(0,0,0)" }), { mode: "keep" });
assert.deepEqual(resolveSvgRootFill("none", { fill: "rgba(0, 0, 0, 0)", color: "rgb(0,0,0)" }), { mode: "keep" });
// No computed paint captured at all → cannot prove it paints → stays none.
assert.deepEqual(resolveSvgRootFill("none", null), { mode: "keep" });
assert.deepEqual(resolveSvgRootFill("none", undefined), { mode: "keep" });
});
it("does not emit color when the recovered fill is currentColor but color is not a real paint", () => {
// fill == color but color is transparent → cannot be a meaningful currentColor recovery;
// the equality branch is guarded on isRealPaint(color), so it emits the literal fill instead.
const r = resolveSvgRootFill("none", { fill: "rgb(0, 128, 0)", color: "transparent" });
assert.equal(r.mode, "emit");
assert.equal(r.value, "rgb(0, 128, 0)");
assert.equal(r.emitColor, undefined);
});
});
+111 -1
View File
@@ -1,6 +1,6 @@
import { describe, it } from "node:test"; import { describe, it } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { declToUtil } from "../src/generate/tailwind.js"; import { declToUtil, snapBase, prettifyBase, collapseBases } from "../src/generate/tailwind.js";
// Zero-value gating. A named `-0` step only exists for props on Tailwind's spacing (or numeric) // Zero-value gating. A named `-0` step only exists for props on Tailwind's spacing (or numeric)
// scales; for the rest the class compiles to NOTHING — a silent no-op that ships the wrong style // scales; for the rest the class compiles to NOTHING — a silent no-op that ships the wrong style
@@ -41,3 +41,113 @@ describe("declToUtil zero values", () => {
assert.equal(declToUtil("letter-spacing", "-0.5px"), "tracking-[-0.5px]"); 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");
});
});
// A percentage 0 on a MAIN-SIZE axis (flex-basis) or a %-of-indefinite-height axis (height/min-height)
// is NOT the definite zero `-0`: `flex-basis:0%` content-sizes against an auto-sized flex container,
// whereas `flex-basis:0` gives a zero base size (collapsing a `flex:1 1 0%` item in an auto-height
// column). prettifyBase must keep those literal. Width/inset 0% resolve against the definite
// containing-block width, so their `-0` rewrite stays.
describe("prettifyBase 0% on indefinite-axis prefixes", () => {
it("keeps basis-[0%] literal (0% ≠ definite 0 for flex-basis)", () => {
assert.equal(prettifyBase("basis-[0%]"), "basis-[0%]");
});
it("keeps h-[0%] and min-h-[0%] literal (%-of-indefinite-height → auto)", () => {
assert.equal(prettifyBase("h-[0%]"), "h-[0%]");
assert.equal(prettifyBase("min-h-[0%]"), "min-h-[0%]");
});
it("still rewrites width/inset 0% to the definite -0 (definite containing-block width)", () => {
assert.equal(prettifyBase("w-[0%]"), "w-0");
assert.equal(prettifyBase("min-w-[0%]"), "min-w-0");
assert.equal(prettifyBase("left-[0%]"), "left-0");
assert.equal(prettifyBase("inset-x-[0%]"), "inset-x-0");
});
it("still rewrites non-zero fractions on every prefix (basis-[33.3333%] → basis-1/3)", () => {
assert.equal(prettifyBase("basis-[33.3333%]"), "basis-1/3");
assert.equal(prettifyBase("h-[50%]"), "h-1/2");
assert.equal(prettifyBase("min-h-[100%]"), "min-h-full");
});
});
// flex:1 1 0% is Tailwind's `flex-1` — fold the grow-[1] + zero-basis pair so the emitted class both
// reads idiomatically AND resolves to the exact flex longhands (avoiding the basis-[0%] hazard).
describe("collapseBases flex-1 folding", () => {
it("folds grow-[1] + basis-[0%] → flex-1 (shrink defaults to 1, elided)", () => {
assert.deepEqual(collapseBases(["grow-[1]", "basis-[0%]"]), ["flex-1"]);
});
it("folds grow-[1] + basis-0 (already-shortened band delta) → flex-1", () => {
assert.deepEqual(collapseBases(["grow-[1]", "basis-0"]), ["flex-1"]);
});
it("does NOT fold when shrink-0 is present (flex:1 0 0% ≠ flex-1)", () => {
assert.deepEqual(collapseBases(["grow-[1]", "shrink-0", "basis-[0%]"]).sort(), ["basis-[0%]", "grow-[1]", "shrink-0"]);
});
it("does NOT fold a non-zero basis (grow-[1] + basis-[50%] left as-is)", () => {
assert.deepEqual(collapseBases(["grow-[1]", "basis-[50%]"]).sort(), ["basis-[50%]", "grow-[1]"]);
});
});
// letter-spacing is authored at a finer scale than box lengths; a real -0.08px tracking is within
// snapLen's 0.1px integer-snap window and would collapse to 0px → Chromium serializes it as `normal`
// → a false style-gate mismatch. Tracking must skip the integer snap (snapBase keeps 2 decimals).
describe("declToUtil letter-spacing sub-0.1px preservation", () => {
it("keeps a real -0.08px tracking (does NOT snap to tracking-[0px])", () => {
assert.equal(declToUtil("letter-spacing", "-0.08px"), "tracking-[-0.08px]");
});
it("keeps -0.0375px through declToUtil, then snapBase rounds to 2 decimals (not to zero)", () => {
assert.equal(declToUtil("letter-spacing", "-0.0375px"), "tracking-[-0.0375px]");
assert.equal(snapBase("tracking-[-0.0375px]"), "tracking-[-0.04px]");
});
it("still keeps a genuine zero as tracking-[0px]", () => {
assert.equal(declToUtil("letter-spacing", "0px"), "tracking-[0px]");
});
it("still integer-snaps a BOX length near an integer (204.9994px → 205px)", () => {
assert.equal(declToUtil("width", "204.9994px"), "w-[205px]");
});
});
// text-wrap: modern heading line-balancing. `balance`/`pretty` rebalance where a title wraps;
// without emitting them a two-line heading breaks differently in the clone. Tailwind v4 has the
// named utilities text-balance / text-pretty / text-nowrap / text-wrap; anything else (e.g.
// `stable`) falls through to the arbitrary property escape.
describe("declToUtil text-wrap", () => {
it("maps balance and pretty to the named Tailwind v4 utilities", () => {
assert.equal(declToUtil("text-wrap", "balance"), "text-balance");
assert.equal(declToUtil("text-wrap", "pretty"), "text-pretty");
});
it("maps wrap and nowrap to their named utilities", () => {
assert.equal(declToUtil("text-wrap", "wrap"), "text-wrap");
assert.equal(declToUtil("text-wrap", "nowrap"), "text-nowrap");
});
it("falls back to the arbitrary property for an unmapped value", () => {
assert.equal(declToUtil("text-wrap", "stable"), "[text-wrap:stable]");
});
});
+93
View File
@@ -0,0 +1,93 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { selectVideoSourceIndex, planVideoSeek, type VideoSourceCandidate } from "../src/capture/capture.js";
// Predicate builders for the injected matchMedia / canPlayType.
const matchesAny = (matching: Set<string>) => (m: string) => matching.has(m);
const canPlayAll = () => true;
const canPlayNone = () => false;
describe("selectVideoSourceIndex (video <source> resource-selection)", () => {
it("picks the first source whose media matches; missing media matches unconditionally", () => {
// Aspect-gated hero: landscape sources first, portrait after, plain fall-through last.
const sources: VideoSourceCandidate[] = [
{ media: "(min-aspect-ratio: 21/9)", type: "video/webm" },
{ media: "(min-aspect-ratio: 16/9)", type: "video/webm" },
{ media: "(max-aspect-ratio: 4/5)", type: "video/webm" },
{ media: null, type: "video/webm" }, // plain fall-through
];
// Portrait viewport: only the max-aspect-ratio query matches → index 2.
assert.equal(
selectVideoSourceIndex(sources, matchesAny(new Set(["(max-aspect-ratio: 4/5)"])), canPlayAll),
2,
);
// Landscape/wide viewport: the min-aspect 16/9 query matches → index 1.
assert.equal(
selectVideoSourceIndex(sources, matchesAny(new Set(["(min-aspect-ratio: 16/9)"])), canPlayAll),
1,
);
// Nothing matches → falls through to the plain no-media source (index 3).
assert.equal(selectVideoSourceIndex(sources, matchesAny(new Set()), canPlayAll), 3);
});
it("skips a source whose type the UA cannot play", () => {
const sources: VideoSourceCandidate[] = [
{ media: null, type: "video/webm" }, // unplayable here
{ media: null, type: "video/mp4" },
];
const canPlayMp4 = (t: string) => t === "video/mp4";
assert.equal(selectVideoSourceIndex(sources, matchesAny(new Set()), canPlayMp4), 1);
});
it("treats a missing/empty type as never disqualifying", () => {
const sources: VideoSourceCandidate[] = [
{ media: null, type: "" },
{ media: null },
];
// canPlay is never consulted when type is absent, even if it would reject everything.
assert.equal(selectVideoSourceIndex(sources, matchesAny(new Set()), canPlayNone), 0);
});
it("returns -1 when no source is eligible", () => {
const sources: VideoSourceCandidate[] = [
{ media: "(max-aspect-ratio: 4/5)", type: "video/webm" },
];
assert.equal(selectVideoSourceIndex(sources, matchesAny(new Set()), canPlayNone), -1);
assert.equal(selectVideoSourceIndex([], matchesAny(new Set()), canPlayAll), -1);
});
it("is deterministic and first-match-wins in document order", () => {
const sources: VideoSourceCandidate[] = [
{ media: "(min-width: 100px)", type: "video/mp4" },
{ media: "(min-width: 100px)", type: "video/mp4" }, // also eligible, but later
];
assert.equal(selectVideoSourceIndex(sources, matchesAny(new Set(["(min-width: 100px)"])), canPlayAll), 0);
});
});
describe("planVideoSeek (post-reselection seek decision)", () => {
it("forces an epsilon seek on a reloaded video even at t=0 (clears the show-poster flag)", () => {
// The core regression: a just-reloaded video is at t=0 with the poster flag set. Seeking to 0
// fires no `seeked` and leaves the poster showing; only a genuine seek to a nonzero epsilon
// clears the flag and paints the new source's frame 0.
const plan = planVideoSeek(true, 0);
assert.ok(plan, "reloaded video must seek");
assert.ok(plan!.target > 0, "reloaded seek target must be nonzero so `seeked` actually fires");
assert.ok(plan!.target < 1e-3, "epsilon must be well inside frame 0");
});
it("still forces the epsilon seek on a reloaded video that reports a small nonzero time", () => {
const plan = planVideoSeek(true, 5e-4);
assert.deepEqual(plan, { target: 1e-4 });
});
it("fast-skips a non-reloaded video already at frame 0", () => {
assert.equal(planVideoSeek(false, 0), null);
assert.equal(planVideoSeek(false, 5e-4), null); // within the 1e-3 skip band
});
it("seeks a non-reloaded playing video back to 0", () => {
assert.deepEqual(planVideoSeek(false, 3.2), { target: 0 });
assert.deepEqual(planVideoSeek(false, 1e-3), { target: 0 }); // exactly at the band edge → seek
});
});
+219
View File
@@ -0,0 +1,219 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { RawNode, RawChild } from "../src/capture/walker.js";
import { buildIR, isTextChild, type IR, type IRNode } from "../src/normalize/ir.js";
import { collectNodeRules } from "../src/generate/css.js";
// Per-viewport DOM divergence handling:
// • GRAFT — a child that exists ONLY at non-canonical viewports enters the IR as a sibling at
// its source position, carrying per-viewport data only for the widths it appeared at, and is
// emitted display:none at base + revealed in its band(s).
// • DRIFT — a container whose children are a wholly DIFFERENT SET at some viewport (content
// identity drift) is NOT grafted (no duplication); the canonical children stand in at that
// width (the display:none banding is skipped) and the divergence is recorded in
// doc.contentDrift for the manifest.
function raw(tag: string, attrs: Record<string, string> = {}, children: RawChild[] = [], visible = true, computedOver: Record<string, string> = {}): RawNode {
return {
tag, attrs,
computed: { display: visible ? "block" : "none", position: "static", visibility: "visible", ...computedOver },
bbox: { x: 0, y: 0, width: visible ? 640 : 0, height: visible ? 360 : 0 },
visible,
children,
};
}
function snapshot(vp: number, root: RawNode): object {
return {
doc: {
url: "https://example.test/page", title: "Fixture",
head: { description: "", canonical: "", ogTitle: "", ogDescription: "", ogImage: "", ogType: "", ogSiteName: "", twitterCard: "", themeColor: "" },
lang: "en", charset: "UTF-8", viewportWidth: vp, viewportHeight: 800,
scrollWidth: vp, scrollHeight: 800, htmlBg: "rgb(255, 255, 255)", bodyBg: "rgb(255, 255, 255)",
bodyColor: "rgb(0, 0, 0)", bodyFont: "Arial", metaViewport: "width=device-width, initial-scale=1",
nodeCount: 10, truncated: false,
},
root, cssVars: {}, fontFaces: [], cssUrls: [], domAssets: [], keyframes: [],
};
}
/** Build an IR from a DIFFERENT raw body tree per viewport. */
function buildDivergentIR(rootByVp: Record<number, RawNode>): IR {
const vps = Object.keys(rootByVp).map(Number).sort((a, b) => a - b);
const sourceDir = mkdtempSync(join(tmpdir(), "ditto-ir-graft-"));
mkdirSync(join(sourceDir, "capture"), { recursive: true });
for (const vp of vps) {
writeFileSync(join(sourceDir, "capture", `dom-${vp}.json`), JSON.stringify(snapshot(vp, rootByVp[vp]!)));
}
return buildIR(sourceDir, vps);
}
function findByTag(node: IRNode, tag: string): IRNode | null {
if (node.tag === tag) return node;
for (const c of node.children) {
if (isTextChild(c)) continue;
const hit = findByTag(c, tag);
if (hit) return hit;
}
return null;
}
function elemChildren(node: IRNode): IRNode[] {
return node.children.filter((c): c is IRNode => !isTextChild(c));
}
// ---- fixtures ----
/** Carousel whose pagination (ul + 3 bullet li) exists ONLY below the canonical width. */
function carouselFixture(): { small: RawNode; large: RawNode } {
const track = (): RawNode => raw("div", { id: "track" });
const pagination = (): RawNode =>
raw("ul", { id: "pagination" }, [
raw("li", {}, [raw("button", { type: "button" }, [{ text: "1" }])]),
raw("li", {}, [raw("button", { type: "button" }, [{ text: "2" }])]),
raw("li", {}, [raw("button", { type: "button" }, [{ text: "3" }])]),
]);
const small = raw("body", {}, [raw("section", { id: "carousel" }, [track(), pagination()])]);
const large = raw("body", {}, [raw("section", { id: "carousel" }, [track()])]);
return { small, large };
}
/** Container serving a DIFFERENT item set per viewport (content identity drift). */
function driftFixture(): { small: RawNode; large: RawNode } {
const item = (id: string): RawNode => raw("li", { id }, [raw("span", {}, [{ text: `Item ${id}` }])]);
const small = raw("body", {}, [raw("ul", { id: "rotator" }, [item("b1"), item("b2"), item("b3"), item("b4")])]);
const large = raw("body", {}, [raw("ul", { id: "rotator" }, [item("a1"), item("a2"), item("a3"), item("a4")])]);
return { small, large };
}
describe("IR grafts non-canonical-only children", () => {
it("grafts a subtree present only at 375 into the canonical tree at its source position", () => {
const { small, large } = carouselFixture();
const ir = buildDivergentIR({ 375: small, 1280: large });
const section = findByTag(ir.root, "section")!;
const kids = elemChildren(section);
assert.deepEqual(kids.map((k) => k.tag), ["div", "ul"], "pagination grafted after the matched track");
const ul = kids[1]!;
assert.equal(ul.attrs.id, "pagination");
// Per-viewport data ONLY at the source viewport — nothing invented at canonical.
assert.deepEqual(Object.keys(ul.computedByVp), ["375"]);
assert.equal(ul.visibleByVp[375], true);
assert.equal(ul.computedByVp[1280], undefined);
assert.equal(ul.bboxByVp[1280], undefined);
// The grafted subtree's descendants carry the same viewport-scoped data.
const bullets = elemChildren(ul);
assert.equal(bullets.length, 3);
for (const li of bullets) {
assert.deepEqual(Object.keys(li.computedByVp), ["375"]);
const btn = elemChildren(li)[0]!;
assert.equal(btn.tag, "button");
assert.deepEqual(Object.keys(btn.computedByVp), ["375"]);
}
// No drift recorded — this is an additive responsive difference, not a set swap.
assert.equal(section.childDriftVps, undefined);
assert.equal(ir.doc.contentDrift, undefined);
});
it("merges the same non-canonical-only child across several viewports into ONE grafted node", () => {
const { small, large } = carouselFixture();
const ir = buildDivergentIR({ 375: small, 768: structuredClone(small), 1280: large });
const section = findByTag(ir.root, "section")!;
const uls = elemChildren(section).filter((k) => k.tag === "ul");
assert.equal(uls.length, 1, "one grafted node, not one per viewport");
assert.deepEqual(Object.keys(uls[0]!.computedByVp).map(Number).sort((a, b) => a - b), [375, 768]);
});
it("is deterministic: two builds from the same capture are byte-identical", () => {
const { small, large } = carouselFixture();
const a = buildDivergentIR({ 375: small, 1280: large });
const b = buildDivergentIR({ 375: structuredClone(small), 1280: structuredClone(large) });
assert.equal(JSON.stringify(a), JSON.stringify(b));
});
it("does not graft a child that never paints at any band viewport", () => {
const { small, large } = carouselFixture();
// Make the whole pagination subtree invisible at 375.
const section = small.children[0] as RawNode;
const pag = (section.children as RawNode[]).find((c) => c.tag === "ul")!;
const markInvisible = (n: RawNode): void => {
n.visible = false; n.computed.display = "none"; n.bbox = { x: 0, y: 0, width: 0, height: 0 };
for (const c of n.children) if ((c as RawNode).tag) markInvisible(c as RawNode);
};
markInvisible(pag);
const ir = buildDivergentIR({ 375: small, 1280: large });
assert.equal(findByTag(ir.root, "ul"), null, "invisible-everywhere subtree is not grafted");
});
});
describe("IR whole-set content drift falls back to faithful-at-canonical", () => {
it("records drift instead of grafting when both sides mutually mismatch", () => {
const { small, large } = driftFixture();
const ir = buildDivergentIR({ 375: small, 1280: large });
const rotator = findByTag(ir.root, "ul")!;
const kids = elemChildren(rotator);
// Canonical set only — the divergent 375 set is NOT grafted (no duplication).
assert.deepEqual(kids.map((k) => k.attrs.id), ["a1", "a2", "a3", "a4"]);
assert.deepEqual(rotator.childDriftVps, [375]);
// Canonical children carry no invented data at the drift viewport.
for (const k of kids) assert.equal(k.computedByVp[375], undefined);
// Surfaced for the manifest, with the final (post-renumber) id.
assert.deepEqual(ir.doc.contentDrift, [{ id: rotator.id, tag: "ul", viewports: [375] }]);
});
it("does not misread an additive difference as drift", () => {
// Canonical set fully matches at 375; 375 merely has extras → graft path, no drift.
const item = (id: string): RawNode => raw("li", { id });
const small = raw("body", {}, [raw("ul", { id: "list" }, [item("x1"), item("x2"), item("e1"), item("e2"), item("e3")])]);
const large = raw("body", {}, [raw("ul", { id: "list" }, [item("x1"), item("x2")])]);
const ir = buildDivergentIR({ 375: small, 1280: large });
const list = findByTag(ir.root, "ul")!;
assert.equal(list.childDriftVps, undefined);
assert.deepEqual(elemChildren(list).map((k) => k.attrs.id), ["x1", "x2", "e1", "e2", "e3"]);
assert.deepEqual(Object.keys(elemChildren(list)[2]!.computedByVp), ["375"]);
});
});
describe("emission of grafted and drift nodes", () => {
it("emits a grafted node as display:none at base with a full reveal band at its viewport", () => {
const { small, large } = carouselFixture();
const ir = buildDivergentIR({ 375: small, 1280: large });
const ul = findByTag(ir.root, "ul")!;
const rules = collectNodeRules(ir, new Map());
const nr = rules.get(ul.id)!;
assert.deepEqual([...nr.base.entries()], [["display", "none"]], "hidden at the canonical base");
assert.equal(nr.bands.length, 1, "exactly one band: the reveal at its source viewport");
const band = nr.bands[0]!;
assert.match(band.media, /max-width/);
assert.equal(band.decls.get("display"), "block", "the band reveals AND lays out the node");
});
it("skips the display:none band for drift stand-ins (and their descendants), keeps it otherwise", () => {
const { small, large } = driftFixture();
// Control: a genuinely canonical-only sibling (absent at 375, NOT under a drift container)
// must still be banded display:none at 375.
(large.children as RawNode[]).push(raw("aside", { id: "desktop-only" }, [{ text: "Desktop" }]));
const ir = buildDivergentIR({ 375: small, 1280: large });
const rules = collectNodeRules(ir, new Map());
const rotator = findByTag(ir.root, "ul")!;
for (const li of elemChildren(rotator)) {
const nr = rules.get(li.id)!;
assert.equal(nr.bands.some((b) => b.decls.get("display") === "none"), false, `stand-in ${li.attrs.id} not hidden at the drift viewport`);
// Descendants of the stand-in subtree are not hidden either.
const span = elemChildren(li)[0]!;
const snr = rules.get(span.id)!;
assert.equal(snr.bands.some((b) => b.decls.get("display") === "none"), false, "stand-in descendant not hidden");
}
const aside = findByTag(ir.root, "aside")!;
const anr = rules.get(aside.id)!;
assert.equal(anr.bands.some((b) => b.decls.get("display") === "none"), true, "unrelated canonical-only node still hidden per band");
});
});
+355
View File
@@ -158,3 +158,358 @@ describe("walker off-screen visibility", () => {
assert.equal(fixed.visible, false, "fixed box below the viewport is invisible"); assert.equal(fixed.visible, false, "fixed box below the viewport is invisible");
}); });
}); });
describe("walker font-metric probe tagging (fix 4)", () => {
let browser: Browser;
let page: Page;
before(async () => {
browser = await chromium.launch();
page = await browser.newPage();
await page.setViewportSize({ width: 375, height: 768 });
});
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("tags a far-off-screen, non-painting measurement scratch node as a probe", async () => {
// The classic font-metric probe pattern (WordPress/typography libs): absolutely positioned,
// parked ~100000px off-screen, visibility:hidden, holding measurement text.
const snap = await capture(`
<p class="real">Real content</p>
<div class="probe" style="position:absolute;top:-99999px;left:-99999px;
visibility:hidden;white-space:nowrap;">Mgy</div>`);
const probe = findByClass(snap.root, "probe")!;
assert.equal(probe.probe, true, "far-off-screen hidden scratch node is a probe");
const real = findByClass(snap.root, "real")!;
assert.ok(!real.probe, "real content is not a probe");
});
it("does NOT tag a near-off-screen hidden drawer (real content) as a probe", async () => {
// A slide-in drawer parked just off the left edge (x:-375, visibility:hidden) is real
// content that a controller can reveal — it must NOT be mistaken for a measurement probe.
const snap = await capture(`
<div class="drawer" style="position:fixed;left:0;top:0;width:375px;height:768px;
transform:translateX(-100%);visibility:hidden;">
<h2 class="dtitle">Menu</h2>
</div>`);
const drawer = findByClass(snap.root, "drawer")!;
assert.ok(!drawer.probe, "a near-off-screen drawer is not a probe");
});
it("does NOT tag an sr-only (visible, on-screen-adjacent) accessibility label as a probe", async () => {
// Screen-reader-only text stays visibility:visible so AT can read it; even parked far off
// via left:-9999px it must survive (and 9999px is under the 10000px probe threshold anyway).
const snap = await capture(`
<a href="/"><span class="sr" style="position:absolute;left:-9999px;">Skip to content</span>Home</a>`);
const sr = findByClass(snap.root, "sr")!;
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");
});
});
// T4 — symmetric circular-WIDTH guard: an authored `width:24px` inside a SHRINK-TO-FIT parent makes
// both width:auto and width:100% reproduce the box (the parent's width still holds), so the raw probe
// reads wAuto/wFill and the width is dropped — collapsing the swatch in the clone. When the element
// authors an explicit definite width (cascade or inline), the probe must trust that and clear both.
describe("walker sizing probe: circular authored-width guard (T4)", () => {
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 px width inside a shrink-wrap parent (cascade rule)", async () => {
// The wrapper is an inline-block that shrink-wraps to the swatch; width:auto on the swatch still
// reads 24px because the parent width holds → the raw verdict would be wAuto:true (dropped).
const snap = await capture(`
<style>
.wrapper { display: inline-block; border: 1px solid #000; }
.swatch { width: 24px; height: 24px; display: block; background: red; }
</style>
<span class="wrapper"><a class="swatch"></a></span>`);
const swatch = findByClass(snap.root, "swatch")!;
assert.ok(swatch.sizing, "swatch was probed");
assert.equal(swatch.sizing!.wAuto, false, "explicit 24px width is not content-sized (auto)");
assert.equal(swatch.sizing!.wFill, false, "explicit 24px width is authored, not a parent fill");
});
it("keeps an explicit width authored via inline style", async () => {
const snap = await capture(`
<span style="display:inline-block;border:1px solid #000;">
<a class="swatch2" style="width:24px;height:24px;display:block;background:blue;"></a>
</span>`);
const swatch = findByClass(snap.root, "swatch2")!;
assert.ok(swatch.sizing, "swatch was probed");
assert.equal(swatch.sizing!.wAuto, false, "inline explicit width is kept (not auto)");
assert.equal(swatch.sizing!.wFill, false, "inline explicit width is not a fill");
});
it("still detects a genuinely content-sized (auto) width as wAuto", async () => {
// No authored width: an inline-block sizing to its text must stay droppable (wAuto:true).
const snap = await capture(`
<div style="display:block;"><span class="cw" style="display:inline-block;">hello content</span></div>`);
const cw = findByClass(snap.root, "cw")!;
assert.ok(cw.sizing, "cw was probed");
assert.equal(cw.sizing!.wAuto, true, "content-sized width is still auto");
});
it("harvests an explicit-width rule from a custom element's SHADOW ROOT stylesheet", async () => {
// The width:24px rule lives inside the custom element's shadow root (a <style> in the shadow tree),
// never in document.styleSheets. Without walking shadow-root sheets the harvest misses it and the
// circular-width guard can't fire for the swatch link. Verify the shadow swatch keeps its width.
const snap = await capture(`
<script>
customElements.define('color-swatch', class extends HTMLElement {
constructor() {
super();
const r = this.attachShadow({ mode: 'open' });
r.innerHTML = '<style>.wrap{display:inline-block;border:1px solid #000}.pin{width:24px;height:24px;display:block;background:green}</style><span class="wrap"><a class="pin"></a></span>';
}
});
</script>
<color-swatch></color-swatch>`);
const pin = findByClass(snap.root, "pin")!;
assert.ok(pin.sizing, "shadow swatch was probed");
assert.equal(pin.sizing!.wAuto, false, "shadow-root explicit 24px width is kept (not auto)");
assert.equal(pin.sizing!.wFill, false, "shadow-root explicit width is not a fill");
});
});
describe("walker text-wrap capture", () => {
let browser: Browser;
let page: Page;
before(async () => {
browser = await chromium.launch();
page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
});
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("captures text-wrap:balance on a heading (modern line-balancing)", async () => {
// Real case: a hero heading authored `text-wrap:balance` wraps its two lines evenly; without
// capturing the prop the clone wraps it lopsidedly.
const snap = await capture(`<h1 style="text-wrap:balance">BUILT RUGGED. WORN DAILY.</h1>`);
const h1 = findByTag(snap.root, "h1")!;
assert.equal(h1.computed.textWrap, "balance", "text-wrap:balance is captured");
});
it("captures text-wrap:pretty", async () => {
const snap = await capture(`<p style="text-wrap:pretty">Some flowing paragraph text here.</p>`);
const p = findByTag(snap.root, "p")!;
assert.equal(p.computed.textWrap, "pretty", "text-wrap:pretty is captured");
});
});
describe("walker shadow-DOM composed-tree serialization (FIX 1)", () => {
let browser: Browser;
let page: Page;
before(async () => {
browser = await chromium.launch();
page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
});
after(async () => {
await browser.close();
});
// Attach an OPEN shadow root to any host carrying data-shadow, injecting its data-shadow value as
// the shadow tree HTML. Runs in-page before collectPage so getComputedStyle/bbox see the composed tree.
const capture = async (html: string) => {
await page.setContent(html);
await page.evaluate(() => {
for (const host of Array.from(document.querySelectorAll("[data-shadow]"))) {
const markup = host.getAttribute("data-shadow") || "";
const sr = (host as HTMLElement).attachShadow({ mode: "open" });
sr.innerHTML = markup;
}
});
await page.evaluate("globalThis.__name = globalThis.__name || ((fn) => fn);");
return page.evaluate(collectPage);
};
it("serializes an open custom element's shadow tree as the host's children", async () => {
// A `<product-info>` web component renders its swatches/title/price INSIDE its shadow root; a
// childNodes-only walk captured it empty. The composed walk must surface the shadow content.
const snap = await capture(`
<product-info data-shadow="
<div class='pi-title'>Magnolia Shirt</div>
<span class='pi-price'>$78.00</span>
"></product-info>`);
const host = findByTag(snap.root, "product-info")!;
assert.ok(host, "the custom element host is present");
assert.equal(host.shadowHost, true, "the host is tagged as a shadow host");
assert.equal(textRun(host).replace(/\s+/g, " ").trim(), "Magnolia Shirt $78.00", "the shadow tree text is captured");
const title = findByClass(host, "pi-title")!;
assert.ok(title, "a shadow descendant node is serialized");
assert.equal(title.inShadow, true, "shadow descendants are tagged inShadow");
assert.ok(title.bbox.width > 0, "shadow nodes get real bboxes via getBoundingClientRect");
});
it("renders the FLATTENED tree: a <slot> is replaced by its assigned light-DOM nodes", async () => {
// The host's light child (the assigned node) renders at the slot position — once, and NOT tagged
// inShadow (it is the author's real content). Shadow chrome around the slot still appears.
const snap = await capture(`
<my-card data-shadow="
<div class='card-frame'><slot></slot></div>
"><h2 class='slotted'>Vintage Sunset T-Shirt</h2></my-card>`);
const host = findByTag(snap.root, "my-card")!;
const frame = findByClass(host, "card-frame")!;
assert.ok(frame, "the shadow frame around the slot is serialized");
assert.equal(frame.inShadow, true, "the shadow frame is tagged inShadow");
const slotted = findByClass(host, "slotted")!;
assert.ok(slotted, "the slotted light child renders at the slot position");
assert.equal(textRun(slotted).trim(), "Vintage Sunset T-Shirt");
assert.ok(!slotted.inShadow, "the slotted light child is NOT tagged inShadow (author content)");
// No double-serialization: exactly one node carries the slotted text.
let count = 0;
const countText = (n: RawNode): void => {
if (n.children.some((c) => isText(c) && c.text.includes("Vintage Sunset T-Shirt"))) count++;
for (const c of n.children) if (!isText(c)) countText(c as RawNode);
};
countText(host);
assert.equal(count, 1, "the slotted child is serialized exactly once");
});
it("uses <slot> fallback content when nothing is assigned", async () => {
const snap = await capture(`
<my-badge data-shadow="
<span class='badge'><slot>Default Label</slot></span>
"></my-badge>`);
const host = findByTag(snap.root, "my-badge")!;
assert.equal(textRun(host).replace(/\s+/g, " ").trim(), "Default Label", "unfilled slot falls back to its own content");
});
it("does not tag ordinary light-DOM nodes as shadow", async () => {
const snap = await capture(`<div class="plain"><p>light</p></div>`);
const plain = findByClass(snap.root, "plain")!;
assert.ok(!plain.shadowHost, "a plain div is not a shadow host");
assert.ok(!plain.inShadow, "plain light DOM is not tagged inShadow");
});
});