Initial commit
This commit is contained in:
@@ -0,0 +1,641 @@
|
||||
import type { IR, IRNode } from "../normalize/ir.js";
|
||||
import { isTextChild } 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";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// ---------- 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.
|
||||
const WALL_RE = /blocked by egress|access denied|access to this page has been denied|are you a (human|robot)|verify you are human|enable javascript to|please enable javascript|checking your browser|just a moment|attention required|request blocked|why have i been blocked|captcha|cf-browser-verification|ddos protection by/i;
|
||||
|
||||
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;
|
||||
for (const pv of capture.perViewport) {
|
||||
if (pv.height > 0) minHeightRatio = Math.min(minHeightRatio, pv.scrollHeight / pv.height);
|
||||
}
|
||||
if (!Number.isFinite(minHeightRatio)) minHeightRatio = 1;
|
||||
|
||||
// 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");
|
||||
|
||||
return {
|
||||
gate: "pollution",
|
||||
pass: issues.length === 0,
|
||||
metrics: {
|
||||
nodeCount, visibleTextChars: textChars, wallTextDetected: wall,
|
||||
overlaysRemaining, blocking, minScrollHeightRatio: round2(minHeightRatio),
|
||||
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 404`);
|
||||
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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
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
|
||||
}
|
||||
|
||||
function normHref(href: string, origin: string): string {
|
||||
if (!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;
|
||||
if (!cmpNum(s.computed[p], g.computed[p], 2, 0)) { 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));
|
||||
}
|
||||
|
||||
// ---------- 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; }
|
||||
Reference in New Issue
Block a user