Files
hyperframes/packages/cli/src/utils/checkBrowser.ts
T
Xuanru Li e710a1686f feat(lint): add off_pivot_rotation hub-referenced layout check (#2744)
## What it catches

A gauge needle / clock hand / dial pointer / radar sweep that rotates about the **wrong pivot** — the recovered center-of-rotation sits far from the dial hub (e.g. `transform-origin` at the needle base or SVG element edge instead of the dial center). Visually the needle "wobbles" or orbits off-axis instead of sweeping cleanly about the hub.

This is a genuine gap in the current checks: `rotation_pivot_drift` (#2741) provably **cannot** catch it — a correct sweeping needle's bbox-center orbits identically to a broken one, so only a **dial-hub reference** distinguishes them. This is the separate hub-referenced check that analysis called for.

## How it works

- Sampler maps 2 material endpoints per frame via `getScreenCTM` (honors the actual rendered transform, independent of `svgOrigin`).
- Resolves the dial hub = shared center of the modal set of static concentric circles, or the arc-center of the largest static near-circular path (Kasa circle fit).
- Fits a circle to the endpoint trajectory to recover the true center-of-rotation; flags drift `> 0.35 * pointer_length`. One warning per hub.
- Never fires without a resolvable hub. Walks the rotation reference to the composition root (not the `<svg>`) so a pointer rotated by a `div` ancestor is measured correctly.
- Multi-body guard: `>= 2` bodies at distinct angular positions on one hub = orbit/atom system, not a dial → suppressed.

## Corpus evidence (autonomous geometry-fuzz run, 81 fuzzed diagrams)

- **7 / 7 true positives, 0 false positives across all 81 samples.**
- Assigned TPs: fuzz005, fuzz017, fuzz032. Bonus TPs: fuzz044, fuzz056, fuzz068, fuzz080.
- **The Gemini-3.6 video-judge itself MISSED all 4 bonus TPs** (`vlm_has_defects: false`) — the deterministic hub-reference check beats the VLM on this defect class.
- FPs driven to 0 by the two principled guards above: fuzz016 (planet arc rotated by a `div` ancestor) cleared by root-walk; fuzz055 (atom) cleared by the multi-body guard.
- fuzz080 reads as a false positive to the connector check but is a true positive here — confirms the architectural boundary between the two checks is drawn correctly.

## Validation

- Autonomous Gemini-3.6 **video**-judge fuzz run to surface candidate defects, then a **deterministic FP sweep** across all 81 rendered compositions (not VLM-gated — code inspection is the arbiter, since the VLM both over- and under-calls this class).
- 9 unit tests (`checkPipeline.offPivotRotation.test.ts`) + full check suite pass; `bun run build` green.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-24 17:38:58 -07:00

1207 lines
44 KiB
TypeScript

import { mkdirSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path";
import type { Page } from "puppeteer-core";
import {
AUDIT_SEEK_OPTIONS,
DEFAULT_ZOOM_PADDING_PX,
DEFAULT_ZOOM_SCALE,
captureRegionCrop,
openSettledCompositionPage,
padCropRegion,
resolveCliChromeGpuMode,
seekCompositionTimeline,
waitForPreferredSeekTarget,
} from "../capture/captureCompositionFrame.js";
import { auditClipDurations, shouldIgnoreRequestFailure } from "../commands/validate.js";
import { loadBrowserScript } from "../commands/layout.js";
import { normalizeErrorMessage } from "./errorMessage.js";
import { ambiguousIssue, type MotionFrame } from "./motionAudit.js";
import type { LayoutIssue, LayoutIssueCode, LayoutRect } from "./layoutAudit.js";
import { serveStaticProjectHtml } from "./staticProjectServer.js";
import { resolveAutoProxy } from "./projectConfig.js";
import {
decideMediaProxyEligibility,
proxyVariantFor,
scanProjectMediaCodecMap,
} from "@hyperframes/studio-server/media-codec-map";
import { resolveProxy } from "@hyperframes/studio-server/proxy-transcoder";
import { rectToBbox } from "./checkTypes.js";
import type {
AnchoredLayoutIssue,
CheckAnchor,
CheckAnnotationBox,
CheckAuditDriver,
CheckBbox,
CheckBrowserResult,
CheckFinding,
CheckFindingCropRequest,
CheckGeometryCandidate,
CheckOptions,
CheckSeverity,
ContrastAuditEntry,
ContrastCapture,
GeometryCandidateRequest,
MotionSpecResolution,
OffPivotFrame,
OffPivotRotationSample,
RotationSample,
RunAuditGrid,
} from "./checkTypes.js";
import type { ProjectDir } from "./project.js";
interface RuntimeDraft {
code: string;
severity: CheckSeverity;
message: string;
time: number;
url?: string;
line?: number;
}
interface AnchorRequest {
selector: string;
time: number;
bbox: CheckBbox;
}
interface ContrastCandidate {
selector: string;
text: string;
fg: [number, number, number, number];
large: boolean;
bbox: CheckBbox;
}
interface PreparedContrast {
// The untouched candidate object from __contrastAuditPrepare. It round-trips
// back into __contrastAuditFinish verbatim — the browser script owns its
// shape (e.g. bbox uses w/h, not width/height), so Node must not normalize
// what it sends back. `candidate` is the parsed copy for Node-side reporting.
raw: unknown;
candidate: ContrastCandidate;
anchor: CheckAnchor;
}
interface FinishedContrast {
selector: string;
text: string;
ratio: number;
wcagAA: boolean;
large: boolean;
fg: string;
bg: string;
}
/**
* Awaits the H.264 authoring proxy for every browser-hostile local video
* asset in `html` BEFORE the timed render-ready wait starts. `check`'s
* render-ready wait defaults to 3000ms (`DEFAULT_CHECK_OPTIONS.timeout`,
* passed through as `renderReadyTimeoutMs`), and a cold `.transcode-cache`
* cannot fit inside that window — without this, a project's first
* hostile-asset check (e.g. a fresh CI checkout) would race the timeout
* instead of paying a bounded one-time transcode cost
* (docs/plans/2026-07-14-002-feat-transparent-media-proxies-plan.md, unit U4).
* Best-effort: a probe or transcode failure does not fail `check`; one summary
* line records the pre-resolve outcome before the runtime attempts playback.
*/
export async function preResolveHostileMediaProxies(
projectDir: string,
html: string,
autoProxyOverride?: boolean,
): Promise<void> {
if (!resolveAutoProxy(projectDir, autoProxyOverride)) return;
let codecMap: Awaited<ReturnType<typeof scanProjectMediaCodecMap>>;
try {
codecMap = await scanProjectMediaCodecMap(projectDir, [{ html }]);
} catch (err) {
console.info(
`[hyperframes] media proxy pre-resolve: scan failed (${normalizeErrorMessage(err)})`,
);
return;
}
const hostileEntries = Object.entries(codecMap).filter(
([, facts]) => decideMediaProxyEligibility(facts).eligible,
);
if (hostileEntries.length === 0) return;
const startedAt = Date.now();
const results = await Promise.allSettled(
hostileEntries.map(([pathname, facts]) =>
resolveProxy(
projectDir,
resolve(projectDir, pathname.replace(/^\/+/, "")),
proxyVariantFor(facts),
),
),
);
const failed = results.filter((result) => result.status === "rejected").length;
console.info(
`[hyperframes] media proxy pre-resolve: ${results.length - failed}/${results.length} ready, ${failed} failed (${Date.now() - startedAt}ms)`,
);
}
export async function runBrowserCheck(
project: ProjectDir,
options: CheckOptions,
motion: MotionSpecResolution,
runGrid: RunAuditGrid,
): Promise<CheckBrowserResult> {
const { bundleWithLocalizedFonts } = await import("./bundleWithLocalizedFonts.js");
const html = await bundleWithLocalizedFonts(project.dir);
await preResolveHostileMediaProxies(project.dir, html, options.autoProxy);
const server = await serveStaticProjectHtml(
project.dir,
html,
"Failed to bind check server",
[],
options.autoProxy,
);
const drafts: RuntimeDraft[] = [];
let currentTime = 0;
let chromeBrowser: import("puppeteer-core").Browser | undefined;
try {
const launchSettleStart = Date.now();
const session = await openSettledCompositionPage(html, server.url, {
renderReadyTimeoutMs: options.timeout,
renderReadyWarningSuffix: "checking the current page state",
browserGpuMode: resolveCliChromeGpuMode(),
beforeNavigate: (page) => wireRuntimeListeners(page, drafts, () => currentTime),
});
chromeBrowser = session.browser;
const page = session.page;
await waitForPreferredSeekTarget(page);
const rootAnchor = await resolveRootAnchor(page);
const launchSettleMs = Date.now() - launchSettleStart;
// validate's per-media-element audit, kept in the consolidation: a clip
// whose intrinsic duration is meaningfully shorter than its data-duration
// slot silently shortens the slot at render time — invisible to lint (no
// intrinsic durations statically) and to the runtime listeners (nothing
// errors). The session is already open, so this is one extra evaluate.
const { analyzeClipMediaFit } = await import("@hyperframes/engine");
for (const entry of await auditClipDurations(page, analyzeClipMediaFit, options.timeout)) {
drafts.push({ code: "clip_media_fit", severity: entry.level, message: entry.text, time: 0 });
}
const driver = createPageDriver(page, (time) => {
currentTime = time;
});
const result = await runGrid(driver, options, motion);
return {
...result,
timings: { ...result.timings, launchSettleMs },
runtimeFindings: drafts.map((draft) => runtimeFinding(draft, rootAnchor)),
};
} finally {
await chromeBrowser?.close().catch(() => undefined);
await server.close();
}
}
/**
* `check --snapshots`'s per-finding evidence crops. Opens its own session
* (the main grid session already closed by the time findings are shaped) and
* re-seeks to each finding's sample time — renders are deterministic, so a
* fresh page at the same time reproduces the same pixels the grid audited.
*/
export async function captureFindingCrops(
project: ProjectDir,
options: CheckOptions,
requests: CheckFindingCropRequest[],
): Promise<string[]> {
if (requests.length === 0) return [];
const { bundleWithLocalizedFonts } = await import("./bundleWithLocalizedFonts.js");
const html = await bundleWithLocalizedFonts(project.dir);
const server = await serveStaticProjectHtml(
project.dir,
html,
"Failed to bind check server",
[],
options.autoProxy,
);
let chromeBrowser: import("puppeteer-core").Browser | undefined;
const written: string[] = [];
try {
const session = await openSettledCompositionPage(html, server.url, {
renderReadyTimeoutMs: options.timeout,
renderReadyWarningSuffix: "capturing finding crops",
browserGpuMode: resolveCliChromeGpuMode(),
});
chromeBrowser = session.browser;
const page = session.page;
await waitForPreferredSeekTarget(page);
const snapshotDir = join(project.dir, "snapshots");
mkdirSync(snapshotDir, { recursive: true });
for (const request of requests) {
await seekCompositionTimeline(page, request.time, AUDIT_SEEK_OPTIONS);
const canvas = await page.evaluate(() => ({
width: window.innerWidth,
height: window.innerHeight,
}));
const region = padCropRegion(request.bbox, canvas, DEFAULT_ZOOM_PADDING_PX);
const buffer = await captureRegionCrop(page, region, DEFAULT_ZOOM_SCALE);
writeFileSync(join(snapshotDir, request.filename), buffer);
written.push(join("snapshots", request.filename));
}
return written;
} finally {
await chromeBrowser?.close().catch(() => undefined);
await server.close();
}
}
// `swapToProxy` / `emitUnavailableDiagnostic` (packages/core/src/runtime/
// mediaProxy.ts) embed their stable diagnostic codes in the console.info line
// precisely so this scraper can match a token instead of prose. Matching the
// shared "runtime_media_proxy_" prefix surfaces both codes; only those
// runtime-emitted info lines should ever become findings here — an ordinary
// `console.info` from a composition author's own script must not.
const MEDIA_PROXY_MARKER_PREFIX = "[hyperframes] runtime_media_proxy_";
const MEDIA_PROXY_UNAVAILABLE_MARKER = "[hyperframes] runtime_media_proxy_unavailable";
function wireRuntimeListeners(page: Page, drafts: RuntimeDraft[], currentTime: () => number): void {
page.on("console", (message) => {
const type = message.type();
const text = message.text();
if (type === "error" && !text.startsWith("Failed to load resource")) {
const location = message.location();
drafts.push({
code: "console_error",
severity: "error",
message: text,
time: currentTime(),
url: location.url,
line: location.lineNumber,
});
} else if (type === "warn") {
const location = message.location();
drafts.push({
code: "console_warning",
severity: "warning",
message: text,
time: currentTime(),
url: location.url,
line: location.lineNumber,
});
} else if (type === "info" && text.startsWith(MEDIA_PROXY_MARKER_PREFIX)) {
const location = message.location();
drafts.push({
code: text.includes(MEDIA_PROXY_UNAVAILABLE_MARKER)
? "media_proxy_unavailable"
: "media_proxy_fallback",
severity: "info",
message: text,
time: currentTime(),
url: location.url,
line: location.lineNumber,
});
}
});
page.on("pageerror", (error) => {
const message = normalizeErrorMessage(error);
if (message.includes("Unexpected token '<'") || message.includes("Unexpected token '&lt;'")) {
return;
}
drafts.push({ code: "page_error", severity: "error", message, time: currentTime() });
});
wireNetworkListeners(page, drafts, currentTime);
}
function wireNetworkListeners(page: Page, drafts: RuntimeDraft[], currentTime: () => number): void {
page.on("requestfailed", (request) => {
const url = request.url();
if (url.includes("favicon") || url.startsWith("data:")) return;
const failure = request.failure()?.errorText;
if (shouldIgnoreRequestFailure(url, failure, request.resourceType())) return;
drafts.push({
code: "request_failed",
severity: "error",
message: `Failed to load ${urlPath(url)}: ${failure ?? "net::ERR_FAILED"}`,
time: currentTime(),
url,
});
});
page.on("response", (response) => {
if (response.status() < 400) return;
const url = response.url();
if (url.includes("favicon")) return;
drafts.push({
code: "http_error",
severity: "error",
message: `${response.status()} loading ${urlPath(url)}`,
time: currentTime(),
url,
});
});
}
function createPageDriver(page: Page, setTime: (time: number) => void): CheckAuditDriver {
return {
initialize: (contrast) => injectAuditScripts(page, contrast),
getDuration: () => getCompositionDuration(page),
getTransitionBoundaries: () => collectTweenBoundaries(page),
getCanvas: () =>
page.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight })),
findAmbiguousSelectors: (selectors) => findAmbiguousSelectors(page, selectors),
seek: async (time) => {
setTime(time);
await seekCompositionTimeline(page, time, AUDIT_SEEK_OPTIONS);
},
collectLayout: (time, tolerance) => collectLayout(page, time, tolerance),
collectLayoutGeometry: () => collectLayoutGeometry(page),
collectRotationSample: (time) => collectRotationSample(page, time),
collectOffPivotRotationSample: (time) => collectOffPivotRotationSample(page, time),
collectGeometryCandidates: (time, request) => collectGeometryCandidates(page, time, request),
collectMotionFrame: (time, selectors, scopes) =>
collectMotionFrame(page, time, selectors, scopes),
anchorMotionIssues: (issues) => anchorLayoutIssues(page, issues),
collectContrast: (time, annotations) => collectContrast(page, time, annotations),
};
}
async function injectAuditScripts(page: Page, contrast: boolean): Promise<void> {
await page.addScriptTag({ content: loadBrowserScript("layout-audit.browser.js") });
await page.addScriptTag({ content: loadBrowserScript("motion-sample.browser.js") });
if (contrast) {
await page.addScriptTag({ content: loadBrowserScript("contrast-audit.browser.js") });
}
}
async function getCompositionDuration(page: Page): Promise<number> {
// Duration resolution is serialized into the page and must remain self-contained.
// fallow-ignore-next-line complexity
return page.evaluate(() => {
const value = (target: unknown, key: string): unknown =>
typeof target === "object" && target !== null ? Reflect.get(target, key) : undefined;
const positive = (candidate: unknown): number | null =>
typeof candidate === "number" && candidate > 0 ? candidate : null;
const callDuration = (target: unknown): number | null => {
const duration = value(target, "duration");
if (typeof duration === "function") {
const result = Reflect.apply(duration, target, []);
return positive(result);
}
return positive(duration);
};
const hfDuration = positive(value(Reflect.get(window, "__hf"), "duration"));
if (hfDuration) return hfDuration;
const playerDuration = callDuration(Reflect.get(window, "__player"));
if (playerDuration) return playerDuration;
const root = document.querySelector("[data-composition-id][data-duration]");
const authored = root ? parseFloat(root.getAttribute("data-duration") ?? "0") : 0;
if (authored > 0) return authored;
const timelines = Reflect.get(window, "__timelines");
if (typeof timelines !== "object" || timelines === null) return 0;
for (const key of Object.keys(timelines)) {
const duration = callDuration(Reflect.get(timelines, key));
if (duration) return duration;
}
return 0;
});
}
async function collectTweenBoundaries(page: Page): Promise<number[]> {
// GSAP getter binding and parent-time conversion form one serialized algorithm.
// fallow-ignore-next-line complexity
return page.evaluate(() => {
const property = (target: unknown, key: string): unknown =>
(typeof target === "object" && target !== null) || typeof target === "function"
? Reflect.get(target, key)
: undefined;
const numberCall = (target: unknown, key: string, fallback: number): number => {
const method = property(target, key);
if (typeof method !== "function") return fallback;
const result = Reflect.apply(method, target, []);
return typeof result === "number" ? result : fallback;
};
const rootTime = (root: unknown, animation: unknown, local: number): number => {
let time = local;
let node = animation;
while (node && node !== root) {
time = numberCall(node, "startTime", 0) + time / (numberCall(node, "timeScale", 1) || 1);
node = property(node, "parent");
}
return time;
};
const timelines = Reflect.get(window, "__timelines");
if (typeof timelines !== "object" || timelines === null) return [];
const boundaries: number[] = [];
for (const key of Object.keys(timelines)) {
const timeline = Reflect.get(timelines, key);
const getChildren = property(timeline, "getChildren");
if (typeof getChildren !== "function") continue;
try {
const children = Reflect.apply(getChildren, timeline, [true, true, false]);
if (!Array.isArray(children)) continue;
for (const child of children) {
const duration = numberCall(child, "duration", Number.NaN);
if (!Number.isFinite(duration)) continue;
boundaries.push(rootTime(timeline, child, 0), rootTime(timeline, child, duration));
}
} catch {
continue;
}
}
return boundaries.filter(Number.isFinite);
});
}
async function collectLayout(
page: Page,
time: number,
tolerance: number,
): Promise<AnchoredLayoutIssue[]> {
const raw = await page.evaluate(
(options: { time: number; tolerance: number }) => {
const audit = Reflect.get(window, "__hyperframesLayoutAudit");
if (typeof audit !== "function") return [];
const result = Reflect.apply(audit, window, [options]);
return Array.isArray(result) ? result : [];
},
{ time, tolerance },
);
return anchorLayoutIssues(page, raw.flatMap(parseLayoutIssue));
}
async function collectLayoutGeometry(page: Page): Promise<string> {
return page.evaluate(() => {
const geometry = Reflect.get(window, "__hyperframesLayoutGeometry");
if (typeof geometry !== "function") return "";
const result = Reflect.apply(geometry, window, []);
return typeof result === "string" ? result : "";
});
}
/** Invoke a `window.__hyperframes*` sampler injected by layout-audit.browser.js
* and return its array result (or [] when absent / non-array). Shared by the
* per-frame sample collectors so the page.evaluate boilerplate lives once. */
async function evaluateSampler(page: Page, globalName: string): Promise<unknown[]> {
return page.evaluate((name) => {
const sample = Reflect.get(window, name);
if (typeof sample !== "function") return [];
const result = Reflect.apply(sample, window, []);
return Array.isArray(result) ? result : [];
}, globalName);
}
async function collectRotationSample(page: Page, time: number): Promise<RotationSample[]> {
const raw = await evaluateSampler(page, "__hyperframesRotationSample");
return raw.flatMap((value) => parseRotationSample(value, time));
}
function parseRotationSample(value: unknown, time: number): RotationSample[] {
if (!isRecord(value)) return [];
const selector = stringValue(value, "selector");
const cx = numberValue(value, "cx");
const cy = numberValue(value, "cy");
const w = numberValue(value, "w");
const h = numberValue(value, "h");
const angle = numberValue(value, "angle");
if (!selector || cx === null || cy === null || w === null || h === null || angle === null) {
return [];
}
return [{ time, selector, cx, cy, w, h, angle }];
}
async function collectOffPivotRotationSample(page: Page, time: number): Promise<OffPivotFrame> {
const raw = await evaluateSampler(page, "__hyperframesOffPivotRotationSample");
return { time, samples: raw.flatMap(parseOffPivotRotationSample) };
}
/** Read every named key as a finite number; null if ANY is missing/non-finite.
* The mapped return type keeps each field a plain `number` (not `number |
* undefined`) so callers read `nums.ax` without re-narrowing. */
function requiredNumbers<K extends string>(
value: Record<string, unknown>,
keys: readonly K[],
): { [P in K]: number } | null {
const out = {} as { [P in K]: number };
for (const key of keys) {
const num = numberValue(value, key);
if (num === null) return null;
out[key] = num;
}
return out;
}
const OFF_PIVOT_REQUIRED_NUMBERS = ["ax", "ay", "bx", "by", "len", "angle", "hubCount"] as const;
function parseOffPivotRotationSample(value: unknown): OffPivotRotationSample[] {
if (!isRecord(value)) return [];
const selector = stringValue(value, "selector");
const nums = requiredNumbers(value, OFF_PIVOT_REQUIRED_NUMBERS);
if (!selector || !nums) return [];
return [
{
selector,
ax: nums.ax,
ay: nums.ay,
bx: nums.bx,
by: nums.by,
len: nums.len,
angle: nums.angle,
hx: numberValue(value, "hx"),
hy: numberValue(value, "hy"),
hr: numberValue(value, "hr"),
hubCount: nums.hubCount,
},
];
}
async function collectGeometryCandidates(
page: Page,
time: number,
request: GeometryCandidateRequest,
): Promise<CheckGeometryCandidate[]> {
try {
const raw = await page.evaluate((options: GeometryCandidateRequest) => {
const collect = Reflect.get(window, "__hyperframesGeometryCandidates");
if (typeof collect !== "function") return [];
const result = Reflect.apply(collect, window, [options]);
return Array.isArray(result) ? result : [];
}, request);
return raw.flatMap((value) => parseGeometryCandidate(value, time));
} catch {
return [];
}
}
async function findAmbiguousSelectors(
page: Page,
selectors: string[],
): Promise<AnchoredLayoutIssue[]> {
const ambiguous = await page.evaluate(
(values: string[]) =>
values.filter((selector) => {
try {
return document.querySelectorAll(selector).length > 1;
} catch {
return false;
}
}),
selectors,
);
return anchorLayoutIssues(page, ambiguous.map(ambiguousIssue));
}
async function collectMotionFrame(
page: Page,
time: number,
selectors: string[],
livenessScopes: string[],
): Promise<MotionFrame> {
const raw = await page.evaluate(
(options: { selectors: string[]; livenessScopes: string[] }) => {
const sample = Reflect.get(window, "__hyperframesMotionSample");
if (typeof sample !== "function") return null;
return Reflect.apply(sample, window, [options]);
},
{ selectors, livenessScopes },
);
return parseMotionFrame(raw, time, selectors, livenessScopes);
}
async function anchorLayoutIssues(
page: Page,
issues: LayoutIssue[],
): Promise<AnchoredLayoutIssue[]> {
const requests = issues.map((issue) => ({
selector: issue.selector,
time: issue.time,
bbox: rectToBbox(issue.rect),
}));
const anchors = await resolveAnchors(page, requests);
return issues.map((issue, index) => ({
...issue,
...(anchors[index] ?? fallbackAnchor(requests[index])),
}));
}
async function resolveAnchors(page: Page, requests: AnchorRequest[]): Promise<CheckAnchor[]> {
if (requests.length === 0) return [];
return page.evaluate((values: AnchorRequest[]) => {
const root = document.querySelector("[data-composition-id]");
return values.map((request) => {
let element: Element | null = null;
try {
element = document.querySelector(request.selector);
} catch {
element = null;
}
element ??= root;
// Clones the anchor-extraction block in prepareContrast's evaluate() below;
// both run inside separate serialized browser closures and can't share a
// Node-side helper.
// fallow-ignore-next-line code-duplication
const dataAttributes: Record<string, string> = {};
for (const attribute of Array.from(element?.attributes ?? [])) {
if (attribute.name.startsWith("data-")) dataAttributes[attribute.name] = attribute.value;
}
const source = element
?.closest("[data-composition-file]")
?.getAttribute("data-composition-file");
return {
selector: element ? request.selector : "[data-composition-id]",
dataAttributes,
sourceFile: source || "index.html",
bbox: request.bbox,
time: request.time,
};
});
}, requests);
}
async function resolveRootAnchor(page: Page): Promise<CheckAnchor> {
const anchors = await resolveAnchors(page, [
{ selector: "[data-composition-id]", time: 0, bbox: await compositionBbox(page) },
]);
return anchors[0] ?? fallbackAnchor(undefined);
}
async function compositionBbox(page: Page): Promise<CheckBbox> {
return page.evaluate(() => {
const element = document.querySelector("[data-composition-id]");
const rect = element?.getBoundingClientRect();
return rect
? { x: rect.x, y: rect.y, width: rect.width, height: rect.height }
: { x: 0, y: 0, width: 0, height: 0 };
});
}
async function collectContrast(
page: Page,
time: number,
layoutAnnotations: CheckAnnotationBox[] = [],
): Promise<ContrastCapture> {
let prepared: PreparedContrast[] = [];
try {
prepared = parsePreparedContrast(await prepareContrast(page, time));
// This screenshot is the one contrast math is sampled from below — it must
// stay untouched by the annotation overlay (finishContrast reads real
// painted pixels), so annotation only ever happens on a SECOND shot.
const measurementShot = await page.screenshot({
encoding: "base64",
type: "png",
omitBackground: true,
});
if (typeof measurementShot !== "string") throw new Error("Contrast screenshot was not base64");
const raw = await finishContrast(
page,
measurementShot,
time,
prepared.map((entry) => entry.raw),
);
const finished = raw.flatMap(parseFinishedContrast);
const entries = joinContrastEntries(finished, prepared);
// __contrastAuditFinish restores the text paint hidden by prepare. The
// measurement screenshot intentionally has glyphs removed so contrast
// can sample the pixels behind them; never persist that image as visual
// QA evidence. Capture the restored page for the overview instead.
const overviewShot = await page.screenshot({ encoding: "base64", type: "png" });
if (typeof overviewShot !== "string") throw new Error("Overview screenshot was not base64");
// Contrast failures are only known once measurement above completes, so
// they're appended to the layout-derived annotations passed in by the
// pipeline rather than being requested up front.
const annotations = [
...layoutAnnotations,
...contrastFailureAnnotations(entries, layoutAnnotations.length),
];
const pngBase64 = await captureOverviewShot(page, annotations, overviewShot);
return { entries, pngBase64 };
} finally {
await page
.evaluate(() => {
const restore = Reflect.get(window, "__contrastAuditRestoreIfPending");
if (typeof restore === "function") Reflect.apply(restore, window, []);
})
.catch(() => undefined);
}
}
function contrastFailureAnnotations(
entries: ContrastAuditEntry[],
labelOffset: number,
): CheckAnnotationBox[] {
return entries
.filter((entry) => !entry.wcagAA)
.map((entry, index) => ({
label: `${labelOffset + index + 1} contrast_aa_failure`,
bbox: entry.bbox,
}));
}
const ANNOTATION_OVERLAY_ID = "__hyperframesCheckAnnotations";
/**
* `check --snapshots`'s overview-frame annotation: every audit for this
* sample time has already run (layout/geometry findings arrive via
* `annotations`; contrast failures were just measured above), so it's safe
* to draw labeled boxes and take one more shot without perturbing anything
* audits read. No-op (returns the plain screenshot) when there's nothing to
* annotate — the common case stays exactly as before this feature existed.
*/
export async function captureOverviewShot(
page: Page,
annotations: CheckAnnotationBox[],
fallbackScreenshot: string,
): Promise<string> {
if (annotations.length === 0) return fallbackScreenshot;
await injectAnnotationOverlay(page, annotations);
try {
const shot = await page.screenshot({ encoding: "base64", type: "png" });
return typeof shot === "string" ? shot : fallbackScreenshot;
} finally {
await removeAnnotationOverlay(page);
}
}
/** One small, self-contained DOM overlay: fixed-position labeled boxes over
* each finding's bbox. Injected immediately before the annotated overview
* shot and torn down immediately after (see `captureOverviewShot`), so it
* never leaks into any audit's DOM reads. */
async function injectAnnotationOverlay(page: Page, boxes: CheckAnnotationBox[]): Promise<void> {
await page.evaluate(
(items: CheckAnnotationBox[], overlayId: string) => {
const root = document.createElement("div");
root.id = overlayId;
root.style.cssText = "position:fixed;inset:0;z-index:2147483647;pointer-events:none;";
for (const item of items) {
const box = document.createElement("div");
box.style.cssText = `position:fixed;left:${item.bbox.x}px;top:${item.bbox.y}px;width:${item.bbox.width}px;height:${item.bbox.height}px;border:2px solid #ff2d55;box-sizing:border-box;`;
const label = document.createElement("div");
label.textContent = item.label;
label.style.cssText =
"position:absolute;top:-18px;left:0;background:#ff2d55;color:#fff;font:11px/16px monospace;padding:0 4px;white-space:nowrap;";
box.appendChild(label);
root.appendChild(box);
}
document.body.appendChild(root);
},
boxes,
ANNOTATION_OVERLAY_ID,
);
}
async function removeAnnotationOverlay(page: Page): Promise<void> {
await page.evaluate((overlayId: string) => {
document.getElementById(overlayId)?.remove();
}, ANNOTATION_OVERLAY_ID);
}
async function prepareContrast(page: Page, time: number): Promise<unknown[]> {
// Candidate-to-element provenance must be captured while the prepare restore list is live.
// fallow-ignore-next-line complexity
return page.evaluate((sampleTime: number) => {
const prepare = Reflect.get(window, "__contrastAuditPrepare");
const candidates = typeof prepare === "function" ? Reflect.apply(prepare, window, []) : [];
if (!Array.isArray(candidates)) return [];
const restores = Reflect.get(window, "__contrastAuditRestores");
const restoreList = Array.isArray(restores) ? restores : [];
const escape = (value: string) =>
typeof CSS !== "undefined" && typeof CSS.escape === "function"
? CSS.escape(value)
: value.replace(/[^a-zA-Z0-9_-]/g, "\\$&");
const selectorFor = (element: Element | null, fallback: string): string => {
if (!element) return fallback;
if (element.id) return `#${escape(element.id)}`;
const parts: string[] = [];
for (
let current: Element | null = element;
current && current !== document.body;
current = current.parentElement
) {
const tag = current.tagName.toLowerCase();
const siblings = current.parentElement
? Array.from(current.parentElement.children).filter(
(item) => item.tagName === current?.tagName,
)
: [];
parts.push(
siblings.length > 1 ? `${tag}:nth-of-type(${siblings.indexOf(current) + 1})` : tag,
);
}
return parts.reverse().join(" > ") || fallback;
};
// Part of the serialized evaluate body above; cannot delegate to Node helpers.
// fallow-ignore-next-line complexity
return candidates.map((candidate, index) => {
const restore = restoreList[index];
const candidateObject = typeof candidate === "object" && candidate !== null ? candidate : {};
const elementValue =
typeof restore === "object" && restore !== null ? Reflect.get(restore, "el") : null;
const element = elementValue instanceof Element ? elementValue : null;
const fallback = Reflect.get(candidateObject, "selector");
const selector = selectorFor(
element,
typeof fallback === "string" ? fallback : "[data-composition-id]",
);
const dataAttributes: Record<string, string> = {};
for (const attribute of Array.from(element?.attributes ?? [])) {
if (attribute.name.startsWith("data-")) dataAttributes[attribute.name] = attribute.value;
}
const source = element
?.closest("[data-composition-file]")
?.getAttribute("data-composition-file");
return {
candidate: { ...candidateObject, selector },
anchor: {
selector,
dataAttributes,
sourceFile: source || "index.html",
bbox: Reflect.get(candidateObject, "bbox"),
time: sampleTime,
},
};
});
}, time);
}
async function finishContrast(
page: Page,
screenshot: string,
time: number,
candidates: unknown[],
): Promise<unknown[]> {
return page.evaluate(
async (payload: { screenshot: string; time: number; candidates: unknown[] }) => {
const finish = Reflect.get(window, "__contrastAuditFinish");
if (typeof finish !== "function") return [];
const result = await Reflect.apply(finish, window, [
payload.screenshot,
payload.time,
payload.candidates,
]);
return Array.isArray(result) ? result : [];
},
{ screenshot, time, candidates },
);
}
function parsePreparedContrast(raw: unknown[]): PreparedContrast[] {
return raw.flatMap((value) => {
if (!isRecord(value)) return [];
const raw = Reflect.get(value, "candidate");
const candidate = parseContrastCandidate(raw);
const anchor = parseAnchor(Reflect.get(value, "anchor"));
return candidate && anchor ? [{ raw, candidate, anchor }] : [];
});
}
function parseContrastCandidate(value: unknown): ContrastCandidate | null {
if (!isRecord(value)) return null;
const selector = stringValue(value, "selector");
const text = stringValue(value, "text");
const fg = rgbaValue(Reflect.get(value, "fg"));
const large = booleanValue(value, "large");
const bbox = parseBbox(Reflect.get(value, "bbox"));
return selector && text !== null && fg && large !== null && bbox
? { selector, text, fg, large, bbox }
: null;
}
function parseFinishedContrast(value: unknown): FinishedContrast[] {
if (!isRecord(value)) return [];
const selector = stringValue(value, "selector");
const text = stringValue(value, "text");
const ratio = numberValue(value, "ratio");
const wcagAA = booleanValue(value, "wcagAA");
const large = booleanValue(value, "large");
const fg = stringValue(value, "fg");
const bg = stringValue(value, "bg");
return selector &&
text !== null &&
ratio !== null &&
wcagAA !== null &&
large !== null &&
fg &&
bg
? [{ selector, text, ratio, wcagAA, large, fg, bg }]
: [];
}
function joinContrastEntries(
finished: FinishedContrast[],
prepared: PreparedContrast[],
): ContrastAuditEntry[] {
const remaining = [...prepared];
return finished.flatMap((entry) => {
const index = remaining.findIndex(
(candidate) =>
candidate.candidate.selector === entry.selector && candidate.candidate.text === entry.text,
);
const match = index >= 0 ? remaining.splice(index, 1)[0] : undefined;
return match ? [{ ...entry, ...match.anchor }] : [];
});
}
function parseLayoutIssue(value: unknown): LayoutIssue[] {
if (!isRecord(value)) return [];
const code = layoutCodeValue(Reflect.get(value, "code"));
const severity = severityValue(Reflect.get(value, "severity"));
const time = numberValue(value, "time");
const selector = stringValue(value, "selector");
const message = stringValue(value, "message");
const rect = parseRect(Reflect.get(value, "rect"));
if (!code || !severity || time === null || !selector || !message || !rect) return [];
const issue: LayoutIssue = { code, severity, time, selector, message, rect };
assignOptionalLayoutFields(issue, value);
return [issue];
}
function parseGeometryCandidate(value: unknown, time: number): CheckGeometryCandidate[] {
if (!isRecord(value)) return [];
const rect = parseRect(Reflect.get(value, "rect"));
const elementRect = parseRect(Reflect.get(value, "elementRect"));
if (!rect || !elementRect) return [];
const identity = parseGeometryIdentity(value);
if (!identity) return [];
const anchor = parseGeometryAnchor(value, rect, time);
if (!anchor) return [];
const candidate: CheckGeometryCandidate = { ...identity, ...anchor, rect, elementRect };
const overflow = parseOverflow(Reflect.get(value, "overflow"));
if (overflow) candidate.overflow = overflow;
return [candidate];
}
function parseGeometryIdentity(
value: Record<string, unknown>,
): Pick<CheckGeometryCandidate, "kind" | "tag" | "text"> | null {
const kindValue = Reflect.get(value, "kind");
const kind = kindValue === "text" || kindValue === "media" ? kindValue : null;
if (!kind) return null;
const tag = stringValue(value, "tag");
if (!tag) return null;
const text = stringValue(value, "text");
return text === null ? null : { kind, tag, text };
}
function parseGeometryAnchor(
value: Record<string, unknown>,
rect: LayoutRect,
time: number,
): CheckAnchor | null {
const selector = stringValue(value, "selector");
if (!selector) return null;
const sourceFile = stringValue(value, "sourceFile");
if (!sourceFile) return null;
const dataAttributes = stringRecord(Reflect.get(value, "dataAttributes"));
return dataAttributes
? {
selector,
sourceFile,
dataAttributes,
bbox: rectToBbox(rect),
time,
}
: null;
}
function assignOptionalLayoutFields(issue: LayoutIssue, value: Record<string, unknown>): void {
assignOptionalString(issue, value, "containerSelector");
assignOptionalString(issue, value, "text");
assignOptionalString(issue, value, "fixHint");
const containerRect = parseRect(Reflect.get(value, "containerRect"));
if (containerRect) issue.containerRect = containerRect;
const overflow = parseOverflow(Reflect.get(value, "overflow"));
if (overflow) issue.overflow = overflow;
const coveredFraction = numberValue(value, "coveredFraction");
if (coveredFraction !== null) issue.coveredFraction = coveredFraction;
}
function recordField(value: unknown, key: string): Record<string, unknown> | null {
if (!isRecord(value)) return null;
const field = Reflect.get(value, key);
return isRecord(field) ? field : null;
}
function parseMotionFrame(
value: unknown,
time: number,
selectors: string[],
scopes: string[],
): MotionFrame {
const rawData = recordField(value, "data");
const rawLiveness = recordField(value, "liveness");
const data: MotionFrame["data"] = {};
for (const selector of selectors) {
data[selector] = rawData ? parseFrameSample(Reflect.get(rawData, selector)) : null;
}
const liveness: Record<string, string> = {};
for (const scope of scopes) {
const signature = rawLiveness ? Reflect.get(rawLiveness, scope) : "";
liveness[scope] = typeof signature === "string" ? signature : "";
}
return { time, data, liveness };
}
function parseFrameSample(value: unknown): MotionFrame["data"][string] {
if (!isRecord(value)) return null;
const rect = parseRect(Reflect.get(value, "rect"));
const opacity = numberValue(value, "opacity");
const visible = booleanValue(value, "visible");
return rect && opacity !== null && visible !== null ? { rect, opacity, visible } : null;
}
function parseAnchor(value: unknown): CheckAnchor | null {
if (!isRecord(value)) return null;
const selector = stringValue(value, "selector");
const sourceFile = stringValue(value, "sourceFile");
const time = numberValue(value, "time");
const bbox = parseBbox(Reflect.get(value, "bbox"));
const dataAttributes = stringRecord(Reflect.get(value, "dataAttributes"));
return selector && sourceFile && time !== null && bbox && dataAttributes
? { selector, sourceFile, time, bbox, dataAttributes }
: null;
}
function runtimeFinding(draft: RuntimeDraft, root: CheckAnchor): CheckFinding {
return {
code: draft.code,
severity: draft.severity,
message: draft.message,
selector: root.selector,
dataAttributes: root.dataAttributes,
sourceFile: root.sourceFile,
bbox: root.bbox,
time: draft.time,
url: draft.url,
line: draft.line,
};
}
function fallbackAnchor(request: AnchorRequest | undefined): CheckAnchor {
return {
selector: request?.selector ?? "[data-composition-id]",
dataAttributes: {},
sourceFile: "index.html",
bbox: request?.bbox ?? { x: 0, y: 0, width: 0, height: 0 },
time: request?.time ?? 0,
};
}
function parseBbox(value: unknown): CheckBbox | null {
if (!isRecord(value)) return null;
const x = numberValue(value, "x");
const y = numberValue(value, "y");
const width = numberValue(value, "width") ?? numberValue(value, "w");
const height = numberValue(value, "height") ?? numberValue(value, "h");
return x !== null && y !== null && width !== null && height !== null
? { x, y, width, height }
: null;
}
function parseRect(value: unknown): LayoutRect | null {
if (!isRecord(value)) return null;
const left = numberValue(value, "left");
const top = numberValue(value, "top");
const right = numberValue(value, "right");
const bottom = numberValue(value, "bottom");
const width = numberValue(value, "width");
const height = numberValue(value, "height");
return left !== null &&
top !== null &&
right !== null &&
bottom !== null &&
width !== null &&
height !== null
? { left, top, right, bottom, width, height }
: null;
}
function parseOverflow(value: unknown): LayoutIssue["overflow"] | null {
if (!isRecord(value)) return null;
const overflow: LayoutIssue["overflow"] = {};
for (const side of ["left", "right", "top", "bottom"] as const) {
const amount = numberValue(value, side);
if (amount !== null) overflow[side] = amount;
}
return Object.keys(overflow).length > 0 ? overflow : null;
}
const LAYOUT_ISSUE_CODES: readonly LayoutIssueCode[] = [
"text_box_overflow",
"clipped_text",
"canvas_overflow",
"container_overflow",
"content_overlap",
"text_occluded",
"text_not_painted",
"caption_zone_collision",
"frame_out_of_frame",
"escaped_container",
"panel_out_of_canvas",
"connector_detached",
"rotation_pivot_drift",
"off_pivot_rotation",
"motion_appears_late",
"motion_out_of_order",
"motion_off_frame",
"motion_frozen",
"motion_selector_missing",
"motion_selector_ambiguous",
];
function layoutCodeValue(value: unknown): LayoutIssueCode | null {
return LAYOUT_ISSUE_CODES.find((code) => code === value) ?? null;
}
function severityValue(value: unknown): CheckSeverity | null {
return value === "error" || value === "warning" || value === "info" ? value : null;
}
function rgbaValue(value: unknown): [number, number, number, number] | null {
if (!Array.isArray(value) || value.length < 4) return null;
const [red, green, blue, alpha] = value;
return [red, green, blue, alpha].every((channel) => typeof channel === "number")
? [red, green, blue, alpha]
: null;
}
function stringRecord(value: unknown): Record<string, string> | null {
if (!isRecord(value)) return null;
const record: Record<string, string> = {};
for (const key of Object.keys(value)) {
const entry = Reflect.get(value, key);
if (typeof entry !== "string") return null;
record[key] = entry;
}
return record;
}
function assignOptionalString(
issue: LayoutIssue,
source: Record<string, unknown>,
key: "containerSelector" | "text" | "fixHint",
): void {
const value = stringValue(source, key);
if (value !== null) issue[key] = value;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function stringValue(value: Record<string, unknown>, key: string): string | null {
const entry = Reflect.get(value, key);
return typeof entry === "string" ? entry : null;
}
function numberValue(value: Record<string, unknown>, key: string): number | null {
const entry = Reflect.get(value, key);
return typeof entry === "number" && Number.isFinite(entry) ? entry : null;
}
function booleanValue(value: Record<string, unknown>, key: string): boolean | null {
const entry = Reflect.get(value, key);
return typeof entry === "boolean" ? entry : null;
}
function urlPath(url: string): string {
try {
return decodeURIComponent(new URL(url).pathname).replace(/^\//, "");
} catch {
return url;
}
}