Salvaged and re-implemented from PR #15: - Deterministic environment shim (seeded Math.random, pinned-but-advancing Date consistent across constructor/now/getTime/valueOf/toISOString) so time/random-dependent pages capture reproducibly; epoch and seed are recorded run metadata, performance.now stays real for motion sampling - Post-snapshot lazy-asset discovery sweep for channels the walker misses: noscript contents (DOMParser), inline style url(), data-background(-image), img[loading=lazy] currentSrc - Navigation gets a total 90s budget, an early bot-wall probe that aborts with a pollution-style error instead of capturing garbage (WALL_RE now shared with the validator gate), and one fresh-context retry scoped to the initial nav on retryable failure classes only 505 tests pass (29 new), typecheck clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
718 lines
37 KiB
TypeScript
718 lines
37 KiB
TypeScript
import type { IR, IRNode } 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";
|
||
import type { AssetGraph } from "../infer/assets.js";
|
||
import type { FontGraph } from "../infer/fonts.js";
|
||
import type { Section } from "../infer/sections.js";
|
||
import type { CaptureResult } from "../capture/capture.js";
|
||
import { WALL_RE } from "../util/captureFailure.js";
|
||
|
||
export type GateResult = {
|
||
gate: string;
|
||
pass: boolean;
|
||
metrics: Record<string, unknown>;
|
||
issues: string[];
|
||
};
|
||
|
||
// ---------- helpers ----------
|
||
function normText(s: string): string { return s.replace(/\s+/g, " ").trim(); }
|
||
function pxNum(v: string | undefined): number {
|
||
if (!v) return NaN;
|
||
// include an optional exponent so saturating values like `rounded-full`'s 3.35544e+07px parse
|
||
// as the full magnitude (not just the mantissa 3.35) — pill-radius equivalence depends on it.
|
||
const m = /(-?\d+(?:\.\d+)?(?:e[+-]?\d+)?)/i.exec(v);
|
||
return m ? parseFloat(m[1]!) : NaN;
|
||
}
|
||
function withinAbs(a: number, b: number, tol: number): boolean { return Math.abs(a - b) <= tol; }
|
||
function withinPct(a: number, b: number, pct: number): boolean {
|
||
const m = Math.max(Math.abs(a), Math.abs(b));
|
||
return m === 0 ? true : Math.abs(a - b) / m <= pct;
|
||
}
|
||
function normColor(v: string | undefined): string { return (v ?? "").replace(/\s+/g, ""); }
|
||
function parseRgb(v: string | undefined): [number, number, number, number] | 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 [p[0]!, p[1]!, p[2]!, p.length >= 4 && !Number.isNaN(p[3]!) ? p[3]! : 1];
|
||
}
|
||
/** Colors are equivalent within a small per-channel tolerance: re-emitting a
|
||
* computed color (often defined in oklch/wide-gamut on modern SaaS sites) can
|
||
* round ±1 per channel. Imperceptible, but a real color difference still fails. */
|
||
function colorsClose(a: string | undefined, b: string | undefined): boolean {
|
||
const ca = parseRgb(a), cb = parseRgb(b);
|
||
if (!ca || !cb) return normColor(a) === normColor(b);
|
||
return Math.abs(ca[0] - cb[0]) <= 2 && Math.abs(ca[1] - cb[1]) <= 2 &&
|
||
Math.abs(ca[2] - cb[2]) <= 2 && Math.abs(ca[3] - cb[3]) <= 0.04;
|
||
}
|
||
function normFontFamily(v: string | undefined): string {
|
||
// Compare effective font stacks: drop quotes/case/whitespace, then dedup tokens
|
||
// preserving order. Sites that build font-family from CSS variables often emit
|
||
// the fallback list twice (e.g. `mono, …, monospace, mono, …, monospace`); the
|
||
// clone resolves it once. Both name the same fonts in the same priority order,
|
||
// so they are equivalent — a real primary-font difference is still caught.
|
||
const toks = (v ?? "").replace(/['"]/g, "").replace(/\s+/g, " ").toLowerCase()
|
||
.split(",").map((s) => s.trim()).filter(Boolean);
|
||
const seen = new Set<string>();
|
||
const out: string[] = [];
|
||
for (const t of toks) if (!seen.has(t)) { seen.add(t); out.push(t); }
|
||
return out.join(",");
|
||
}
|
||
|
||
type SrcNode = { node: IRNode; computed: Record<string, string>; bbox: { x: number; y: number; width: number; height: number }; visible: boolean; directText: string };
|
||
|
||
function collectSrcNodes(ir: IR, vp: number): SrcNode[] {
|
||
const out: SrcNode[] = [];
|
||
const walk = (node: IRNode): void => {
|
||
const computed = node.computedByVp[vp];
|
||
const bbox = node.bboxByVp[vp];
|
||
if (computed && bbox) {
|
||
let directText = "";
|
||
for (const c of node.children) if (isTextChild(c)) directText += c.text;
|
||
out.push({ node, computed, bbox, visible: !!node.visibleByVp[vp], directText });
|
||
}
|
||
for (const c of node.children) if (!isTextChild(c)) walk(c);
|
||
};
|
||
walk(ir.root);
|
||
return out;
|
||
}
|
||
|
||
function hasVisibleElementChild(node: IRNode, vp: number): boolean {
|
||
for (const c of node.children) {
|
||
if (isTextChild(c)) continue;
|
||
if (c.visibleByVp[vp]) return true;
|
||
}
|
||
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
|
||
// never dismissed (the clone then reproduces the modal, so the perceptual gate is
|
||
// fooled too). This gate flags those captures so a "perfect" score can't hide them.
|
||
// WALL_RE lives in util/captureFailure.ts — shared with the capture-side fast-fail so
|
||
// the abort and the grade can never drift on what counts as a wall.
|
||
|
||
export function gatePollution(ir: IR, capture: CaptureResult, viewports: number[]): GateResult {
|
||
const issues: string[] = [];
|
||
const nodeCount = ir.doc.nodeCount;
|
||
|
||
// Collect all visible source text once.
|
||
let textChars = 0;
|
||
const textParts: string[] = [];
|
||
const walk = (node: IRNode): void => {
|
||
const anyVisible = Object.values(node.visibleByVp).some(Boolean);
|
||
for (const c of node.children) {
|
||
if (isTextChild(c)) { if (anyVisible) { const t = normText(c.text); if (t) { textParts.push(t); textChars += t.length; } } }
|
||
else walk(c);
|
||
}
|
||
};
|
||
walk(ir.root);
|
||
const allText = textParts.join(" ");
|
||
const wall = WALL_RE.test(allText);
|
||
|
||
// Overlay remaining after dismissal (max across viewports), and shortest page.
|
||
// `blocking` = a full-viewport overlay that STILL scroll-locks the page (a modal
|
||
// we could not clear); mere overlay presence (a legit fixed hero/app banner) is
|
||
// tracked but not failed, to avoid false positives on real fixed content.
|
||
let overlaysRemaining = 0;
|
||
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) {
|
||
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.
|
||
if (nodeCount < 12) issues.push(`degenerate DOM: only ${nodeCount} nodes`);
|
||
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",
|
||
pass: issues.length === 0,
|
||
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,
|
||
},
|
||
issues,
|
||
};
|
||
}
|
||
|
||
// ---------- Gate 1: capture completeness ----------
|
||
export function gate1Capture(ir: IR, viewports: number[], artifacts: { screenshots: Record<number, boolean>; assetsPresent: boolean; fontsPresent: boolean }): GateResult {
|
||
const issues: string[] = [];
|
||
let rootOk = ir.root && ir.root.tag === "body";
|
||
for (const vp of viewports) {
|
||
if (!ir.doc.perViewport[vp]) issues.push(`missing perViewport ${vp}`);
|
||
if (!artifacts.screenshots[vp]) issues.push(`missing screenshot ${vp}`);
|
||
if (!((ir.doc.perViewport[vp]?.scrollHeight ?? 0) > 0)) issues.push(`no scrollHeight ${vp}`);
|
||
}
|
||
if (!rootOk) issues.push("no visible DOM root");
|
||
if (!artifacts.assetsPresent) issues.push("assets-discovered missing");
|
||
if (!artifacts.fontsPresent) issues.push("fonts-discovered missing");
|
||
return { gate: "capture", pass: issues.length === 0, metrics: { viewports }, issues };
|
||
}
|
||
|
||
// ---------- Gate 2: asset/font equivalence ----------
|
||
export function gate2Assets(assetGraph: AssetGraph, fontGraph: FontGraph, gen: { remoteRefs: string[]; failed404: string[] }): GateResult {
|
||
const issues: string[] = [];
|
||
let unclassified = 0, zeroByte = 0, skippedNoReason = 0;
|
||
for (const e of assetGraph.entries) {
|
||
if (e.type === "css") continue;
|
||
if (e.classification !== "downloaded" && e.classification !== "skipped") unclassified++;
|
||
if (e.classification === "downloaded" && e.bytes <= 0) zeroByte++;
|
||
if (e.classification === "skipped" && !e.reason) skippedNoReason++;
|
||
}
|
||
if (unclassified > 0) issues.push(`${unclassified} unclassified assets`);
|
||
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 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 {
|
||
gate: "asset_font",
|
||
pass: issues.length === 0,
|
||
metrics: {
|
||
total: assetGraph.entries.filter((e) => e.type !== "css").length,
|
||
downloaded: assetGraph.entries.filter((e) => e.classification === "downloaded" && e.type !== "css").length,
|
||
skipped: assetGraph.entries.filter((e) => e.classification === "skipped" && e.type !== "css").length,
|
||
remoteRefs: gen.remoteRefs.length, failed404: gen.failed404.length,
|
||
failed404Sample: [...new Set(gen.failed404)].slice(0, 6),
|
||
fonts: fontGraph.entries.length,
|
||
},
|
||
issues,
|
||
};
|
||
}
|
||
|
||
// ---------- Gate 3: rendered DOM equivalence ----------
|
||
export function gate3Dom(ir: IR, genSnaps: Record<number, PageSnapshot>, viewports: number[], sourceOrigin: string): GateResult {
|
||
const issues: string[] = [];
|
||
let totalVisible = 0, matched = 0;
|
||
let textTotal = 0, textPresent = 0;
|
||
let linksTotal = 0, linksOk = 0;
|
||
let mediaTotal = 0, mediaOk = 0;
|
||
let inventedText = 0;
|
||
|
||
// All source text (visible or not) — the deterministic compiler only ever
|
||
// replays captured text, so this is the authoritative "not invented" set.
|
||
const allSrcText = new Set<string>();
|
||
for (const vp of viewports) {
|
||
for (const s of collectSrcNodes(ir, vp)) {
|
||
const t = normText(s.directText);
|
||
if (t.length > 0) allSrcText.add(t);
|
||
}
|
||
}
|
||
|
||
for (const vp of viewports) {
|
||
const gen = genSnaps[vp] ? indexByCid(genSnaps[vp]!) : new Map<string, GenNode>();
|
||
const srcNodes = collectSrcNodes(ir, vp);
|
||
const genTextAll = normText([...gen.values()].filter((g) => g.visible).map((g) => g.text).join(" "));
|
||
const srcTextSet = new Set<string>();
|
||
|
||
for (const s of srcNodes) {
|
||
if (!s.visible) continue;
|
||
totalVisible++;
|
||
const g = gen.get(s.node.id);
|
||
if (g && (g.tag === s.node.tag || isValidRetag(s.node.tag, g.tag))) matched++;
|
||
|
||
const t = normText(s.directText);
|
||
if (t.length > 0) {
|
||
textTotal++;
|
||
srcTextSet.add(t);
|
||
if (genTextAll.includes(t)) textPresent++;
|
||
}
|
||
if (s.node.tag === "a" && s.node.attrs.href) {
|
||
linksTotal++;
|
||
const srcHref = normHref(s.node.attrs.href, sourceOrigin);
|
||
const genHref = g ? normHref(g.attrs.href ?? "", sourceOrigin) : "";
|
||
if (srcHref === genHref) linksOk++;
|
||
}
|
||
if (/^(img|video|svg)$/.test(s.node.tag)) {
|
||
mediaTotal++;
|
||
if (s.node.tag === "svg") { if (g) mediaOk++; }
|
||
else {
|
||
const src = g?.attrs.src ?? "";
|
||
if (g && (src === "" || !src.startsWith("http") || src.includes("127.0.0.1") || src.startsWith("data:"))) mediaOk++;
|
||
}
|
||
}
|
||
}
|
||
|
||
// invented visible text > 20 chars not present anywhere in the source DOM
|
||
for (const g of gen.values()) {
|
||
if (!g.visible) continue;
|
||
const t = normText(g.text);
|
||
if (t.length > 20 && !allSrcText.has(t)) {
|
||
const inAnySrc = [...allSrcText].some((s) => s.includes(t) || t.includes(s));
|
||
if (!inAnySrc) inventedText++;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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;
|
||
const mediaPct = mediaTotal ? mediaOk / mediaTotal : 1;
|
||
|
||
if (textPct < 0.999) issues.push(`text presence ${(textPct * 100).toFixed(1)}% (< 100%)`);
|
||
if (matchPct < 0.98) issues.push(`node match ${(matchPct * 100).toFixed(1)}% (< 98%)`);
|
||
if (linkPct < 0.999) issues.push(`link href preserve ${(linkPct * 100).toFixed(1)}%`);
|
||
if (mediaPct < 0.999) issues.push(`media mapping ${(mediaPct * 100).toFixed(1)}%`);
|
||
if (inventedText > 0) issues.push(`${inventedText} invented visible text nodes > 20 chars`);
|
||
|
||
return {
|
||
gate: "dom",
|
||
pass: issues.length === 0,
|
||
metrics: {
|
||
nodeMatchPct: round4(matchPct), textPresentPct: round4(textPct),
|
||
linkPct: round4(linkPct), mediaPct: round4(mediaPct),
|
||
totalVisible, matched, textTotal, textPresent, linksTotal, mediaTotal, inventedText,
|
||
textHiddenInClone,
|
||
},
|
||
issues,
|
||
};
|
||
}
|
||
|
||
// The generator deterministically retags certain source elements to a neutral
|
||
// div/span for valid HTML / hydration correctness (a <button>/<a> nested in an
|
||
// interactive ancestor; a <ul>/<ol>/<p>/<hN> whose JS-built children violate its
|
||
// content model). Those retags preserve cid, geometry, styles, and content — the
|
||
// node is faithfully reproduced, just with an HTML-valid tag — so the DOM gate must
|
||
// credit them as matched rather than penalize the compiler's own correct transform.
|
||
function isValidRetag(srcTag: string, genTag: string): boolean {
|
||
if (genTag !== "div" && genTag !== "span") return false;
|
||
if (srcTag === "a" || srcTag === "button") return true; // nested-interactive → div/span
|
||
return genTag === "div" && /^(ul|ol|menu|dl|p|h[1-6])$/.test(srcTag); // content-model → div
|
||
}
|
||
|
||
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);
|
||
return (u.origin + u.pathname).replace(/\/$/, "") + (u.hash || "");
|
||
} catch { return href; }
|
||
}
|
||
|
||
// ---------- Gate 4: computed style equivalence ----------
|
||
const EXACT_PROPS = ["display", "position", "flexDirection", "justifyContent", "alignItems", "textAlign", "textTransform", "fontWeight", "zIndex"];
|
||
const COLOR_PROPS = ["color", "backgroundColor"];
|
||
const PX2_PROPS = ["fontSize", "lineHeight", "letterSpacing", "borderTopLeftRadius"];
|
||
const PILL_PX = 1000; // radius at/above which a corner is fully rounded — pill ≡ rounded-full
|
||
// padding + gap are authored spacing — kept exact. MARGINS are deferred to the layout gate:
|
||
// auto margins resolve from free space (geometry), so a flowed box shifts them to a
|
||
// visually-equivalent but different px; the box's PLACEMENT is what matters, enforced there.
|
||
const PX4_PROPS = ["paddingTop", "paddingRight", "paddingBottom", "paddingLeft", "gap"];
|
||
const PCT_PROPS = ["width", "height"];
|
||
|
||
export function gate4Style(ir: IR, genSnaps: Record<number, PageSnapshot>, viewports: number[]): GateResult {
|
||
let nodesChecked = 0, nodesPass = 0;
|
||
const propFail: Record<string, number> = {};
|
||
const issues: string[] = [];
|
||
|
||
for (const vp of viewports) {
|
||
const gen = genSnaps[vp] ? indexByCid(genSnaps[vp]!) : new Map<string, GenNode>();
|
||
const srcNodes = collectSrcNodes(ir, vp);
|
||
for (const s of srcNodes) {
|
||
if (!s.visible) continue;
|
||
const g = gen.get(s.node.id);
|
||
if (!g || g.tag !== s.node.tag) continue;
|
||
nodesChecked++;
|
||
let nodeOk = true;
|
||
const fails: string[] = [];
|
||
|
||
for (const p of EXACT_PROPS) {
|
||
// `flex-start`/`flex-end` and `start`/`end` are equivalent in flex/grid layout
|
||
// (and render identically — the perceptual gate confirms). A source `end` vs a
|
||
// Tailwind `items-end` (→`flex-end`) is not a real divergence, so normalize the
|
||
// synonym before comparing alignment keywords.
|
||
const norm = ALIGN_PROPS.has(p) ? normAlign : (x: string | undefined) => x;
|
||
if (!cmpExact(norm(s.computed[p]), norm(g.computed[p]))) { nodeOk = false; fails.push(p); }
|
||
}
|
||
for (const p of COLOR_PROPS) {
|
||
if (!colorsClose(s.computed[p], g.computed[p])) { nodeOk = false; fails.push(p); }
|
||
}
|
||
if (normFontFamily(s.computed.fontFamily) !== normFontFamily(g.computed.fontFamily)) { nodeOk = false; fails.push("fontFamily"); }
|
||
for (const p of PX2_PROPS) {
|
||
// A "pill" radius (≥ PILL_PX, e.g. the conventional 9999px) and Tailwind's `rounded-full`
|
||
// (which compiles to a saturating 3.4e38px) fully round the corner identically — visually
|
||
// 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;
|
||
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
|
||
// is equivalent to `gap: 0px`; the clone often renders one where the source
|
||
// computed the other. Normalize before comparing (real gaps still checked).
|
||
const a = p === "gap" ? (s.computed[p] ?? "").replace(/\bnormal\b/g, "0px") : s.computed[p];
|
||
const b = p === "gap" ? (g.computed[p] ?? "").replace(/\bnormal\b/g, "0px") : g.computed[p];
|
||
if (!cmpNum(a, b, 4, 0)) { nodeOk = false; fails.push(p); }
|
||
}
|
||
// width/height (PCT_PROPS) are NOT compared here: rendered box size is geometry, enforced
|
||
// directly by the layout gate (per-leaf size + section bbox) and the perceptual gate. A
|
||
// content-driven box (width/height:auto) reflows to a visually-equivalent but not
|
||
// byte-identical px — the right bar is "same place + size + pixels", not exact computed CSS.
|
||
// background image presence
|
||
const sBg = (s.computed.backgroundImage ?? "none") !== "none";
|
||
const gBg = (g.computed.backgroundImage ?? "none") !== "none";
|
||
if (sBg !== gBg) { nodeOk = false; fails.push("backgroundImage"); }
|
||
|
||
if (nodeOk) nodesPass++;
|
||
else {
|
||
for (const f of fails) propFail[f] = (propFail[f] ?? 0) + 1;
|
||
if (process.env.STYLE_DEBUG && (process.env.STYLE_DEBUG === "*" || fails.includes(process.env.STYLE_DEBUG))) {
|
||
const dp = process.env.STYLE_DEBUG === "*" ? fails : [process.env.STYLE_DEBUG];
|
||
console.error(`[style] vp=${vp} cid=${s.node.id} tag=${s.node.tag} fails=${fails.join(",")} ` +
|
||
dp.map((p) => `${p}: src='${s.computed[p]}' gen='${g.computed[p]}'`).join(" | "));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const passPct = nodesChecked ? nodesPass / nodesChecked : 1;
|
||
if (passPct < 0.95) issues.push(`computed style pass ${(passPct * 100).toFixed(1)}% (< 95%)`);
|
||
const topFails = Object.entries(propFail).sort((a, b) => b[1] - a[1]).slice(0, 12);
|
||
return {
|
||
gate: "style",
|
||
pass: issues.length === 0,
|
||
metrics: { passPct: round4(passPct), nodesChecked, nodesPass, topFailingProps: Object.fromEntries(topFails) },
|
||
issues,
|
||
};
|
||
}
|
||
|
||
function cmpExact(a: string | undefined, b: string | undefined): boolean {
|
||
if (a === undefined) return true; // source didn't constrain it
|
||
return (a ?? "") === (b ?? "");
|
||
}
|
||
const ALIGN_PROPS = new Set(["alignItems", "justifyContent"]);
|
||
/** Collapse flex-relative alignment synonyms to their writing-mode keyword
|
||
* (`flex-start`→`start`, `flex-end`→`end`) — equivalent in standard layouts. */
|
||
function normAlign(v: string | undefined): string | undefined {
|
||
return v === undefined ? v : v.replace(/^flex-/, "");
|
||
}
|
||
function cmpNum(a: string | undefined, b: string | undefined, abs: number, pct: number): boolean {
|
||
if (a === undefined) return true;
|
||
const na = pxNum(a), nb = pxNum(b);
|
||
if (Number.isNaN(na) || Number.isNaN(nb)) return (a ?? "") === (b ?? "");
|
||
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[] = [];
|
||
const perVp: Record<number, Record<string, unknown>> = {};
|
||
|
||
for (const vp of viewports) {
|
||
const genSnap = genSnaps[vp];
|
||
if (!genSnap) { issues.push(`no generated render at ${vp}`); continue; }
|
||
const gen = indexByCid(genSnap);
|
||
const srcHeight = ir.doc.perViewport[vp]?.scrollHeight ?? 0;
|
||
const genHeight = genSnap.doc.scrollHeight;
|
||
const heightDelta = srcHeight ? Math.abs(srcHeight - genHeight) / srcHeight : 0;
|
||
|
||
// sections present + order + bbox
|
||
let sectionsPresent = 0, sectionsBboxOk = 0;
|
||
const srcSectionYs: number[] = [], genSectionYs: number[] = [];
|
||
for (const sec of sections) {
|
||
const g = gen.get(sec.nodeId);
|
||
const srcBox = sec.bboxByVp[vp];
|
||
if (g) {
|
||
sectionsPresent++;
|
||
if (srcBox) {
|
||
srcSectionYs.push(srcBox.y);
|
||
genSectionYs.push(g.bbox.y);
|
||
const yOk = withinAbs(srcBox.y, g.bbox.y, Math.max(24, srcHeight * 0.03));
|
||
const hOk = withinPct(srcBox.height, g.bbox.height, 0.08);
|
||
const wOk = withinPct(srcBox.width, g.bbox.width, 0.02);
|
||
if (yOk && hOk && wOk) sectionsBboxOk++;
|
||
}
|
||
}
|
||
}
|
||
// The clone must reproduce the source's vertical section order — not be sorted
|
||
// ascending: a fixed/sticky nav can legitimately sit above the fold (y<0) after
|
||
// the main section in DOM order. Compare gen's ordering against the source's.
|
||
const orderOk = sameOrder(srcSectionYs, genSectionYs);
|
||
|
||
// leaf bbox position (median delta) AND size (per-leaf width/height within a visual tolerance).
|
||
// Rendered SIZE fidelity lives here, not in the computed-style gate: content-driven boxes
|
||
// (width/height:auto) reflow to visually-equivalent but not byte-identical px, so the right
|
||
// bar is "renders at the same place and size" (here + the perceptual gate), not exact CSS px.
|
||
const srcNodes = collectSrcNodes(ir, vp);
|
||
const deltas: number[] = [];
|
||
let sizeChecked = 0, sizeOk = 0;
|
||
for (const s of srcNodes) {
|
||
if (!s.visible) continue;
|
||
if (hasVisibleElementChild(s.node, vp)) continue; // leaf only
|
||
const g = gen.get(s.node.id);
|
||
if (!g) continue;
|
||
const d = Math.abs(s.bbox.x - g.bbox.x) + Math.abs(s.bbox.y - g.bbox.y);
|
||
deltas.push(d / 2);
|
||
sizeChecked++;
|
||
const wOk = withinAbs(s.bbox.width, g.bbox.width, Math.max(8, s.bbox.width * 0.06));
|
||
const hOk = withinAbs(s.bbox.height, g.bbox.height, Math.max(8, s.bbox.height * 0.06));
|
||
if (wOk && hOk) sizeOk++;
|
||
}
|
||
const medianDelta = median(deltas);
|
||
const leafSizeOkPct = sizeChecked ? sizeOk / sizeChecked : 1;
|
||
|
||
const sectionsBboxPct = sections.length ? sectionsBboxOk / sections.length : 1;
|
||
perVp[vp] = {
|
||
srcHeight, genHeight, heightDeltaPct: round4(heightDelta),
|
||
sectionsTotal: sections.length, sectionsPresent, sectionsBboxOkPct: round4(sectionsBboxPct),
|
||
orderOk, leafMedianDelta: round2(medianDelta), leafSamples: deltas.length, leafSizeOkPct: round4(leafSizeOkPct),
|
||
};
|
||
|
||
if (heightDelta > 0.05) issues.push(`vp${vp} page height delta ${(heightDelta * 100).toFixed(1)}% (> 5%)`);
|
||
if (sectionsPresent < sections.length) issues.push(`vp${vp} ${sections.length - sectionsPresent} sections missing`);
|
||
if (!orderOk) issues.push(`vp${vp} section order mismatch`);
|
||
if (sections.length && sectionsBboxPct < 0.9) issues.push(`vp${vp} section bbox ok ${(sectionsBboxPct * 100).toFixed(0)}% (< 90%)`);
|
||
// leaf-POSITION median tolerance is generous (the perceptual screenshot gate is the strict
|
||
// visual bar for placement; a small median drift from letting geometry flow is invisible there
|
||
// — section structure + leaf SIZE below stay tight). A large drift still fails. In the opt-in
|
||
// reflow trade (flowed heights re-position content vertically) the bar widens to 2% of the page
|
||
// height (≈160px on a tall mobile page vs the ~72px benign drift) — backstopped by the page-height
|
||
// (≤5%), section-bbox (≥90%), leaf-size (≥92%) and perceptual (≤10–14%) gates, which all stay
|
||
// strict and catch any REAL break (content lost, mis-sized, or visually displaced).
|
||
const posTol = reflow ? Math.max(16, Math.round(srcHeight * 0.02)) : 16;
|
||
if (medianDelta > posTol) issues.push(`vp${vp} leaf median bbox delta ${medianDelta.toFixed(1)}px (> ${posTol}px)`);
|
||
if (leafSizeOkPct < 0.92) issues.push(`vp${vp} leaf size ok ${(leafSizeOkPct * 100).toFixed(0)}% (< 92%)`);
|
||
}
|
||
|
||
return { gate: "layout", pass: issues.length === 0, metrics: { perViewport: perVp }, issues };
|
||
}
|
||
|
||
// ---------- Responsive gate: behaviour at NON-captured widths ----------
|
||
// The other layout gates render the clone only at the captured widths (375/768/1280/1920),
|
||
// so per-node baked-px output scores perfectly there even though it stairsteps between bands
|
||
// and off-centres beyond the widest one (the "not truly responsive" failure). This gate
|
||
// renders the clone at widths the grader never sampled and asserts source-independent
|
||
// invariants that any faithful responsive clone must hold:
|
||
// 1. no horizontal overflow the source didn't have (a baked px wider than the window);
|
||
// 2. a container the source centres stays centred at every width (not frozen at one band's
|
||
// margins);
|
||
// 3. a full-bleed section keeps filling the window.
|
||
type ParentOf = Map<string, IRNode | undefined>;
|
||
function buildParentMap(ir: IR): ParentOf {
|
||
const m: ParentOf = new Map();
|
||
const walk = (node: IRNode, parent: IRNode | undefined): void => {
|
||
m.set(node.id, parent);
|
||
for (const c of node.children) if (!isTextChild(c)) walk(c, node);
|
||
};
|
||
walk(ir.root, undefined);
|
||
return m;
|
||
}
|
||
function contentBox(bbox: { x: number; width: number }, cs: Record<string, string>): { left: number; right: number; width: number } {
|
||
const padL = pxNum(cs.paddingLeft) || 0, padR = pxNum(cs.paddingRight) || 0;
|
||
const bL = pxNum(cs.borderLeftWidth) || 0, bR = pxNum(cs.borderRightWidth) || 0;
|
||
const left = bbox.x + padL + bL, right = bbox.x + bbox.width - padR - bR;
|
||
return { left, right, width: right - left };
|
||
}
|
||
/** Is this source node horizontally centred in its container at `vp` (block-level, in-flow,
|
||
* symmetric gaps, room to centre)? Mirrors the generator's centring detection. */
|
||
function srcCentered(node: IRNode, parent: IRNode, vp: number): boolean {
|
||
const cs = node.computedByVp[vp], nb = node.bboxByVp[vp];
|
||
const pcs = parent.computedByVp[vp], pb = parent.bboxByVp[vp];
|
||
if (!cs || !nb || !pcs || !pb) return false;
|
||
if (!/^(block|flow-root|list-item|flex|grid)$/.test(cs.display || "")) return false;
|
||
if ((cs.position || "static") !== "static" && (cs.position || "static") !== "relative") return false;
|
||
if (/(?:^|-)(?:flex|grid)$/.test(pcs.display || "")) return false; // flex/grid item — parent positions it
|
||
const pc = contentBox(pb, pcs);
|
||
const gapL = nb.x - pc.left, gapR = pc.right - (nb.x + nb.width);
|
||
return gapL > 2 && gapR > 2 && Math.abs(gapL - gapR) <= 1.5 && nb.width < pc.width - 4;
|
||
}
|
||
/** Does this source node span the full viewport width at ≥2 captured widths (a full-bleed bar)? */
|
||
function srcFullBleed(node: IRNode, viewports: number[]): boolean {
|
||
let n = 0;
|
||
for (const vp of viewports) {
|
||
const nb = node.bboxByVp[vp];
|
||
if (!nb) continue;
|
||
if (Math.abs(nb.x) <= 2 && Math.abs(nb.width - vp) <= 2) n++;
|
||
else if (nb.width > 0) return false;
|
||
}
|
||
return n >= 2;
|
||
}
|
||
|
||
export function gateResponsive(ir: IR, probes: Record<number, PageSnapshot>, viewports: number[]): GateResult {
|
||
const issues: string[] = [];
|
||
const widths = Object.keys(probes).map(Number).sort((a, b) => a - b);
|
||
if (widths.length === 0) return { gate: "responsive", pass: true, metrics: { probed: 0, na: true }, issues };
|
||
|
||
// Source horizontal-overflow baseline: did the captured page itself scroll horizontally?
|
||
// (A site that legitimately overflows at capture is exempt from the overflow invariant.)
|
||
const srcOverflows = viewports.some((vp) => {
|
||
const pv = ir.doc.perViewport[vp];
|
||
return pv ? pv.scrollWidth > vp + 3 : false;
|
||
});
|
||
|
||
const canonical = ir.doc.canonicalViewport;
|
||
const parentOf = buildParentMap(ir);
|
||
const centeredCids: string[] = [];
|
||
const fullBleedCids: string[] = [];
|
||
const walk = (node: IRNode): void => {
|
||
const parent = parentOf.get(node.id);
|
||
if (parent && srcCentered(node, parent, canonical)) centeredCids.push(node.id);
|
||
if (srcFullBleed(node, viewports)) fullBleedCids.push(node.id);
|
||
for (const c of node.children) if (!isTextChild(c)) walk(c);
|
||
};
|
||
walk(ir.root);
|
||
|
||
let overflowViolations = 0, centerChecks = 0, centerViolations = 0, bleedChecks = 0, bleedViolations = 0;
|
||
const samples: string[] = [];
|
||
for (const w of widths) {
|
||
const snap = probes[w]!;
|
||
const idx = indexByCid(snap);
|
||
// (1) no unexpected horizontal overflow
|
||
if (!srcOverflows && snap.doc.scrollWidth > w + Math.max(3, w * 0.01)) {
|
||
overflowViolations++;
|
||
if (samples.length < 8) samples.push(`w=${w}: scrollWidth ${snap.doc.scrollWidth} > viewport ${w} (horizontal overflow)`);
|
||
}
|
||
// (2) centred containers stay centred
|
||
for (const cid of centeredCids) {
|
||
const g = idx.get(cid); if (!g || !g.visible) continue;
|
||
const parent = parentOf.get(cid); const pg = parent ? idx.get(parent.id) : undefined;
|
||
if (!pg) continue;
|
||
const pc = contentBox(pg.bbox, pg.computed);
|
||
if (g.bbox.width >= pc.width - 4) continue; // fills here (below its cap) → nothing to centre
|
||
const gapL = g.bbox.x - pc.left, gapR = pc.right - (g.bbox.x + g.bbox.width);
|
||
centerChecks++;
|
||
if (Math.abs(gapL - gapR) > Math.max(2, pc.width * 0.01)) {
|
||
centerViolations++;
|
||
if (samples.length < 8) samples.push(`w=${w} cid=${cid}: off-centre (left ${Math.round(gapL)} vs right ${Math.round(gapR)})`);
|
||
}
|
||
}
|
||
// (3) full-bleed sections keep filling the window
|
||
for (const cid of fullBleedCids) {
|
||
const g = idx.get(cid); if (!g || !g.visible) continue;
|
||
bleedChecks++;
|
||
if (Math.abs(g.bbox.width - w) > Math.max(3, w * 0.01)) {
|
||
bleedViolations++;
|
||
if (samples.length < 8) samples.push(`w=${w} cid=${cid}: full-bleed width ${Math.round(g.bbox.width)} != viewport ${w}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (overflowViolations > 0) issues.push(`${overflowViolations} probe width(s) overflow horizontally`);
|
||
// A counted violation already cleared the sub-pixel geometric tolerance, so it is a real
|
||
// divergence; tolerate only a ~5% fraction (a rare breakpoint that legitimately re-aligns).
|
||
if (centerViolations > Math.floor(centerChecks * 0.05)) issues.push(`${centerViolations}/${centerChecks} centred-container checks off-centre at non-captured widths`);
|
||
if (bleedViolations > Math.floor(bleedChecks * 0.05)) issues.push(`${bleedViolations}/${bleedChecks} full-bleed checks don't fill the window`);
|
||
|
||
return {
|
||
gate: "responsive",
|
||
pass: issues.length === 0,
|
||
metrics: {
|
||
probeWidths: widths, srcOverflows,
|
||
overflowViolations, centerChecks, centerViolations, bleedChecks, bleedViolations,
|
||
centeredContainers: centeredCids.length, fullBleedSections: fullBleedCids.length,
|
||
samples,
|
||
},
|
||
issues,
|
||
};
|
||
}
|
||
|
||
function isSortedAscending(arr: number[]): boolean {
|
||
for (let i = 1; i < arr.length; i++) if (arr[i]! < arr[i - 1]! - 1) return false;
|
||
return true;
|
||
}
|
||
/** True when `b` orders its elements the same way `a` does (same argsort) — i.e.
|
||
* the clone reproduces the source's relative vertical section order. */
|
||
function sameOrder(a: number[], b: number[]): boolean {
|
||
if (a.length !== b.length) return false;
|
||
const idx = a.map((_, i) => i);
|
||
const byA = [...idx].sort((x, y) => a[x]! - a[y]!);
|
||
const byB = [...idx].sort((x, y) => b[x]! - b[y]!);
|
||
return byA.every((v, i) => v === byB[i]);
|
||
}
|
||
function median(arr: number[]): number {
|
||
if (arr.length === 0) return 0;
|
||
const s = [...arr].sort((a, b) => a - b);
|
||
const mid = Math.floor(s.length / 2);
|
||
return s.length % 2 ? s[mid]! : (s[mid - 1]! + s[mid]!) / 2;
|
||
}
|
||
function round2(n: number): number { return Math.round(n * 100) / 100; }
|
||
function round4(n: number): number { return Math.round(n * 10000) / 10000; }
|