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
+689 -113
View File
@@ -14,6 +14,7 @@ import {
import { discoverBreakpoints } from "./breakpoints.js";
import { writeJSON, writeJSONCompact, writeBytes, ensureDir } from "../util/fsx.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;
// 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;
}
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 {
const p = new URL(url).pathname;
const dot = p.lastIndexOf(".");
if (dot >= 0 && dot > p.lastIndexOf("/")) {
const ext = p.slice(dot + 1).toLowerCase().slice(0, 5);
if (/^[a-z0-9]+$/.test(ext)) return ext;
const ext = p.slice(dot + 1).toLowerCase();
if (ext.length <= MAX_EXT_LEN && /^[a-z0-9]+$/.test(ext)) return ext;
}
} catch { /* ignore */ }
return "";
@@ -175,6 +182,83 @@ export function looksLikeVideoFile(bytes: Buffer): boolean {
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> {
// 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.
@@ -209,6 +293,41 @@ async function settle(page: import("playwright").Page, maxMs = 2500): Promise<vo
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 };
/**
@@ -219,64 +338,102 @@ export type DismissResult = { dismissed: string[]; overlaysRemaining: number; re
* 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.
*/
/**
* In-page dismissal click pass (exported for fixture tests). Runs in ONE document — the main
* page OR a cross-origin frame (Attentive/Recart/Klaviyo creatives host their Decline/× inside
* 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[] = [];
// 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 cs = getComputedStyle(el);
if (cs.display === "none" || cs.visibility === "hidden" || parseFloat(cs.opacity || "1") === 0) return false;
const r = (el as HTMLElement).getBoundingClientRect();
return r.width > 0 && r.height > 0;
};
const click = (el: Element): void => { try { (el as HTMLElement).click(); } catch { /* ignore */ } };
// 1) Known consent-framework / generic close affordances, in priority order.
const KNOWN = [
"#onetrust-accept-btn-handler", "#accept-recommended-btn-handler", ".onetrust-close-btn-handler",
"#CybotCookiebotDialogBodyLevelButtonLevelOptinAllowAll", "#CybotCookiebotDialogBodyButtonAccept",
"#truste-consent-button", ".osano-cm-accept-all", ".osano-cm-dialog__close",
"[data-testid='uc-accept-all-button']", "[data-testid='uc-deny-all-button']",
"#didomi-notice-agree-button", ".didomi-continue-without-agreeing",
".qc-cmp2-summary-buttons button[mode='primary']", "button[aria-label='Consent']",
".cc-allow", ".cookie-consent-accept", "#hs-eu-confirmation-button", "#gdpr-consent-tool-wrapper button",
];
for (const sel of KNOWN) {
for (const el of deepEls(document, sel)) {
if (vis(el)) { click(el); dismissed.push(sel); break; }
}
}
// 2) Scoped text-button pass: only inside overlay-ish containers (dialogs, or id/class naming
// cookie/consent/modal/popup/newsletter/overlay/ccpa/privacy) so we never click an ordinary
// 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([
"accept", "accept all", "accept all cookies", "accept cookies", "accept & close",
"i accept", "i agree", "agree", "agree and continue", "allow all", "allow cookies",
"allow all cookies", "got it", "ok", "okay", "continue", "no thanks", "no, thanks",
"dismiss", "close", "got it!", "understood", "yes, i agree",
"decline", "no, thank you", "not now", "maybe later", "no thank you", "skip", "reject all",
]);
const containerSel =
"[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*='newsletter' i],[class*='interstitial' i],[class*='overlay' i],[id*='overlay' i]," +
"[class*='ccpa' i],[id*='ccpa' i],[class*='privacy' i],[id*='pop' i]";
const containers = deepEls(document, containerSel);
for (const c of containers) {
if (!vis(c)) continue;
// 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) {
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); 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;
}
async function clickDismiss(page: import("playwright").Page): Promise<string[]> {
try {
return await Promise.race([
page.evaluate(() => {
const dismissed: string[] = [];
const vis = (el: Element): boolean => {
const cs = getComputedStyle(el);
if (cs.display === "none" || cs.visibility === "hidden" || parseFloat(cs.opacity || "1") === 0) return false;
const r = (el as HTMLElement).getBoundingClientRect();
return r.width > 0 && r.height > 0;
};
const click = (el: Element): void => { try { (el as HTMLElement).click(); } catch { /* ignore */ } };
// 1) Known consent-framework / generic close affordances, in priority order.
const KNOWN = [
"#onetrust-accept-btn-handler", "#accept-recommended-btn-handler", ".onetrust-close-btn-handler",
"#CybotCookiebotDialogBodyLevelButtonLevelOptinAllowAll", "#CybotCookiebotDialogBodyButtonAccept",
"#truste-consent-button", ".osano-cm-accept-all", ".osano-cm-dialog__close",
"[data-testid='uc-accept-all-button']", "[data-testid='uc-deny-all-button']",
"#didomi-notice-agree-button", ".didomi-continue-without-agreeing",
".qc-cmp2-summary-buttons button[mode='primary']", "button[aria-label='Consent']",
".cc-allow", ".cookie-consent-accept", "#hs-eu-confirmation-button", "#gdpr-consent-tool-wrapper button",
];
for (const sel of KNOWN) {
try {
for (const el of Array.from(document.querySelectorAll(sel))) {
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,
// or id/class naming cookie/consent/modal/popup/newsletter) so we never
// click an ordinary page button.
const ACCEPT = new Set([
"accept", "accept all", "accept all cookies", "accept cookies", "accept & close",
"i accept", "i agree", "agree", "agree and continue", "allow all", "allow cookies",
"allow all cookies", "got it", "ok", "okay", "continue", "no thanks", "no, thanks",
"dismiss", "close", "got it!", "understood", "yes, i agree",
]);
const containerSel =
"[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*='newsletter' i],[class*='interstitial' i]";
let containers: Element[] = [];
try { containers = Array.from(document.querySelectorAll(containerSel)); } catch { /* ignore */ }
for (const c of containers) {
if (!vis(c)) continue;
const btns = Array.from(c.querySelectorAll("button,[role='button'],a,input[type='button'],input[type='submit']"));
for (const b of btns) {
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; }
}
}
return dismissed;
}),
// 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)),
]);
try {
const frames = page.frames();
const results = await Promise.all(frames.map((f) => runOne(f).catch(() => [] as string[])));
return results.flat();
} catch {
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
* 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[] };
/**
* 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;
// Deep element walk that descends into OPEN shadow roots. A shadow host reports 0 light-DOM
// children, so a popup mounted inside a shadow tree (Recart's `#recart-popup-root`) is invisible
// to a plain `querySelectorAll("*")` — its full-viewport fixed layer would never be detected and
// would paint into every screenshot. Traverse `element.shadowRoot` (open only; closed roots are
// unreachable — the host itself is still caught by the geometry gate below and removed whole).
const deepEls = (root: ParentNode): Element[] => {
const out: Element[] = [];
const push = (r: ParentNode): void => {
for (const el of Array.from(r.querySelectorAll("*"))) {
out.push(el);
const sr = (el as HTMLElement).shadowRoot;
if (sr) push(sr);
}
};
push(root);
return out;
};
const isLocked = (): boolean => {
const b = document.body, h = document.documentElement;
const bs = getComputedStyle(b), hs = getComputedStyle(h);
return bs.overflow === "hidden" || hs.overflow === "hidden" ||
bs.overflowY === "hidden" || hs.overflowY === "hidden" ||
bs.position === "fixed" || bs.position === "absolute";
};
// Effective z-index: climb to the nearest ancestor that both is positioned/creates a stacking
// context AND reports a numeric z-index. `z-index:auto` on a positioned child paints in its
// parent stacking context, so the parent's z is the layer's true stacking rank.
const effectiveZ = (el: Element): number => {
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 sig = (el: HTMLElement): string => `${el.id} ${el.className}`.toString();
const removedLabels: string[] = [];
let removed = 0;
let remaining = bigOverlays();
if (remaining.length && locked) {
for (const el of remaining) {
const s = sig(el);
const z = effectiveZ(el);
// Scroll-locked + full-viewport ⇒ blocking modal; remove unless it's page chrome. Always
// remove iframes (cross-origin close, unclickable), max-z wrappers, and the overlay unit:
// 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" ||
containsIframe || isShadowHost || z >= 100;
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.overflowY = "visible"; document.documentElement.style.overflowY = "visible";
document.body.style.position = "static";
}
remaining = bigOverlays();
}
// 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.
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(() => {
const vw = window.innerWidth, vh = window.innerHeight;
const bigOverlays = (): HTMLElement[] => {
const out: HTMLElement[] = [];
for (const el of Array.from(document.body.querySelectorAll("*"))) {
const cs = getComputedStyle(el);
if (cs.position !== "fixed" && cs.position !== "sticky") continue;
if (cs.display === "none" || cs.visibility === "hidden" || parseFloat(cs.opacity || "1") === 0) continue;
const r = (el as HTMLElement).getBoundingClientRect();
const z = parseInt(cs.zIndex || "0", 10) || 0;
const area = (r.width * r.height) / (vw * vh);
if (area >= 0.5 && z >= 100 && r.width >= vw * 0.7 && r.height >= vh * 0.5) out.push(el as HTMLElement);
}
return out.filter((el) => !out.some((o) => o !== el && o.contains(el)));
};
const isLocked = (): boolean => {
const b = document.body, h = document.documentElement;
return getComputedStyle(b).overflow === "hidden" || getComputedStyle(h).overflow === "hidden" ||
getComputedStyle(b).position === "fixed";
};
// A scroll-locked page behind a full-viewport overlay IS a blocking modal by
// definition (legit pages don't scroll-lock). So 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 (ruggable's
// z-[1001] drawer). PROTECTED guards real chrome (header/nav/footer) only.
const PROTECTED = /header|navbar|nav-|site-nav|topbar|masthead|footer/i;
const sig = (el: HTMLElement): string => `${el.id} ${el.className}`.toString();
const removedLabels: string[] = [];
let removed = 0;
let remaining = bigOverlays();
if (remaining.length && isLocked()) {
for (const el of remaining) {
const s = sig(el);
const z = parseInt(getComputedStyle(el).zIndex || "0", 10) || 0;
// Scroll-locked + full-viewport ⇒ blocking modal; remove unless it's page
// chrome. Always remove iframes (cross-origin close, unclickable) and the
// max-z-index popup trick.
const removable = !PROTECTED.test(s) || el.getAttribute("aria-modal") === "true" ||
el.tagName === "IFRAME" || z >= 2_000_000_000;
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"; }
remaining = bigOverlays();
}
return { overlaysRemaining: remaining.length, removed, blocking: remaining.length > 0 && isLocked(), removedLabels };
}),
new Promise<{ overlaysRemaining: number; removed: number; blocking: boolean; removedLabels: string[] }>((res) => setTimeout(() => res({ overlaysRemaining: 0, removed: 0, blocking: false, removedLabels: [] }), 6000)),
page.evaluate(finalizeOverlaysInPage),
new Promise<FinalizeOverlaysResult>((res) => setTimeout(() => res({ overlaysRemaining: 0, removed: 0, blocking: false, removedLabels: [] }), 6000)),
]);
} catch {
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
* timeout is exceeded by tall SaaS pages (Playwright also waits for web fonts);
* use a long timeout, freeze animations (also improves determinism), and retry.
* As a last resort take a viewport-only shot so the file exists (the capture gate
* Stage 2 — canvas raster fallback. A visible <canvas> (animated background, chart,
* WebGL scene) is a runtime-drawn surface the clone cannot reproduce as DOM, so it
* would render as an empty box. Rasterize each meaningful canvas to a PNG still under
* 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).
*/
async function captureScreenshot(
@@ -487,6 +847,13 @@ async function captureScreenshot(
vw: number,
log: (e: Record<string, unknown>) => 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++) {
try {
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: {
url: string;
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
// asset surfaces in visual_assets_missing instead.
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");
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 === "video" ? "mp4" : type === "lottie" ? "json" : "png");
const name = `${sha1_12(url)}.${ext}`;
@@ -870,6 +1377,47 @@ export async function captureSite(opts: {
await page.evaluate(() => window.scrollTo(0, 0));
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
@@ -973,6 +1521,21 @@ export async function captureSite(opts: {
const scrollAnimsCanceled = await neutralizeScrollTimelineAnimations(page);
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
// pathologically large/animated DOM (e.g. asana.com) could hang forever.
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.
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).
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";
// 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 =
/(?: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
// screenshot (the poster frame + play chrome) is the faithful static paint.
const FRAME_STILL_RE =
@@ -165,6 +171,21 @@ export function graftFrameIntoSnapshot(
x: Math.round((n.bbox.x + frame.contentX) * 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 ?? {};
delete a["data-cid-cap"]; // capture-ids belong to the main document only
if (a.id) a.id = prefix + a.id;
@@ -184,8 +205,13 @@ export function graftFrameIntoSnapshot(
host.children = [wrapper];
// 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" };
if ((host.computed.position || "static") === "static") host.computed.position = "relative";
// 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
// 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>;
/** 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
// active/inactive state. Captured per discrete state so the generated controller
// can toggle between them faithfully (M2: tabs + accordion).
@@ -363,11 +370,42 @@ export async function captureInteractions(page: Page, opts?: { maxCandidates?: n
return o;
}, { capId, props: PSEUDO_PROPS as unknown as string[] });
const diff = (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;
const diff = diffStyle;
// Pseudo-state driver via CDP `CSS.forcePseudoState`. Pointer-based hovering (page.hover)
// 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
// 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>> = {};
for (const capId of candidates) {
const sel = `[data-cid-cap="${capId}"]`;
const base = await read(capId);
if (!base) continue;
const nodeId = await nodeIdFor(capId);
const hiddenBase = await readHiddenDesc(capId); // hidden descendants → hover-reveal candidates
const hiddenCaps = Object.keys(hiddenBase);
// :hover — settle before reading: framer-style hover is driven by JS (Framer Motion)
// with a short transition, so an immediate read catches the pre-transition frame and
// sees no delta (the nav-link hover was missed this way). Wait for the transition to land.
try {
await page.hover(sel, { timeout: 1200, force: true });
await page.waitForTimeout(180);
// :hover — force the pseudo-class on the node (geometry-independent; see `force` above),
// then settle before reading: an authored `:hover`/JS transition animates from the resting
// frame, so an immediate read catches the pre-transition value and sees no delta. Wait for it.
if (await force(nodeId, ["hover"])) {
await settlePseudo();
const h = await read(capId);
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
// 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) {
await page.waitForTimeout(380); // let the reveal transition fully finish before reading
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;
}
} catch { /* not hoverable (covered/offscreen) — skip */ }
await page.mouse.move(1, 1).catch(() => {});
// :focus (only for focusable-ish; cheap to attempt, guarded)
try {
await page.focus(sel, { timeout: 800 });
await force(nodeId, []); // clear :hover before probing :focus
}
// :focus — force the pseudo-class the same way (also occlusion-independent).
if (await force(nodeId, ["focus"])) {
await settlePseudo();
const f = await read(capId);
if (f) { const d = diff(base, f); if (Object.keys(d).length) focus[capId] = d; }
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur());
} catch { /* not focusable — skip */ }
await force(nodeId, []); // restore the resting state
}
}
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 });
+195 -15
View File
@@ -60,6 +60,83 @@ export type MarqueeSpec = {
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 = {
waapi: WaapiAnim[];
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
* scroll width) so the clone can replay the loop. Read-only beyond scrolling (restores
* 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[]> {
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++; }
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
// 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[] = [];
for (const el of Array.from(document.querySelectorAll("[data-cid-cap]"))) {
if (Math.abs(txOf(el)) <= 0.5) continue;
if (el.children.length < 2) 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);
if (cand.length >= 16) break;
if (cand.length >= 8) break;
}
const out: Array<{ cap: string; axis: "x"; pxPerSec: number; periodPx: number }> = [];
const seen = new Set<string>();
for (const el of cand) {
const cap = el.getAttribute("data-cid-cap"); if (!cap || seen.has(cap)) continue;
try { el.scrollIntoView({ block: "center" }); } catch { /* ignore */ }
await sleep(450); // 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); }
const dxs: number[] = []; for (let i = 1; i < xs.length; i++) dxs.push(xs[i]! - xs[i - 1]!);
if (dxs.length < 3) continue;
// velocity = median per-120ms delta (median ignores the single wrap-reset outlier).
const med = [...dxs].sort((a, b) => a - b)[Math.floor(dxs.length / 2)]!;
const pxPerSec = Math.round((med / 120) * 1000);
if (Math.abs(pxPerSec) < 4) continue; // not actually moving (e.g. a frozen scroll-scrub offset)
// direction must be consistent in all but (at most) the one wrap step.
if (dxs.filter((d) => Math.sign(d) === Math.sign(med)).length < dxs.length - 1) continue;
await sleep(300); // wake the off-screen-paused ticker + let the scroll settle
// Discriminator 2: two velocity windows ≥1.5s apart. A scroll-settle lerp decays;
// a real marquee holds constant velocity.
const w1 = await sampleWindow(el, WINDOW_SAMPLES, SAMPLE_MS);
await sleep(INTER_WINDOW_MS);
const w2 = await sampleWindow(el, WINDOW_SAMPLES, SAMPLE_MS);
if (w1.length < 3 || w2.length < 3) continue;
// direction must be consistent within window 1, but for (at most) the one wrap step.
const med1 = [...w1].sort((a, b) => a - b)[Math.floor(w1.length / 2)]!;
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);
if (periodPx < 40) continue;
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");
if (!cap) continue;
const elem = el as Element;
// 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
// only track small text-bearing elements (a rotating word/phrase, not a big block).
// 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 || "");
if (!txt || txt.length > 80) continue;
const e = changes.get(cap) ?? { texts: [], times: [] };
+267 -16
View File
@@ -44,6 +44,18 @@ export type RawNode = {
computed: RawStyle;
bbox: RawBBox;
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;
before?: RawStyle;
after?: RawStyle;
@@ -51,6 +63,10 @@ export type RawNode = {
// clone renders the browser's default gray, losing the authored placeholder color/type.
placeholder?: RawStyle;
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[];
};
@@ -64,6 +80,10 @@ export type FontFace = {
display?: string;
unicodeRange?: 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 = {
@@ -134,6 +154,11 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
"letterSpacing", "wordSpacing", "textAlign", "textTransform",
"textDecorationLine", "textDecorationColor", "textDecorationStyle",
"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",
// 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
@@ -286,7 +311,24 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
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; }
const tag = el.tagName.toLowerCase();
nodeCount++;
@@ -359,9 +401,36 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
const hf = el.getBoundingClientRect().height;
// 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).
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 = {
wAuto: Math.abs(wa - r.width) <= 0.5, wFill: Math.abs(wf - r.width) <= 0.5, hAuto: Math.abs(ha - r.height) <= 0.5,
hFill: Math.abs(hf - r.height) <= 0.5,
wAuto, wFill, hAuto,
hFill,
wMin: Math.round(wmin * 100) / 100, wMax: Math.round(wmax * 100) / 100,
};
} finally {
@@ -401,6 +470,8 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
computed,
bbox,
visible: isVisible(el, cs, bbox),
...(isProbe(cs, bbox) ? { probe: true } : {}),
...(inShadow ? { inShadow: true } : {}),
...(sizing ? { sizing } : {}),
children: [],
};
@@ -429,6 +500,14 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
// Inline SVG → raw markup, no recursion.
if (tag === "svg") {
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;
}
@@ -436,8 +515,28 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
// code blocks with highlighted spans separated by newlines).
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.
for (const child of Array.from(el.childNodes)) {
for (const child of childNodes) {
if (child.nodeType === Node.TEXT_NODE) {
const t = child.textContent || "";
if (preserveWs && t.length > 0) {
@@ -457,8 +556,28 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
}
if (child.nodeType !== Node.ELEMENT_NODE) continue;
const childEl = child as Element;
if (SKIP_TAGS.has(childEl.tagName.toLowerCase())) continue;
const sn = serializeElement(childEl);
const childTag = childEl.tagName.toLowerCase();
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);
}
@@ -470,22 +589,107 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
const cssUrlSet = new Set<string>();
const keyframes: 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 => {
try { return new URL(u, document.baseURI).href; } catch { return u; }
const absUrl = (u: string, base?: string): string => {
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;
let m: RegExpExecArray | null;
while ((m = re.exec(text)) !== null) {
const raw = m[2];
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)) {
const type = rule.constructor.name;
if (type === "CSSFontFaceRule") {
@@ -502,26 +706,40 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
display: s.getPropertyValue("font-display") || undefined,
unicodeRange: s.getPropertyValue("unicode-range") || undefined,
stretch: s.getPropertyValue("font-stretch") || undefined,
baseHref: base,
});
harvestUrlsFromText(src);
harvestUrlsFromText(src, base);
}
} else if (type === "CSSKeyframesRule") {
keyframes.push((rule as CSSKeyframesRule).cssText);
} else if (type === "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") {
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") {
// 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;
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)) {
try {
readRules(sheet.cssRules);
readRules(sheet.cssRules, sheetBaseHref(sheet));
} catch {
// 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.
@@ -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.
try {
const rootCs = window.getComputedStyle(document.documentElement);
+294 -43
View File
@@ -1,7 +1,7 @@
import { join } from "node:path";
import { rmSync } from "node:fs";
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 { generateCss, RESET_CSS } from "./css.js";
import { generateInteractionCss } from "./interactionCss.js";
@@ -17,7 +17,7 @@ import { buildClassMap } from "./classMap.js";
import { buildTailwind, tailwindGlobalsCss } from "./tailwind.js";
import { planSections, type SectionPlan } from "./sectionSplit.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";
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)}}`;
}
/** 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
* by renderAttrs. Each valueExpr is ready-to-emit JS source (a JSON literal,
* `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;
value = kept.join(", ");
} else if (key === "href") {
// Preserve in-page anchors. For multi-route sites a linkRewrite maps internal
// links to the generated clone routes (and collapsed-collection links to their
// representative); otherwise absolutize so it never 404s inside the clone
// (external navigation is allowed by the rubric).
if (!value.startsWith("#")) value = ctx?.linkRewrite ? ctx.linkRewrite(value) : resolveUrl(value, sourceUrl);
// 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
// links to the generated clone routes (and collapsed-collection links to their
// representative); otherwise absolutize so it never 404s inside the clone
// (external navigation is allowed by the rubric).
value = ctx?.linkRewrite ? ctx.linkRewrite(value) : resolveUrl(value, sourceUrl);
}
}
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")]);
// 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") {
// Strip the Stage-4 capture-id (`data-cid-cap`) the interaction pass stamps on
// elements: it's internal instrumentation, render-inert, and would otherwise
// surface verbatim in the markup and (post-extraction) as a bogus data field.
const inner = svgInnerForNode(node, ctx);
const svgAttrs = extractSvgAttrs(node.rawHTML);
let hasFillAttr = false;
let rawFill: string | undefined;
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;
const reactName = SVG_ATTR_RENAME[k] ?? k;
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)]);
}
// Raw SVG icons often rely on site CSS (`svg { fill: currentColor }` or a class) that is
// stripped during extraction. If the root didn't declare a fill, inherit the surrounding text
// color so monochrome wordmarks/icons don't fall back to the browser's black default.
if (!hasFillAttr && !props.some(([pk]) => pk === "fill")) props.push(["fill", JSON.stringify("currentColor")]);
// Reconcile the root fill against the captured COMPUTED paint. A raw `fill="none"` only looks
// unfilled: site CSS (`fill: currentColor` on the wordmark, an inherited color) may actually
// paint it, and that CSS is stripped during extraction. resolveSvgRootFill recovers the real
// 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)} }`]);
}
@@ -445,6 +485,50 @@ function extractSvgAttrs(outerHTML: string): Array<[string, string]> {
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
// 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
@@ -546,6 +630,12 @@ export function resolveTag(node: IRNode, insideInteractive: boolean, insideTable
// 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).
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 (violatesContentModel(node, tag)) tag = "div";
return tag;
@@ -591,7 +681,7 @@ function renderNode(node: IRNode, assetMap: Map<string, string>, sourceUrl: stri
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) {
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
* by renderNode (parentTag is the element tag) and the body/chrome fragment
* 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 parts: string[] = [];
// 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 = "";
const flushText = () => {
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 = "";
};
const reg = ctx?.components;
@@ -708,12 +803,20 @@ function takeCid(coll: CidCollector, instances: IRNode[]): number {
return idx;
}
/** A self-contained class-name merger emitted into each component module that needs it
* (kept import-free so component files stay standalone). Skips falsy parts and joins
* exact for our output because a node's baked base classes and its per-instance overrides
* are disjoint token sets. Swap in `tailwind-merge` if you 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}`;
/** The single shared `cn()` module (`src/lib/utils.ts`), imported by every module that
* merges class names one definition per clone instead of a copy per component file.
* Skips falsy parts and joins exact for our output because a node's baked base classes
* and its per-instance overrides are disjoint token sets. Swap in `tailwind-merge` if you
* want conflict-aware merging when hand-editing the ./_styles overrides. */
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
* whitespace-separated tokens. Returns [] for a non-string / unparseable source. */
@@ -852,6 +955,15 @@ function canonicalViewportFor(n: IRNode): number {
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[] };
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 styles = reg.styleDecls.map((s) => `const ${s.varName} = ${s.body};`);
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];
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.`;
}
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 [];
return [...reg.funcDefs].map(([name, src]) => {
const agg = reg.byName.get(name);
void agg;
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
// 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).
@@ -1842,9 +1955,9 @@ export function componentFiles(reg: ComponentRegistry | undefined, svgs?: SvgReg
for (const c of scanRefs(src).comps) {
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" : "";
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 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 allConsts = consts;
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)}";` : "",
hasLottie ? `import DittoLottie from "${dittoLottieImportPath(0)}";` : "",
hasMenus ? `import DropdownMenu from "${dropdownMenuImportPath(0)}";` : "",
/\bcn\(/.test(body) ? cnImportLine(1) : "", // page.tsx lives at src/app
...compImports,
].filter(Boolean).join("\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
* from the captured URL / title / description the discovery files a real site ships. */
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 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";
${siteImport}
export const dynamic = "force-static";
export default function robots(): MetadataRoute.Robots {
return {
rules: { userAgent: "*", allow: "/" },
sitemap: ${JSON.stringify(origin + "/sitemap.xml")},
sitemap: SITE_ORIGIN + "/sitemap.xml",
};
}
`;
const sitemap = `import type { MetadataRoute } from "next";
${siteImport}
export const dynamic = "force-static";
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";
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 jsonLd = seo ? jsonLdHeadMarkup(seo, 8) : "";
const head = jsonLd ? ` <head>\n${jsonLd}\n </head>\n` : "";
const siteImport = seo ? SITE_ORIGIN_LAYOUT_IMPORT + "\n" : "";
return `import "./globals.css";
import "./ditto.css";
import type { ReactNode } from "react";
${siteImport}
${metadata}${viewport}
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 {
const cw = ir.doc.canonicalViewport;
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
// viewport at every captured width), neither should the clone. JS-driven
// widgets (custom-element carousels, sliders) position children off-axis via
@@ -2222,8 +2366,7 @@ ${fontGraph.css}
${tokensCss}
/* page base */
html { background: ${htmlBg}; }
body { font-family: ${SYSTEM_FALLBACK}; }${clip}
${htmlBgRule(htmlBg)}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 repeatedGridParents = 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 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 => {
if (c.kind !== "card-grid" && c.kind !== "feature-grid" && c.kind !== "product-grid") return null;
const regimes = c.responsiveRegimes
.filter((r) => (r.visibleItems ?? 0) > 0 && (r.columns ?? 0) > 0)
.sort((a, b) => a.viewport - b.viewport);
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 tokens: string[] = [];
let last: number | undefined;
@@ -2377,6 +2616,12 @@ function recipeResponsiveClassCleaner(recipes: RecipeReport | undefined, opts: {
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) {
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") fillGridParents.add(c.itemParentCid);
const columns = gridColumnTokens(c);
@@ -2390,8 +2635,8 @@ function recipeResponsiveClassCleaner(recipes: RecipeReport | undefined, opts: {
const columnPlan = opts.tailwind ? columnPlans.get(cid) : undefined;
const keep = tokens.filter((token) => {
if (columnPlan && /^(?:[a-z0-9-]+:)*grid-cols-(?:\d+|\[[^\]]+\])$/.test(token)) return false;
if (/^(?:[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) && /^(?:[a-z0-9-]+:)*grid-rows-\[(?:auto_?)+\]$/.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);
if (initialCols) {
const prefix = initialCols[1] ? `${initialCols[1]}:` : "";
@@ -2436,9 +2681,12 @@ export function generateApp(input: GenerateInput, tokensCss: string): { pageTsx:
const mode = input.humanizeMode ?? "tailwind";
// Tailwind mode (default): translate each node's exact decls to utility classes.
// 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 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 styleOf = tw ? (cid: string) => tw.styleOf.get(cid) : undefined;
// 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) {
const cw = ir.doc.canonicalViewport;
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 clip = noHScroll ? "\nhtml, body { overflow-x: clip; }" : "";
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, "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 (accordions.length) writeText(join(rootDir, "ditto", "Accordion.tsx"), ACCORDION_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";
import { useEffect, useRef } from "react";
import { useEffect } from "react";
type CapStyle = Record<string, string>;
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.
* Hydration initializes the captured base state, then clicks toggle only the target row. */
export default function Accordion({ specs }: { specs: AccordionSpec[] }) {
const wired = useRef(false);
useEffect(() => {
if (wired.current) return;
wired.current = true;
const ac = new AbortController();
const { signal } = ac;
for (const spec of specs) {
const state = spec.items.map((it) => it.expanded);
const renderItem = (i: number) => {
@@ -193,10 +192,11 @@ export default function Accordion({ specs }: { specs: AccordionSpec[] }) {
e.preventDefault();
state[i] = !state[i];
renderItem(i);
});
}, { signal });
renderItem(i);
});
}
return () => ac.abort();
}, [specs]);
return null;
}
@@ -204,7 +204,7 @@ export default function Accordion({ specs }: { specs: AccordionSpec[] }) {
/** The fixed DittoWire client component, written once per generated app. */
export const DITTO_WIRE_TSX = `"use client";
import { useEffect, useRef } from "react";
import { useEffect } from "react";
type CapStyle = Record<string, string>;
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
* base state exactly, so state styles are applied only on user interaction. */
export default function DittoWire({ spec }: { spec: Spec }) {
const wired = useRef(false);
useEffect(() => {
if (wired.current) return;
wired.current = true;
const ac = new AbortController();
const { signal } = ac;
if (spec.kind === "tabs") {
let active = spec.active;
const render = () => spec.tabs.forEach((t, i) => {
@@ -254,7 +253,7 @@ export default function DittoWire({ spec }: { spec: Spec }) {
spec.tabs.forEach((t, i) => {
const trig = byCid(t.trigger);
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) => {
const k = (e as KeyboardEvent).key;
if (k === "ArrowRight" || k === "ArrowLeft") {
@@ -263,7 +262,7 @@ export default function DittoWire({ spec }: { spec: Spec }) {
render();
byCid(spec.tabs[active].trigger)?.focus();
}
});
}, { signal });
});
// No initial render() — the static base state is already correct.
} else if (spec.kind === "accordion") {
@@ -278,7 +277,7 @@ export default function DittoWire({ spec }: { spec: Spec }) {
};
spec.items.forEach((it, i) => {
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.
} 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 prevEl = spec.prev ? byCid(spec.prev) : null;
nextEl?.addEventListener("click", (e) => { e.preventDefault(); go(index + 1); });
prevEl?.addEventListener("click", (e) => { e.preventDefault(); go(index - 1); });
spec.bullets.forEach((b, bi) => byCid(b)?.addEventListener("click", (e) => { e.preventDefault(); go(bi); }));
nextEl?.addEventListener("click", (e) => { e.preventDefault(); go(index + 1); }, { signal });
prevEl?.addEventListener("click", (e) => { e.preventDefault(); go(index - 1); }, { signal });
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.
} else {
// 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) 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) {
const root = trig.parentElement ?? trig;
root.addEventListener("mouseenter", () => set(true));
root.addEventListener("mouseleave", () => set(false));
root.addEventListener("mouseenter", () => set(true), { signal });
root.addEventListener("mouseleave", () => set(false), { signal });
}
it.closes.forEach((c) => byCid(c)?.addEventListener("click", (e) => { e.preventDefault(); set(false); }));
if (it.backdropClose) panel.addEventListener("click", (e) => { if (e.target === panel) set(false); });
document.addEventListener("keydown", (e) => { if ((e as KeyboardEvent).key === "Escape" && open) 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); }, { signal });
document.addEventListener("keydown", (e) => { if ((e as KeyboardEvent).key === "Escape" && open) set(false); }, { signal });
});
// No initial set() — the static base state is already correct.
}
return () => ac.abort();
}, [spec]);
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 }) {
useEffect(() => {
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;
void (async () => {
const lottie = (await import("lottie-web")).default;
@@ -134,17 +134,59 @@ export default function DittoLottie({ spec }: { spec: LottieSpec }) {
for (const it of spec.items) {
const el = byCid(it.cid);
if (!el) continue;
// clear the captured placeholder frame so it never stacks with the live render
el.innerHTML = "";
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({
container: el,
container: mount,
renderer: it.renderer === "canvas" ? "canvas" : "svg",
loop: it.loop,
autoplay: it.autoplay && !stopped,
...(it.path ? { path: it.path } : { animationData: it.animationData as object }),
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 {} }
anims.push(anim);
} catch {
+7
View File
@@ -56,6 +56,13 @@ export function buildManifest(args: {
fallback: fontGraph.entries.filter((f) => f.status === "fallback").length,
},
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
// still covered the page, video stills materialized, and per-viewport quiescence.
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. */
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 };
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
* render is therefore unchanged. */
export default function DropdownMenu({ menus }: { menus: RTMenu[] }) {
const wired = useRef(false);
useEffect(() => {
if (wired.current) return;
wired.current = true;
const ac = new AbortController();
const { signal } = ac;
const openPanels: HTMLElement[] = [];
for (const m of menus) {
const trig = byCid(m.trigger);
if (!trig) continue;
@@ -127,27 +127,41 @@ export default function DropdownMenu({ menus }: { menus: RTMenu[] }) {
panel = wrap.firstElementChild as HTMLElement | null;
if (!panel) return;
document.body.appendChild(panel);
openPanels.push(panel);
place();
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());
if (m.hoverOpen) {
const root = trig.parentElement ?? trig;
root.addEventListener("mouseenter", open);
root.addEventListener("mouseleave", close);
root.addEventListener("mouseenter", open, { signal });
root.addEventListener("mouseleave", close, { signal });
} 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) => {
const t = e.target as Node;
if (panel && !trig.contains(t) && !panel.contains(t)) close();
});
window.addEventListener("resize", place);
window.addEventListener("scroll", place, { passive: true });
}, { signal });
window.addEventListener("resize", place, { signal });
window.addEventListener("scroll", place, { passive: true, signal });
}
(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]);
return null;
}
+35 -4
View File
@@ -41,12 +41,31 @@ function capToCid(ir: IR): Map<string, string> {
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
* scoped by an include filter, for multi-route body/chrome splitting). Specs whose
* element was pruned are dropped (left static). */
export function buildMotionSpec(ir: IR, motion: MotionCapture | undefined, include?: (cid: string) => boolean): MotionSpec {
if (!motion) return { waapi: [], rotators: [], reveals: [], marquees: [] };
const map = capToCid(ir);
const nodeByCid = cidToNode(ir);
const ok = (cid: string | undefined): cid is string => !!cid && (!include || include(cid));
const waapi: RTWaapi[] = [];
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[]) {
const cid = map.get(r.cap);
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 });
}
const reveals: RTReveal[] = [];
@@ -157,7 +181,7 @@ export default function DittoMotion({ spec }: { spec: MotionSpec }) {
useEffect(() => {
if ((window as any).__dittoMotionStopped) return; // measurement mode — apply nothing
const intervals: ReturnType<typeof setInterval>[] = [];
const rotators: Array<{ el: HTMLElement; original: string | null }> = [];
const rotators: Array<{ el: HTMLElement; original: Node[] }> = [];
const anims: Animation[] = [];
// per-reveal "show now" fns (also the cleanup); animate=false jumps to the settled frame
const revealed: Array<(animate: boolean) => void> = [];
@@ -196,8 +220,15 @@ export default function DittoMotion({ spec }: { spec: MotionSpec }) {
for (const r of spec.rotators) {
const el = byCid(r.cid);
if (!el || r.texts.length < 2) continue;
const original = el.textContent;
const start = r.texts.findIndex((t) => t === (original || "").replace(/\\s+/g, " ").trim());
// Defense in depth: a genuine rotating word is a text leaf. If the target has element
// 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;
rotators.push({ el, original });
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 = () => {
(window as any).__dittoMotionStopped = true;
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 */ } }
if (io) io.disconnect();
if (forceTimer) clearTimeout(forceTimer);
+112 -45
View File
@@ -15,6 +15,7 @@
import type { IR, IRNode } from "../normalize/ir.js";
import { isTextChild } from "../normalize/ir.js";
import type { RecipeCandidate, RecipeKind, RecipeReport } from "../infer/recipes.js";
import { detectSectionNodes, heroLikeHeader } from "../infer/sections.js";
import { subtreeSignature } from "../site/sharedLayout.js";
export type SectionPlan = {
@@ -23,7 +24,7 @@ export type SectionPlan = {
};
const MIN_SECTION_H = 56;
const MAX_SECTIONS = 24;
const MAX_SECTIONS = 32;
function box(n: IRNode, cw: number): { width: number; height: number } | undefined {
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 {
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[] {
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 {
if (depth < 0) return false;
@@ -105,6 +100,83 @@ function identName(name: string): string {
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 {
if (n.tag === "nav" || n.tag === "header") 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 {
const cw = ir.doc.canonicalViewport;
// Descend through the wrapper chain (single significant child) to the container
// whose children are the actual sections.
let node = ir.root;
for (let i = 0; i < 10; i++) {
const sig = significantChildren(node, cw);
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
const pageH = ir.doc.perViewport[cw]?.scrollHeight ?? 0;
// The shared recursive band decomposition (infer/sections) — the same roots the
// section gate validates, so each emitted file corresponds to a gate section.
const bands = detectSectionNodes(ir).filter((n) => n.id !== ir.root.id);
// Exclude any band 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
// `.map()` over a data array — not a wall of near-identical "section" files. Only the
// distinct, one-off blocks become sections.
@@ -246,29 +311,14 @@ export function planSections(ir: IR, recipes?: RecipeReport): SectionPlan {
return list.filter((s) => (count.get(subtreeSignature(s)) ?? 0) < 3);
};
let candidates = significantChildren(node, cw);
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 sections = distinctOf(bands);
let recipeNameHints = new Map<string, string>();
if (sections.length < 3 && fallback.sections.length >= 3) {
sections = fallback.sections;
recipeNameHints = fallback.names;
if (sections.length < 3) {
const fallback = recipeFallbackSections(ir, recipes);
if (fallback.sections.length >= 3) {
sections = fallback.sections;
recipeNameHints = fallback.names;
}
}
const roots = new Map<string, string>();
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}`;
};
// 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;
sections.forEach((sec, i) => {
const isLast = i === sections.length - 1;
@@ -287,18 +341,31 @@ export function planSections(ir: IR, recipes?: RecipeReport): SectionPlan {
let name: string;
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");
} 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";
} 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";
} else if (!heroAssigned) {
} else if (!heroAssigned && yOf(sec, cw) <= heroMaxY) {
heroAssigned = true;
name = "HeroSection";
} else if (recipeName) {
name = recipeName;
} 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));
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)));
});
+83 -11
View File
@@ -289,8 +289,15 @@ function metadataObject(report: SeoInventory): Record<string, unknown> {
if (keywords) metadata.keywords = keywords;
if (report.robots) metadata.robots = report.robots;
if (report.referrer) metadata.referrer = report.referrer;
const sourceOrigin = originOf(report.sourceUrl);
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) {
const languages: Record<string, string> = {};
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;
}
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 (Object.keys(og).length) metadata.openGraph = og;
@@ -352,8 +359,23 @@ function metadataObject(report: SeoInventory): Record<string, unknown> {
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 {
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 {
@@ -367,13 +389,31 @@ function safeJsonLd(text: string): string {
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 {
if (!report.jsonLd.length) return "";
const pad = " ".repeat(indent);
const sourceOrigin = originOf(report.sourceUrl);
return report.jsonLd.map((entry, index) => `${pad}<script
${pad} key="ditto-json-ld-${index}"
${pad} type="application/ld+json"
${pad} dangerouslySetInnerHTML={{ __html: ${JSON.stringify(safeJsonLd(entry.text))} }}
${pad} dangerouslySetInnerHTML={{ __html: ${jsonLdHtmlExpr(entry.text, sourceOrigin)} }}
${pad}/>`).join("\n");
}
@@ -397,6 +437,27 @@ function originOf(url: string): string {
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 {
const title = report.title || routes[0]?.title || "Generated Clone";
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]> {
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";
${siteImport}
export const dynamic = "force-static";
export default function robots(): MetadataRoute.Robots {
return {
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",
description: report.description,
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";
${siteImport}
export const dynamic = "force-static";
export default function sitemap(): MetadataRoute.Sitemap {
return ${JSON.stringify(sitemapEntries, null, 2)};
return [
${entryLines},
];
}
`;
const sourceLlms = resourceText(report, "llms");
@@ -472,11 +543,12 @@ function xmlEscape(value: string): string {
export function seoStaticFiles(report: SeoInventory, routes: SeoRouteSummary[]): Array<[string, string]> {
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 = [
"User-agent: *",
"Allow: /",
`Sitemap: ${sitemapUrl}`,
"Sitemap: /sitemap.xml",
"",
].join("\n");
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">',
...sitemapRoutes.map((route, index) => [
" <url>",
` <loc>${xmlEscape(route.url)}</loc>`,
` <loc>${xmlEscape(relativizeToSource(route.url, origin).path)}</loc>`,
" <changefreq>weekly</changefreq>",
` <priority>${index === 0 ? "1.0" : "0.7"}</priority>`,
" </url>",
+279 -22
View File
@@ -18,6 +18,7 @@
import type { IR, IRNode } from "../normalize/ir.js";
import { isTextChild } from "../normalize/ir.js";
import { collectNodeRules, computeBands, keyframesCss, type NodeRule } from "./css.js";
import { colorClusterKey } from "../infer/semanticTokens.js";
import type { InteractionCapture, StyleDelta } from "../capture/interactions.js";
// ---- arbitrary-value escaping ----
@@ -28,6 +29,25 @@ import type { InteractionCapture, StyleDelta } from "../capture/interactions.js"
function arb(v: string): string {
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 {
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" },
"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" },
"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-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" },
@@ -243,7 +264,14 @@ export function declToUtil(prop: string, value: string): string {
if (value === "0" || value === "0px" || value === "0rem") {
return ZERO_NAMED.has(prop) ? `${ARB[prop]}-0` : `${ARB[prop]}-[0px]`;
}
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)) {
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) };
return null;
}
function collapseBases(bases: string[]): string[] {
export function collapseBases(bases: string[]): string[] {
const byHead = new Map<string, SidePart>(); // head → its parsed part (sides are unique within a group)
for (const b of bases) { const p = parseSide(b); if (p) byHead.set(p.head, p); }
const origOf = new Map<string, string>(); // head → original base string (to drop/replace by identity)
@@ -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}`); }
const cv = allEqSide(bcSuf);
if (cv !== null) { replace.set(`border-t-${cv}`, `border-${cv}`); for (const s of ["r", "b", "l"]) drop.add(`border-${s}-${cv}`); }
// flex:1 1 0% → the idiomatic `flex-1` (Tailwind `flex-1` compiles to `flex: 1 1 0%`, EXACTLY these
// longhands). Fold when this band sets flex-grow:1 (`grow-[1]`) AND a zero flex-basis (`basis-[0%]`,
// or its already-shortened `basis-0` — reached from a band delta). flex-shrink is the CSS default 1
// (elided) unless an explicit `shrink-0` is present, which would break the equivalence — so require
// its absence. Emitting `flex-1` rather than `grow basis-[0%]` also sidesteps the `basis-[0%]`→`basis
// -0` prettify hazard (0% content-sizes against an indefinite main axis; 0px is a definite zero).
const hasGrow1 = bases.includes("grow-[1]");
const zeroBasis = bases.includes("basis-[0%]") ? "basis-[0%]" : bases.includes("basis-0") ? "basis-0" : null;
if (hasGrow1 && zeroBasis && !bases.includes("shrink-0")) {
replace.set("grow-[1]", "flex-1");
drop.add(zeroBasis);
}
const out: string[] = [];
for (const b of bases) { if (drop.has(b)) continue; out.push(replace.get(b) ?? b); }
return out;
@@ -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);
if (pm) {
const v = parseFloat(pm[2]!);
// A percentage on a MAIN-SIZE axis whose containing block may be indefinite is NOT equal to the
// same length in px. `flex-basis:0%` against an auto-sized (indefinite) flex container falls back
// to CONTENT sizing per the flexbox spec, whereas `flex-basis:0` (definite zero) gives a zero base
// size — collapsing a `flex:1 1 0%` item to 0 in an auto-height column. `height`/`min-height` in %
// resolve to `auto` when the containing block's height is indefinite, the same hazard. So never
// rewrite `[0%]` to the definite `-0` for these prefixes; keep the literal `basis-[0%]`/`h-[0%]`.
// (Width/inset percentages resolve against the always-definite containing-block WIDTH — safe.)
if (v === 0 && (pm[1] === "basis" || pm[1] === "h" || pm[1] === "min-h")) return base;
const frac = FRACTIONS.find(([p]) => Math.abs(p - v) < 0.4);
return frac ? `${pm[1]}-${frac[1]}` : base;
}
@@ -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
// 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.
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 NAMED_K_SORTED = [...NAMED_K].sort((a, b) => a - b);
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;
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);
if (!m) return base;
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]}`;
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)) {
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;
}
@@ -694,6 +747,25 @@ function sourceVariantActive(variant: string, vp: number): boolean {
return true;
}
// Tailwind cascade specificity of an ACTIVE variant at a viewport: the effective min-width its media
// query opens at, so a more-specific (higher) breakpoint wins over a lower one. Tailwind emits its
// utilities sorted by breakpoint (min-width ascending), NOT by class-attribute order, so when both
// `md:` and `lg:` apply at 1280 the `lg:` rule wins regardless of which appears first in the class
// string. `min-[Npx]:` embeds N; a bare min breakpoint uses its threshold; `max-*` variants are
// upper-bounded windows that a plain min variant out-specifies, so they score below any min. The
// unprefixed base scores 0. Only meaningful for variants already known active at `vp`.
function sourceVariantSpecificity(variant: string, vp: number): number {
if (!variant) return 0;
let score = 0;
for (const part of variant.split(":").filter(Boolean)) {
const minArb = /^min-\[(\d+)px\]$/.exec(part);
if (minArb) { score = Math.max(score, +minArb[1]!); continue; }
if (part in SOURCE_BP) { score = Math.max(score, SOURCE_BP[part]!); continue; }
// max-* windows are less specific than any min breakpoint; leave score as-is (a min wins).
}
return score;
}
function sourceArbitraryInner(suffix: string): string | null {
return suffix.startsWith("[") && suffix.endsWith("]") ? suffix.slice(1, -1) : null;
}
@@ -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);
}
// 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 {
let m = /^aspect-(\d+)\/(\d+)-box$/.exec(core);
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);
if (maxH && sourceFluidLengthSuffix(maxH[1]!)) return { axis: "max-h", utility: 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;
}
@@ -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-auto") return !!s?.hAuto;
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;
});
}
@@ -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 {
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)) {
const parsed = sourceAxisForCore(tok.core);
if (!parsed) continue;
@@ -939,7 +1136,12 @@ function sourceIntentUtilities(node: IRNode, parent: IRNode | undefined, viewpor
if (!sourceVariantActive(tok.variant, vp)) continue;
let m = perAxis.get(parsed.axis);
if (!m) { m = new Map<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>();
@@ -947,15 +1149,47 @@ function sourceIntentUtilities(node: IRNode, parent: IRNode | undefined, viewpor
const css: string[] = [];
const bands = computeBands(viewports, canonical);
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;
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;
const base = byVp.get(canonical)!;
// 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);
if (axis === "aspect") axes.add("grid-rows");
utilities.push(base);
for (const b of bands) {
if (!b.media) continue;
const v = byVp.get(b.vp)!;
const v = asEmit(b.vp);
if (v !== base) utilities.push(prefixFor(b.media) + v);
}
css.push(...sourceIntentVarCss(node, axis, byVp, viewports, canonical));
@@ -988,11 +1222,12 @@ export type TailwindOutput = {
export type ColorInterner = {
defs: Map<string, string>; // minted token name → literal value
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)
seq: { n: number }; // monotonic counter for clr-N names
};
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). */
export function colorDefsCssOf(it: ColorInterner): string {
@@ -1053,11 +1288,11 @@ function interactionUtilities(
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
// than a token ref). The full tokenResolver is deliberately NOT passed — only colors are
// 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 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]
@@ -1075,12 +1310,31 @@ export function buildTailwind(ir: IR, assetMap: Map<string, string>, colorVar?:
indexNode(ir.root);
// Color interner: every distinct color value → a stable theme token referenced as
// var(--…). Palette colors already arrive as var(--color-*) (kept, semantic); any other
// color is minted a numbered token (--clr-N) so raw rgb/hex NEVER lands in markup.
// The token holds the literal value, minted in deterministic first-encounter order.
// var(--…). A color the semantic palette recognizes (exactly OR within the grader's ±2
// sRGB tolerance — the same tolerance css.ts already trusts for color/bg/border) reuses
// 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 semantic = colorVar?.(literal);
if (semantic) { const n = tokenName(semantic); if (n) colorTokens.add(n); return semantic; }
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);
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
// 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.
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>();
for (const [p, v] of [...baseRaw]) {
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
* so utilities override them. */
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;
}): string {
const screens = [
@@ -1250,8 +1508,7 @@ ${opts.tokensCss}
${opts.reset}
/* fonts */
${opts.fontCss}
html { background: ${opts.htmlBg}; }
body { font-family: ${opts.bodyFont}; }${opts.clip}
${opts.htmlBg !== null ? `html { background: ${opts.htmlBg}; }\n` : ""}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[] = [];
const cssBlocks: string[] = [];
const seen = new Set<string>();
for (const ff of fontFaces) {
const weight = (ff.weight || "400").trim();
const style = (ff.style || "normal").trim();
const display = (ff.display || "swap").trim();
const key = `${ff.family}|${weight}|${style}|${ff.unicodeRange ?? ""}`;
if (seen.has(key)) continue;
seen.add(key);
const srcs = parseSrcUrls(ff.src, sourceUrl);
// Resolve one face's src descriptor against the downloaded-asset graph. A face harvested from
// CSSOM carries the url of its owning sheet in `baseHref`; its relative src url()s must resolve
// 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
// `baseHref` is absent and the document url is a harmless fallback.
const order = ["woff2", "woff", "truetype", "opentype", "embedded-opentype"];
const resolveFace = (ff: FontFace): { resolved: Array<{ localPath: string; format: string }>; dataUris: string[] } => {
const srcs = parseSrcUrls(ff.src, ff.baseHref || sourceUrl);
const resolved: Array<{ localPath: string; format: string }> = [];
const dataUris: string[] = [];
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" });
}
}
// 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));
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) {
const srcParts: string[] = [];
+154 -87
View File
@@ -4,10 +4,19 @@ import { round } from "../util/canonical.js";
/**
* Deterministic section detection. Splits the page into visually coherent
* top-level blocks using semantic tags and geometry (we do not rely on class
* names those are intentionally dropped from the IR). Sections are metadata:
* stable ids + per-viewport bboxes for the layout/section gate. Role guesses are
* advisory and never affect fidelity.
* top-level bands using semantic tags and geometry (we do not rely on class
* names those are intentionally dropped from the IR).
*
* 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 = {
@@ -19,104 +28,162 @@ export type Section = {
};
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[] {
const cw = ir.doc.canonicalViewport;
const vpWidth = cw;
const candidates: Cand[] = [];
const walk = (node: IRNode, path: string): void => {
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) => ({
const pageH = ir.doc.perViewport[cw]?.scrollHeight ?? 0;
const nodes = detectSectionNodes(ir);
const roles = guessRoles(nodes, cw, pageH);
return nodes.map((node, i) => ({
id: `section-${String(i + 1).padStart(3, "0")}`,
nodeId: c.node.id,
role: guessRole(c.node, i, final.length),
nodeId: node.id,
role: roles[i]!,
order: i,
bboxByVp: bboxesFor(c.node),
bboxByVp: bboxesFor(node),
}));
}
function expandOversized(cands: Cand[], vpWidth: number, pageHeight: number, cw: number): Cand[] {
if (cands.length > 2) return cands;
const threshold = Math.max(pageHeight * 0.55, 1200);
/** Landmark evidence that a bar is site chrome: an ARIA landmark role on the node
* itself, or a <nav> within a shallow wrapper chain. Lets a thin fixed bar (often a
* 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[] = [];
for (const c of cands) {
if (c.height < threshold) { out.push(c); continue; }
const inner: Cand[] = [];
const collect = (node: IRNode, path: string, depth: number): void => {
if (depth > 6) return;
for (const ch of node.children) {
if (isTextChild(ch)) continue;
const bbox = ch.bboxByVp[cw];
if (bbox && ch.visibleByVp[cw] && bbox.width >= vpWidth * 0.5 && bbox.height >= 64) {
inner.push({ node: ch, path: `${path}/${ch.id}`, y: bbox.y, width: bbox.width, height: bbox.height });
} else {
collect(ch, `${path}/${ch.id}`, depth + 1);
}
}
};
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);
for (const child of container.children) {
if (isTextChild(child)) continue;
const cand = candOf(child, ctx);
if (!cand) {
out.push(...bandsOf(child, depth + 1, ctx));
continue;
}
if (keptInner.length >= 3) out.push(...keptInner);
else out.push(c);
if (ctx.pageH > 0 && cand.height > ctx.pageH * WRAPPER_FRAC) {
const inner = bandsOf(child, depth + 1, ctx);
if (acceptSplit(inner, cand)) {
out.push(...inner);
continue;
}
}
out.push(cand);
}
return out;
}
function guessRole(node: IRNode, index: number, total: number): string {
if (node.tag === "header") return "header";
if (node.tag === "nav") return "nav";
if (node.tag === "footer") return "footer";
if (node.tag === "main") return "main";
if (index === 0) return "hero";
if (index === total - 1) return "footer";
return "section";
/** May `inner` replace its containing candidate? Only when it reads as a stack of
* bands: at least two, not a fragmentation explosion, vertically stacked (side-by-side
* 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 === "aside") return "aside";
if (node.tag === "main") return "main";
const bbox = node.bboxByVp[cw];
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";
});
}
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)", ""]);
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 {
const m = v.match(/rgba?\(([^)]+)\)/i);
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;
return [Math.round(p[0]!), Math.round(p[1]!), Math.round(p[2]!), p.length >= 4 && !Number.isNaN(p[3]!) ? p[3]! : 1];
return parseColor(v);
}
/** A stable clustering key for a color literal: its rounded sRGB (+ alpha), so two literals
* 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 {
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);
if (fgCluster) assign("--foreground", fgCluster);
// 2) Primary/accent: most-used chromatic color, weighted toward interactive usage.
const remaining = clusters.filter((c) => !c.members.some((m) => valueToName.has(m)));
// Luminance (01) for deterministic tiebreaks (Rec.709). Ties in every ranking below
// 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
.filter((c) => { const { s } = satLight(c.canon.rgba); return s >= 0.25; })
.sort((a, b) => (b.canon.interactive * 3 + b.canon.count) - (a.canon.interactive * 3 + a.canon.count));
.filter((c) => satLight(c.canon.rgba).s >= 0.25 && chroma(c.canon.rgba) >= 24)
.sort(byCount((c) => c.interactive * 3 + c.count));
if (chromatic[0]) assign("--primary", chromatic[0]);
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
.filter((c) => !c.members.some((m) => valueToName.has(m)) && c.canon.border > 0)
.sort((a, b) => b.canon.border - a.canon.border)[0];
.filter((c) => unnamed(c) && c.canon.border > 0)
.sort(byCount((c) => c.border))[0];
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.
for (const c of remaining) {
if (c.members.some((m) => valueToName.has(m))) continue;
if (c.canon.count < minCount) continue;
const { s, l } = satLight(c.canon.rgba);
if (s < 0.15 && c.canon.bg >= c.canon.text && l > 0.85 && !taken.has("--surface")) assign("--surface", c);
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);
// 4) Neutrals, ranked so the most-used earns the cleanest name. Light neutrals used as
// backgrounds → --surface, --surface-2, … (section/card/footer alt backgrounds);
// mid/dark neutrals used as text → --muted-foreground then --muted. "Neutral" is the
// complement of "chromatic" above (no strong hue: low saturation OR tiny channel spread),
// so no colour falls through the gap between the two thresholds.
const neutrals = remaining
.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;
for (const c of remaining) {
if (c.members.some((m) => valueToName.has(m))) continue;
for (const c of [...remaining].sort(byCount(() => 0))) {
if (!unnamed(c)) continue;
if (c.canon.count < minCount) continue;
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
* 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.
*/
@@ -25,6 +28,9 @@ export type IRNode = {
// element had no class.
srcClass?: string;
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>;
bboxByVp: Record<number, BBox>;
computedByVp: Record<number, StyleMap>;
@@ -37,6 +43,14 @@ export type IRNode = {
// ::placeholder computed style (input/textarea with placeholder text) — emitted as a
// `::placeholder` rule so form controls keep their authored placeholder color.
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[];
};
@@ -72,6 +86,11 @@ export type IRDoc = {
// 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).
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 = {
@@ -83,6 +102,33 @@ export function isTextChild(c: IRChild): c is IRTextNode {
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.
* 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
// 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-)/;
/**
* 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 {
const idClass = `${raw.attrs?.id ?? ""} ${raw.attrs?.class ?? ""}`.toLowerCase();
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,
// so this cannot swallow site chrome under normal captures.
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[];
}
/** 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). */
function sigFull(n: RawNode): string {
return `${n.tag}#${n.attrs?.id ?? ""}.${(n.attrs?.class ?? "").trim()}`;
@@ -259,6 +435,39 @@ function alignChildren(canon: RawNode[], other: RawNode[]): (RawNode | undefined
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
* (they are display:none in the inactive/collapsed base state). Empty when no
* 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 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
// 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 => {
if (NOISE_TAGS.has(raw.tag)) 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 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.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 = {
id: nextId(),
@@ -335,6 +559,7 @@ export function buildIR(sourceDir: string, viewports: number[], opts?: { motion?
children: [],
};
if (raw.rawHTML) node.rawHTML = raw.rawHTML;
if (raw.svgPaint) node.svgPaint = raw.svgPaint;
const srcClass = (raw.attrs?.class ?? "").trim();
if (srcClass) node.srcClass = srcClass;
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);
// Align this node's canonical element children to each viewport's children.
const aligned: Record<number, (RawNode | undefined)[]> = {};
const kidsByVp: Record<number, RawNode[]> = {};
for (const vw of viewports) {
if (vw === canonical) continue;
const m = matched[vw];
const vwKids = m && m.tag === raw.tag ? elementChildren(m) : [];
kidsByVp[vw] = 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 pushedLeading = false;
const pushGrafts = (anchor: number): void => {
for (const gn of graftedByAnchor.get(anchor) ?? []) node.children.push(gn);
};
for (const c of raw.children) {
if ((c as IRTextNode).text !== undefined) {
node.children.push({ text: (c as IRTextNode).text });
continue;
}
if (!pushedLeading) { pushGrafts(-1); pushedLeading = true; }
const child = c as RawNode;
const childMatched: Record<number, RawNode | undefined> = {};
for (const vw of viewports) childMatched[vw] = vw === canonical ? child : aligned[vw]![ei];
ei++;
// A grafted subtree has no canonical counterpart: its own children must not be treated as
// 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);
if (converted) node.children.push(converted);
pushGrafts(ei);
ei++;
}
if (!pushedLeading) pushGrafts(-1);
}
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.
const keepAll = forced || preserveCaps.has(node.attrs["data-cid-cap"] ?? "");
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 hasText = false;
for (const c of node.children) {
@@ -397,16 +730,81 @@ export function buildIR(sourceDir: string, viewports: number[], opts?: { motion?
if (keepAll) {
prune(c, true);
keptChildren.push(c);
elemKept.push({ child: c, kept: true });
if (Object.values(c.visibleByVp).some(Boolean) || childHasVisible(c)) hasVisibleDescendant = true;
} else if (prune(c, false) || keepSource) {
keptChildren.push(c);
elemKept.push({ child: c, kept: true });
if (Object.values(c.visibleByVp).some(Boolean) || childHasVisible(c)) hasVisibleDescendant = true;
} else {
elemKept.push({ child: c, kept: false });
}
}
node.children = keptChildren;
reanchorTrackTransform(node, elemKept);
if (keepAll) return true;
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 => {
for (const c of n.children) {
@@ -427,6 +825,16 @@ export function buildIR(sourceDir: string, viewports: number[], opts?: { motion?
};
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"] = {};
for (const vw of viewports) {
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
// clone stays byte-identical to the pre-Stage-5 frozen output.
keyframes: opts?.motion ? [...new Set(canonSnap.keyframes ?? [])].sort() : [],
...(contentDrift.length ? { contentDrift } : {}),
};
// Repair transient root scroll-locks. A site that locks scrolling during an intro
+117 -118
View File
@@ -1,142 +1,141 @@
/**
* Code-quality audit count the "bad code" tells that make a generated clone read robotic, for any
* number of app source trees, side by side. Repeatable: point it at shipped deliverables and it
* prints one column per tree.
* Code-quality audit an honest, human-readable report over one or more generated app
* trees. Built directly on the dimension scorer in ./qualityScore, so the audit and the
* shipped `code-quality.md` quality number never disagree.
*
* npm run audit # auto: every output/<site>/app
* npm run audit -- <dir> [<dir> ...] # explicit trees; .clone resolves to sibling app/
* npm run audit -- output/example/app
* npm run audit # auto: every runs/<site>/latest/generated/app
* npm run audit -- <appDir> [<appDir> ...] # explicit app trees
* 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
* COUNT where lower is better, except the two "good" context rows (fluid width / standard scale).
* The decisive "decimal" tell is non-integer-PX: each arbitrary px/rem is converted to px and flagged
* if it isn't a whole pixel (a frozen measurement), so a clean 12.5rem (=200px) does NOT count.
* For each tree it prints:
* the LETTER GRADE + numeric score (and any hard-cap reason),
* a per-DIMENSION table (payload / decomposition / duplication / semantics / hygiene
* / 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 { join, resolve, basename, dirname, sep } from "node:path";
import { readdirSync, statSync, existsSync } from "node:fs";
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 } {
const tsxParts: string[] = [], cssParts: string[] = [];
const walk = (d: string): void => {
for (const n of readdirSync(d)) {
if (n === "node_modules" || n === ".next" || n === "out" || n.startsWith(".")) continue;
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"));
/** Is `dir` a scannable app tree (has a src/ with code in it, or is itself full of code)? */
function isAppDir(dir: string): boolean {
if (!existsSync(dir)) return false;
if (existsSync(join(dir, "src"))) return true;
try { return readdirSync(dir).some((n) => /\.(tsx|jsx|ts|css|astro)$/.test(n)); } catch { return false; }
}
/** Discover every runs/<site>/latest/generated/app deliverable, newest layout first.
* Checks both the cwd and its parent, so `npm run audit` works whether invoked from the
* repo root or from compiler/ (where runs/ lives one level up). */
function discoverTargets(): string[] {
const out: string[] = [];
const roots = ["runs", "output", "../runs", "../output"].map((r) => resolve(r)).filter(existsSync);
for (const root of roots) {
let sites: string[];
try { sites = readdirSync(root); } catch { continue; }
for (const site of sites) {
for (const app of [
join(root, site, "latest", "generated", "app"),
join(root, site, "app"),
]) {
if (isAppDir(app)) { out.push(app); break; }
}
}
};
walk(dir);
const tsx = tsxParts.join("\n"), css = cssParts.join("\n");
return { tsx, css, all: tsx + "\n" + css };
}
return out;
}
/** All metric counts for one tree. */
function audit(target: Target): Record<string, number> {
const dir = target.scanDir;
const { tsx, css, all } = readTree(dir);
const n = (re: RegExp, s = all): number => (s.match(re) || []).length;
const validationDataCid = target.validationDir && existsSync(target.validationDir)
? (readTree(target.validationDir).all.match(/data-cid/g) || []).length
: 0;
// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------
// Arbitrary px/rem values + the non-integer-px ("decimal") subset, the decisive measurement tell.
let arb = 0, decimal = 0;
for (const m of all.matchAll(/\[(-?[0-9]+\.?[0-9]*)(px|rem)\]/g)) {
arb++;
const px = parseFloat(m[1]!) * (m[2] === "rem" ? 16 : 1);
if (Math.abs(px - Math.round(px)) > 0.02) decimal++;
}
return {
"files (tsx+css)": (tsx ? 1 : 0), // overwritten below with real file counts
"── 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),
};
/** 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 fileCount(dir: string): number {
let c = 0;
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;
const pad = (s: string, w: number): string => s + " ".repeat(Math.max(0, w - s.length));
const padL = (s: string, w: number): string => " ".repeat(Math.max(0, w - s.length)) + s;
function renderReport(label: string, rep: QualityReport): string {
const L: string[] = [];
L.push("");
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}`);
}
L.push("");
L.push(` ${pad("dimension", 15)}${padL("score", 9)} sub-metrics`);
L.push(` ${"─".repeat(66)}`);
for (const [dim, cat] of Object.entries(rep.categories)) {
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 (rep.offenders.length) {
L.push("");
L.push(` top offenders`);
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 L.join("\n");
}
function normalizeTarget(input: string): Target {
const d = resolve(input);
const asApp = (appDir: string, validationDir?: string): Target => ({ inputDir: d, scanDir: appDir, validationDir });
if (basename(d) === ".clone") {
const app = join(dirname(d), "app");
const validation = join(d, "generated", "app");
if (existsSync(join(app, "src"))) return asApp(app, existsSync(validation) ? validation : undefined);
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(""));
}
const generatedSuffix = `${sep}.clone${sep}generated${sep}app`;
if (d.endsWith(generatedSuffix)) {
const cloneDir = d.slice(0, -`${sep}generated${sep}app`.length);
const app = join(dirname(cloneDir), "app");
if (existsSync(join(app, "src"))) return asApp(app, d);
}
if (basename(d) === "generated" && existsSync(join(d, "app", "src"))) {
const cloneDir = dirname(d);
const app = join(dirname(cloneDir), "app");
if (basename(cloneDir) === ".clone" && existsSync(join(app, "src"))) return asApp(app, join(d, "app"));
}
return { inputDir: d, scanDir: d };
L.push(" " + pad("GRADE", 15) + reps.map((r) => padL(`${r.grade} (${r.total})`, colW)).join(""));
return L.join("\n");
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
function main(): void {
const args = process.argv.slice(2).filter((a) => !a.startsWith("--"));
let targets: Target[];
if (args.length) targets = args.map(normalizeTarget);
else {
// 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); }
const asJson = process.argv.includes("--json");
const targets = (args.length ? args.map((a) => resolve(a)) : discoverTargets()).filter(isAppDir);
if (!targets.length) { console.error("no app trees found — pass app dirs explicitly (e.g. runs/<site>/latest/generated/app)"); process.exit(1); }
// A readable column label: the deliverable dir's parent name, or the scanned dir name.
const label = (t: Target): string => (basename(dirname(t.scanDir)) || basename(t.scanDir)).slice(0, 16);
const labels = targets.map(label);
const audits = targets.map(audit);
audits.forEach((a, i) => { a["files (tsx+css)"] = fileCount(targets[i]!.scanDir); });
const labels = targets.map(labelFor);
const reports = targets.map((t) => scoreApp(t));
const keys = Object.keys(audits[0]!);
const w0 = Math.max(...keys.map((k) => k.length));
const colW = Math.max(11, ...labels.map((l) => l.length));
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);
if (asJson) {
console.log(JSON.stringify(reports.map((r, i) => ({ label: labels[i], ...r })), null, 2));
return;
}
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
* behaves* like the source. They say nothing about whether the generated CODE is
* good componentized, semantically named, styled through a reusable token/class
* system, editable, and well organized. That "developer-facing quality" is exactly
* where a deterministic converter is judged once fidelity is a given.
* good small files, decomposed into real components, semantic and accessible,
* free of capture artifacts, and safe at runtime. That "developer-facing quality"
* 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
* six categories. It reads only source text (`.tsx/.jsx/.ts/.astro/.css`). The
* metrics are chosen to be framework-agnostic: e.g. "style reuse" rewards shared
* classes whether they're Tailwind utilities or semantic clone classes, and
* penalizes per-node unique rules (`.c1{...} .c2{...}`) regardless of framework.
* This module statically analyzes a generated app directory (source text only
* `.tsx/.jsx/.ts/.astro/.css`) and scores it 0100 across SIX dimensions, each
* with visible subscores. The score is a weighted blend of the dimensions EXCEPT
* that a single dimension in catastrophic territory (a multi-megabyte source file,
* 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.
*/
import { readFileSync, readdirSync, statSync } from "node:fs";
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
// ---------------------------------------------------------------------------
@@ -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 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
* pages/ dir, home.tsx, or an .astro route shell. Used for the dominance metric. */
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"];
function isEntryFile(rel: string): boolean {
const b = basename(rel).toLowerCase();
if (/^(page|index|home|app)\.[jt]sx$/.test(b)) return true;
@@ -66,54 +166,8 @@ function isEntryFile(rel: string): boolean {
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*$/;
/** Exported / defined component names in a file. */
function componentNames(text: string): string[] {
export function componentNames(text: string): string[] {
const names = new Set<string>();
let m: RegExpExecArray | null;
const re1 = /export\s+default\s+function\s+([A-Z]\w*)/g;
@@ -125,221 +179,428 @@ function componentNames(text: string): string[] {
return [...names];
}
/** Data/content field names declared in a content or data module. */
function contentFieldNames(text: string): string[] {
const out: string[] = [];
// type Foo = { a: string; b?: number } — capture keys
const typeBlocks = text.match(/(?:type|interface)\s+\w+\s*=?\s*\{([^}]*)\}/g) ?? [];
for (const blk of typeBlocks) {
const re = /(\w+)\s*\??\s*:/g; let m: RegExpExecArray | null;
while ((m = re.exec(blk)) !== null) out.push(m[1]!);
// ===========================================================================
// METRIC EXTRACTORS (exported for unit tests — each is a pure text→number/obj)
// ===========================================================================
/** Bytes of embedded base64 blobs + HTML passed as string props/dangerous html. */
export function inlineBlobBytes(text: string): number {
let bytes = 0;
// 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 Offender = { file: string; metric: string; value: number };
export type QualityReport = {
dir: string;
total: number;
grade: LetterGrade;
caps: string[];
categories: Record<string, CategoryScore>;
offenders: Offender[];
raw: Record<string, number>;
};
const clamp01 = (x: number): number => Math.max(0, Math.min(1, x));
/** Linear ramp: value v mapped from [lo,hi] → [0,1]. */
const ramp = (v: number, lo: number, hi: number): number => clamp01((v - lo) / (hi - lo));
const r1 = (n: number): number => Math.round(n * 10) / 10;
/** Dimension weights (sum = 100). Guiding, not exact see CALIBRATION note.
* Payload + decomposition carry the most weight because an unopenable, monolithic
* file is the defect a reviewer notices first; hygiene/duplication are lighter
* 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 {
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 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 = tsx.filter((f) =>
const componentModules = markup.filter((f) =>
!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 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 entryFiles = componentModules.filter((f) => isEntryFile(f.rel));
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 +
// component names into words (splitting camelCase, kebab, snake, slashes) so both
// `hero-section.tsx` and `HeroSection` are credited.
const isSectionFile = (f: SrcFile): boolean => {
const raw = f.rel + " " + componentNames(f.text).join(" ");
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));
const offenders: Offender[] = [];
const caps: string[] = [];
// =========================================================================
// 1. PAYLOAD DISCIPLINE — no file/line/blob a human cannot open.
// =========================================================================
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;
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;
let total = 0;
for (const [k, sub] of Object.entries(dims)) total += WEIGHTS[k as keyof typeof WEIGHTS] * sub;
// ---- naming ----
const allComponentNames = new Set<string>();
for (const f of componentModules) for (const n of componentNames(f.text)) allComponentNames.add(n);
// exclude the page/Page entry symbol from the semantic judgement
const judgedNames = [...allComponentNames].filter((n) => n !== "Page" && n !== "default" && n !== "RootLayout" && n !== "Layout");
const genericNames = judgedNames.filter((n) => GENERIC_COMP_RE.test(n)).length;
const compNameSemanticRatio = judgedNames.length ? 1 - genericNames / judgedNames.length : 0;
const allClassToks: string[] = [];
for (const f of tsx) allClassToks.push(...classTokens(f.text));
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;
// Catastrophe caps: any ONE unopenable payload drags the whole grade into D-range.
if (worstFileBytes >= K.CATASTROPHE_FILE_BYTES) caps.push(`file ${worstFileRel} is ${(worstFileBytes / 1e6).toFixed(1)}MB (>1MB)`);
if (worstLine >= K.CATASTROPHE_LINE_CHARS) caps.push(`line in ${worstLineRel} is ${(worstLine / 1000).toFixed(0)}KB (>100KB)`);
if (totalInlineBytes >= K.CATASTROPHE_INLINE_BYTES) caps.push(`${(totalInlineBytes / 1e6).toFixed(1)}MB of embedded base64/HTML (>5MB)`);
if (caps.length) total = Math.min(total, K.CAP_GRADE_D);
else {
// 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)`);
if (worstLine >= K.WARN_LINE_CHARS) caps.push(`line in ${worstLineRel} is ${(worstLine / 1000).toFixed(0)}KB (>${K.WARN_LINE_CHARS / 1000}KB)`);
if (caps.length) total = Math.min(total, K.CAP_GRADE_C);
}
// Tailwind-style theme tokens count as token usage too (utility classes referencing roles)
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
total = r1(Math.max(0, Math.min(100, total)));
// magic literals in component markup (raw hex/rgb/px in className/style strings)
let magicLiterals = 0;
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;
// Order offenders by severity so the report's "top offenders" is meaningful.
offenders.sort((a, b) => severity(b) - severity(a));
return {
dir: root,
total: r1(total),
total,
grade: toLetter(total),
caps,
categories: {
componentization: { score: r1(componentization), max: 25, metrics: { components: nonEntry.length, dominancePct: r1(dominance * 100), sectionComponents } },
naming: { score: r1(naming), max: 25, metrics: { compNameSemanticPct: r1(compNameSemanticRatio * 100), classSemanticPct: r1(classSemanticRatio * 100), fieldSemanticPct: r1(fieldSemanticRatio * 100), genericNames } },
styling: { score: r1(styling), max: 20, metrics: { classReusePct: r1(classReuse * 100), tokenRefs, perNodeRules, magicLiterals, arbPx, arbPxPerNode: r1(arbPxPerNode), arbBands, stdBreakpoints, bandSharePct: r1(bandShare * 100) } },
editability: { score: r1(editability), max: 15, metrics: { hasContentModule, fieldSemanticPct: r1(fieldSemanticRatio * 100), propsWithDefaults } },
fileOrg: { score: r1(fileOrg), max: 10, metrics: { orgDirs, svgFiles, hasSectionsDir, hasComponentsDir } },
metadata: { score: r1(metadata), max: 5, metrics: { docHeaders, annotations, roboticComments, dataCidPerNode: r1(dataCidPerNode) } },
payload: { score: r1(WEIGHTS.payload * payloadSub), max: WEIGHTS.payload, metrics: { maxFileKB: r1(worstFileBytes / 1000), maxLineKB: r1(worstLine / 1000), inlineBlobKB: r1(totalInlineBytes / 1000) } },
decomposition: { score: r1(WEIGHTS.decomposition * decompositionSub), max: WEIGHTS.decomposition, metrics: { components: nonEntry.length, dominancePct: r1(dominance * 100), bigSections } },
duplication: { score: r1(WEIGHTS.duplication * duplicationSub), max: WEIGHTS.duplication, metrics: { helperDups, helperDupPct: r1(helperDupRatio * 100), svgPathRepeats: svgDup.repeats, nearDupPairs: nearDup } },
semantics: { score: r1(WEIGHTS.semantics * semanticsSub), max: WEIGHTS.semantics, metrics: { h1: h1Count, divRatioPct: r1(divRatio * 100), semanticTags, altCoveragePct: r1(altCoverage * 100), ariaHiddenFocusables: ariaHidden } },
hygiene: { score: r1(WEIGHTS.hygiene * hygieneSub), max: WEIGHTS.hygiene, metrics: { wsLiterals, subpixelArbitraries: subpixel, opaqueTokenPct: r1(opaqueRatio * 100), probeArtifacts: probes } },
runtime: { score: r1(WEIGHTS.runtime * runtimeSub), max: WEIGHTS.runtime, metrics: { uncleanedListeners: leaks, undeclaredImports } },
},
offenders: offenders.slice(0, 15),
raw: {
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 {
const lines: string[] = [];
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)) {
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");
}
@@ -353,11 +614,11 @@ async function main(): Promise<void> {
for (const rep of reports) console.log(fmt(rep));
if (reports.length > 1) {
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)) {
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 { buildRuntimeSpecs, wiresJsx, dittoWireImportPath, DITTO_WIRE_TSX, interactionRejectedSet } from "../generate/interactive.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 type { InteractionCapture } from "../capture/interactions.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 { toRoutePath, segmentsOf } from "../crawl/url.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";
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
* 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 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);
return { htmlBg, clip: noHScroll ? "\nhtml, body { overflow-x: clip; }" : "" };
}
@@ -112,8 +112,7 @@ ${paletteCss}
${tokensToCss(entry.tokens, true)}
/* page base */
html { background: ${htmlBg}; }
body { font-family: ${SYSTEM_FALLBACK}; }${clip}
${htmlBgRule(htmlBg)}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 jsonLd = seo ? jsonLdHeadMarkup(seo, 8) : "";
const head = jsonLd ? ` <head>\n${jsonLd}\n </head>\n` : "";
const siteImport = seo ? SITE_ORIGIN_LAYOUT_IMPORT + "\n" : "";
return `import "./globals.css";
${chromeImport}import type { ReactNode } from "react";
${siteImport}
${metadata}${viewport}
${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
// added when any route actually replays a Lottie animation.
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");
if (isVite) {
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`
// (page imports them); per-route data stays inline. Route-local dirs keep names from
// 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 dataDecls = componentDataDecls(routeReg);
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}
</>
);
+79 -5
View File
@@ -1,5 +1,5 @@
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 { GenNode } from "./render.js";
import { indexByCid } from "./render.js";
@@ -86,6 +86,32 @@ function hasVisibleElementChild(node: IRNode, vp: number): boolean {
return false;
}
/** A source text node that the capture painted VISIBLE but the clone rendered HIDDEN at the same
* viewport is a high-signal regression: the words are in the markup yet fall in an invisible gen
* subtree (off-viewport-shifted, banded-hidden, or width-frozen off-screen). Cheap to detect
* the gen node exists (matched by cid) but reports `visible === false`. Counts DISTINCT source
* cids over the run (a node hidden at several viewports counts once) so the number reads as
* "how many text nodes went dark", not "hidden-node×viewport". Non-blocking: reported as a metric
* only. Pure + input-driven deterministic. */
export function countVisibleInCaptureHiddenInClone(
ir: IR,
genSnaps: Record<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? ----------
// 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
@@ -119,11 +145,30 @@ export function gatePollution(ir: IR, capture: CaptureResult, viewports: number[
let blocking = capture.dismissal?.blocking ?? false;
for (const pv of capture.perViewport) { overlaysRemaining = Math.max(overlaysRemaining, pv.overlaysRemaining ?? 0); blocking = blocking || !!pv.blocking; }
let minHeightRatio = Infinity;
let maxHeightRatio = 0;
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;
// 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
// ~3 nodes / ~24 chars; the most minimal legitimate page in the suite
// (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 (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 (scrollLockedContradiction) issues.push(`scroll-locked capture: scrollHeight pinned to ~1 viewport at every width while IR content spans ${round2(maxContentRatio)} viewports`);
return {
gate: "pollution",
@@ -138,6 +184,7 @@ export function gatePollution(ir: IR, capture: CaptureResult, viewports: number[
metrics: {
nodeCount, visibleTextChars: textChars, wallTextDetected: wall,
overlaysRemaining, blocking, minScrollHeightRatio: round2(minHeightRatio),
maxScrollHeightRatio: round2(maxHeightRatio), maxContentExtentRatio: round2(maxContentRatio),
dismissedCount: capture.dismissal?.dismissed.length ?? 0,
overlaysRemoved: capture.dismissal?.removed ?? 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 (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.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));
if (!fontsResolvedOrFallback) issues.push("font declarations not resolved/fallback-recorded");
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 textPct = textTotal ? textPresent / textTotal : 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),
linkPct: round4(linkPct), mediaPct: round4(mediaPct),
totalVisible, matched, textTotal, textPresent, linksTotal, mediaTotal, inventedText,
textHiddenInClone,
},
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
}
function normHref(href: string, origin: string): string {
export function normHref(href: string, origin: string): string {
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;
try {
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
// 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 (!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) {
// `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));
}
/** 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 ----------
export function gate5Layout(ir: IR, genSnaps: Record<number, PageSnapshot>, sections: Section[], viewports: number[], reflow = false): GateResult {
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 { buildMenuSpecs } from "../generate/menu.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
@@ -61,7 +62,10 @@ export async function driveInteractionGate(opts: {
const ctx = await browser.newContext({ viewport: { width: vp, height: vh }, deviceScaleFactor: 1 });
const page = await ctx.newPage();
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);
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 { chromium } from "playwright";
import { collectPage, type PageSnapshot } from "../capture/walker.js";
import { captureFullPageViaCDP, normalizeVideoTime } from "../capture/capture.js";
import { ensureDir, writeJSONCompact } from "../util/fsx.js";
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> }> {
const server: Server = createServer(async (req, res) => {
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; }
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);
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);
} catch {
res.writeHead(500); res.end("error");
@@ -191,8 +260,151 @@ export type RenderResult = {
runtimeErrors: string[];
httpStatus: number;
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[]> {
const out: R[] = new Array(items.length);
let next = 0;
@@ -218,6 +430,7 @@ export async function renderApp(opts: {
const vh = viewportHeight(vw);
const ctx = await browser.newContext({ ignoreHTTPSErrors: true, viewport: { width: vw, height: vh }, deviceScaleFactor: 1 });
const runtimeErrors: string[] = [];
const fontWarnings: string[] = [];
const failedResources = new Set<string>();
let httpStatus = 0;
try {
@@ -226,9 +439,26 @@ export async function renderApp(opts: {
page.on("pageerror", (e) => runtimeErrors.push(String(e)));
page.on("response", (r) => { if (r.status() >= 400) failedResources.add(`${r.status()} ${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();
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.
await page.evaluate(async () => {
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);
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 {
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 */ }
return { viewport: vw, snapshot, runtimeErrors, failedResources: [...failedResources], httpStatus };
return { viewport: vw, snapshot, runtimeErrors, fontWarnings, failedResources: [...failedResources], httpStatus };
} finally {
await ctx.close();
}
});
const runtimeErrors = results.flatMap((r) => r.runtimeErrors);
const fontWarnings = results.flatMap((r) => r.fontWarnings);
const failedResources = new Set(results.flatMap((r) => r.failedResources));
for (const r of results) snapshots[r.viewport] = r.snapshot;
const non200 = results.find((r) => r.httpStatus && r.httpStatus !== 200);
const httpStatus = non200?.httpStatus ?? results.find((r) => r.httpStatus)?.httpStatus ?? 0;
ensureDir(join(opts.renderedDir, "computed"));
return { snapshots, runtimeErrors, httpStatus, failedResources: [...failedResources] };
return { snapshots, runtimeErrors, httpStatus, failedResources: [...failedResources], fontWarnings };
} finally {
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 page = await ctx.newPage();
await page.addInitScript(ESBUILD_SHIM);
await page.goto(opts.url, { waitUntil: "networkidle", timeout: 45000 });
try { await page.evaluate(() => (document as Document).fonts?.ready); } catch { /* ignore */ }
await gotoAndSettle(page, opts.url);
// 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(() => {
const win = window as unknown as { __dittoMotionStopped?: boolean; __dittoMotionStop?: () => void };
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;
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 { readJSON, writeJSON, writeText, ensureDir, fileExists } from "../util/fsx.js";
import type { CaptureResult } from "../capture/capture.js";
@@ -108,6 +123,9 @@ export async function validateRun(runDir: string, opts?: { harnessDir?: string;
try {
const r = await renderApp({ url: server.url + "/", viewports, renderedDir });
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) });
if (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 });
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 {
await server.close();
}
}
// Video/audio elements stream and their requests are aborted ("failed") when the
// render snapshot is taken before the stream finishes — the file is materialized,
// so this is not an asset failure. Only count non-streaming assets.
const assetFailed = failedResources.filter(
(f) => f.includes("/assets/") && !/\.(mp4|webm|mov|m4v|ogv|ogg|mp3|wav|m3u8)(\?|$)/i.test(f),
);
// A generated asset ref is only a real failure if the file it points to is genuinely
// missing from the served export. Two aborts are NOT failures even though Chromium logs
// them as "failed": (a) video/audio streams aborted when the render snapshot is taken
// before the stream finishes; (b) responsive-image <source srcset> candidates whose
// 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 gate0Issues: string[] = [];
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("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", () => {
@@ -146,6 +163,54 @@ describe("graftFrameIntoSnapshot", () => {
graftFrameIntoSnapshot(host, { idx: 0, contentX: 40, contentY: 30 }, frame);
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 ----
+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 { join } from "node:path";
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];
@@ -98,3 +98,217 @@ describe("IR prune keeps <source> media candidates", () => {
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"));
});
});
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 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)
// 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]");
});
});
// 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");
});
});
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");
});
});