.png` crops |
+ | `--samples` / `--at` / `--at-transitions` | Control the seek grid (default 9 samples; `--at-transitions` adds tween boundaries) |
+ | `--tolerance` | Allowed overflow in px before reporting (default 2) |
+ | `--timeout` | Initial settle budget in ms (default 3000) |
+ | `--no-contrast` | Skip the WCAG audit while iterating |
+ | `--strict` | Exit non-zero on warnings too (default: only errors) |
+ | `--caption-zone ""` | Opt-in band gate: flags content whose center sits inside the fractional band (optional `severity`, `seek`) |
+ | `--frame-check` | Opt-in media out-of-frame detection (img/svg/video/canvas) |
+
+ Contrast failures are **errors** and include the sampled fg/bg colors, measured vs required ratio, and a suggested compliant color. Severity is persistence-aware: single-sample transients demote to info, held findings gate the exit code, and a frozen timeline on a 3s+ composition fails with `sweep_static`.
+
### `beats`
Detect the beats in a composition's music track and write them to a beat file the Studio uses to draw beat guides on the timeline:
@@ -549,6 +577,8 @@ Word-level transcripts (whisper output) are grouped into readable caption cues o
### `inspect`
+ Deprecated: use [`check`](#check) — it covers this layout sweep plus runtime, motion, and contrast in one browser session. `inspect` keeps working and marks `_meta.deprecated: true` in JSON output.
+
Inspect rendered visual layout across the composition timeline:
```bash
diff --git a/packages/cli/src/capture/captureCompositionFrame.test.ts b/packages/cli/src/capture/captureCompositionFrame.test.ts
index cf942565c9..c2e83396cd 100644
--- a/packages/cli/src/capture/captureCompositionFrame.test.ts
+++ b/packages/cli/src/capture/captureCompositionFrame.test.ts
@@ -1,13 +1,316 @@
-import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
-import { describe, expect, it } from "vitest";
-import { runFfmpegOnce } from "./captureCompositionFrame.js";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import {
+ captureRegionCrop,
+ clampCropRegion,
+ installPageFunctionGuard,
+ padCropRegion,
+ parseZoomTarget,
+ resolveCliChromeGpuMode,
+ resolveCropRegion,
+ runFfmpegOnce,
+ seekCompositionTimeline,
+ type CompositionSeekPage,
+} from "./captureCompositionFrame.js";
function tempDir(): string {
return mkdtempSync(join(tmpdir(), "hf-capture-frame-test-"));
}
+function fakeSeekPage() {
+ const evaluate = vi.fn(
+ async (
+ _pageFunction: Parameters[0],
+ _value?: number,
+ _fallbackToBridgeAndTimelines?: boolean,
+ ): Promise => undefined,
+ );
+ const waitForFunction = vi.fn(
+ async (_pageFunction: () => boolean, _options: { timeout: number }): Promise =>
+ undefined,
+ );
+ const page: CompositionSeekPage = { evaluate, waitForFunction };
+ return { page, evaluate, waitForFunction };
+}
+
+function runBrowserSeek(evaluate: ReturnType["evaluate"]): void {
+ const seekInBrowser = evaluate.mock.calls[0]?.[0];
+ if (typeof seekInBrowser !== "function") throw new Error("Expected a browser seek function");
+ Reflect.apply(seekInBrowser, undefined, evaluate.mock.calls[0]?.slice(1) ?? []);
+}
+
+afterEach(() => {
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+});
+
+describe("seekCompositionTimeline", () => {
+ it("keeps the existing raced double-frame settle as the default", async () => {
+ const { page, evaluate, waitForFunction } = fakeSeekPage();
+
+ await seekCompositionTimeline(page, 1.25);
+
+ expect(waitForFunction).not.toHaveBeenCalled();
+ expect(evaluate).toHaveBeenCalledTimes(2);
+ expect(evaluate).toHaveBeenNthCalledWith(1, expect.any(Function), 1.25, false);
+ expect(evaluate.mock.calls[1]?.[0]).toContain("window.setTimeout(finish, 100)");
+ });
+
+ it("prefers renderSeek so the runtime synchronizes clip visibility", async () => {
+ const { page, evaluate } = fakeSeekPage();
+ const renderSeek = vi.fn();
+ const bridgeSeek = vi.fn();
+ const playerSeek = vi.fn();
+ const timelineSeek = vi.fn();
+ vi.stubGlobal("window", {
+ __player: { renderSeek, seek: playerSeek },
+ __hf: { seek: bridgeSeek },
+ __timelines: { main: { seek: timelineSeek } },
+ });
+
+ await seekCompositionTimeline(page, 2.25);
+ runBrowserSeek(evaluate);
+
+ expect(renderSeek).toHaveBeenCalledWith(2.25);
+ expect(bridgeSeek).not.toHaveBeenCalled();
+ expect(playerSeek).not.toHaveBeenCalled();
+ expect(timelineSeek).not.toHaveBeenCalled();
+ });
+
+ function fakeBridgeOnlySeekPage() {
+ const { page, evaluate } = fakeSeekPage();
+ const bridgeSeek = vi.fn();
+ const tickerTick = vi.fn();
+ vi.stubGlobal("window", { __hf: { seek: bridgeSeek }, gsap: { ticker: { tick: tickerTick } } });
+ return { page, evaluate, bridgeSeek, tickerTick };
+ }
+
+ it("keeps bridge and raw fallbacks disabled for default capture callers", async () => {
+ const { page, evaluate, bridgeSeek, tickerTick } = fakeBridgeOnlySeekPage();
+
+ await seekCompositionTimeline(page, 2.5);
+ runBrowserSeek(evaluate);
+
+ expect(bridgeSeek).not.toHaveBeenCalled();
+ expect(tickerTick).not.toHaveBeenCalled();
+ });
+
+ it("opts into the bridge before player and raw timeline fallbacks", async () => {
+ const { page, evaluate, bridgeSeek, tickerTick } = fakeBridgeOnlySeekPage();
+
+ await seekCompositionTimeline(page, 2.5, { fallbackToBridgeAndTimelines: true });
+ runBrowserSeek(evaluate);
+
+ expect(bridgeSeek).toHaveBeenCalledWith(2.5);
+ expect(tickerTick).toHaveBeenCalledOnce();
+ });
+
+ it("opts into pausing and seeking raw timelines when no preferred target exists", async () => {
+ const { page, evaluate } = fakeSeekPage();
+ const pause = vi.fn();
+ const seek = vi.fn();
+ vi.stubGlobal("window", { __timelines: { main: { pause, seek } } });
+
+ await seekCompositionTimeline(page, 1.75, { fallbackToBridgeAndTimelines: true });
+ runBrowserSeek(evaluate);
+
+ expect(pause).toHaveBeenCalledOnce();
+ expect(seek).toHaveBeenCalledWith(1.75);
+ });
+
+ it("supports validate settling without adding an animation-frame or font wait", async () => {
+ vi.useFakeTimers();
+ const { page, evaluate, waitForFunction } = fakeSeekPage();
+
+ const pending = seekCompositionTimeline(page, 3, {
+ fallbackToBridgeAndTimelines: true,
+ waitForPreferredSeekTargetMs: 500,
+ animationFrameSettle: "none",
+ settleMs: 150,
+ });
+ await vi.advanceTimersByTimeAsync(150);
+ await pending;
+
+ expect(waitForFunction).toHaveBeenCalledWith(expect.any(Function), { timeout: 500 });
+ expect(evaluate).toHaveBeenCalledTimes(1);
+ expect(evaluate).toHaveBeenCalledWith(expect.any(Function), 3, true);
+ });
+
+ it("supports layout's ordered double-frame, bounded font, and sleep settles", async () => {
+ vi.useFakeTimers();
+ const { page, evaluate } = fakeSeekPage();
+
+ const pending = seekCompositionTimeline(page, 4, {
+ fallbackToBridgeAndTimelines: true,
+ animationFrameSettle: "double",
+ waitForFontsMs: 500,
+ settleMs: 120,
+ });
+ await vi.advanceTimersByTimeAsync(120);
+ await pending;
+
+ expect(evaluate).toHaveBeenCalledTimes(3);
+ expect(evaluate).toHaveBeenNthCalledWith(1, expect.any(Function), 4, true);
+ expect(evaluate).toHaveBeenNthCalledWith(2, expect.any(Function));
+ expect(evaluate).toHaveBeenNthCalledWith(3, expect.any(Function), 500);
+ });
+});
+
+describe("resolveCliChromeGpuMode", () => {
+ it("preserves validate's software-only opt-in mapping", () => {
+ expect(resolveCliChromeGpuMode("software")).toBe("software");
+ expect(resolveCliChromeGpuMode("hardware")).toBe("hardware");
+ expect(resolveCliChromeGpuMode("auto")).toBe("hardware");
+ expect(resolveCliChromeGpuMode("")).toBe("hardware");
+ });
+});
+
+describe("screenshot Chrome arguments", () => {
+ it("leaves shared capture and layout on the engine's software default", () => {
+ const defaultScreenshotArgs =
+ /args:\s*buildChromeArgs\(\s*\{[^}]*captureMode:\s*"screenshot"[^}]*\}\s*\),/;
+ const captureSource = readFileSync(
+ new URL("./captureCompositionFrame.ts", import.meta.url),
+ "utf8",
+ );
+ const layoutSource = readFileSync(new URL("../commands/layout.ts", import.meta.url), "utf8");
+
+ // openSettledCompositionPage threads the caller's optional browserGpuMode;
+ // callers that omit it (snapshot, compare) fall through to the engine's
+ // software default for screenshot capture.
+ expect(captureSource).toMatch(
+ /args:\s*buildChromeArgs\(\s*\{[^}]*captureMode:\s*"screenshot"[^}]*\},\s*\{\s*browserGpuMode:\s*options\.browserGpuMode\s*\},?\s*\),/,
+ );
+ expect(layoutSource).toMatch(defaultScreenshotArgs);
+ });
+});
+
+describe("parseZoomTarget", () => {
+ it("parses four comma-separated numbers as an exact region", () => {
+ expect(parseZoomTarget("100,50,400,300")).toEqual({
+ kind: "region",
+ region: { x: 100, y: 50, width: 400, height: 300 },
+ });
+ });
+
+ it("treats anything else as a CSS selector", () => {
+ expect(parseZoomTarget("#headline")).toEqual({ kind: "selector", selector: "#headline" });
+ expect(parseZoomTarget(".card:nth-of-type(2)")).toEqual({
+ kind: "selector",
+ selector: ".card:nth-of-type(2)",
+ });
+ });
+});
+
+describe("clampCropRegion / padCropRegion", () => {
+ it("clamps a region to the canvas bounds", () => {
+ expect(
+ clampCropRegion({ x: -10, y: -10, width: 50, height: 50 }, { width: 30, height: 30 }),
+ ).toEqual({
+ x: 0,
+ y: 0,
+ width: 30,
+ height: 30,
+ });
+ });
+
+ it("pads a region on every side when it fits within the canvas", () => {
+ expect(
+ padCropRegion({ x: 500, y: 500, width: 100, height: 40 }, { width: 1920, height: 1080 }, 24),
+ ).toEqual({ x: 476, y: 476, width: 148, height: 88 });
+ });
+
+ it("pads then clamps when padding would spill outside the canvas", () => {
+ expect(
+ padCropRegion({ x: 10, y: 10, width: 20, height: 20 }, { width: 200, height: 200 }, 24),
+ ).toEqual({ x: 0, y: 0, width: 54, height: 54 });
+ });
+});
+
+describe("resolveCropRegion", () => {
+ it("resolves a selector to its bbox, padded 24px and clamped", async () => {
+ const page = { evaluate: vi.fn(async () => ({ x: 500, y: 500, width: 100, height: 40 })) };
+
+ const region = await resolveCropRegion(
+ page,
+ { kind: "selector", selector: "#headline" },
+ { width: 1920, height: 1080 },
+ );
+
+ expect(region).toEqual({ x: 476, y: 476, width: 148, height: 88 });
+ });
+
+ it("crops a region exactly, without padding, when it already fits the canvas", async () => {
+ const page = { evaluate: vi.fn() };
+
+ const region = await resolveCropRegion(
+ page,
+ { kind: "region", region: { x: 100, y: 50, width: 400, height: 300 } },
+ { width: 1920, height: 1080 },
+ );
+
+ expect(region).toEqual({ x: 100, y: 50, width: 400, height: 300 });
+ expect(page.evaluate).not.toHaveBeenCalled();
+ });
+
+ it("throws a clear, loud error when the selector matches nothing", async () => {
+ const page = { evaluate: vi.fn(async () => null) };
+
+ await expect(
+ resolveCropRegion(
+ page,
+ { kind: "selector", selector: "#missing" },
+ { width: 640, height: 360 },
+ ),
+ ).rejects.toThrow("--zoom selector matched no element: #missing");
+ });
+
+ it("returns null when the clamped region is a sliver (element animated off-canvas)", async () => {
+ // Element slid past the right edge: raw bbox is large, but clamping the
+ // padded region to the canvas leaves ~1px — a useless crop.
+ const page = { evaluate: vi.fn(async () => ({ x: 2500, y: 400, width: 600, height: 250 })) };
+
+ const region = await resolveCropRegion(
+ page,
+ { kind: "selector", selector: "#gone-by-now" },
+ { width: 1920, height: 1080 },
+ );
+
+ expect(region).toBeNull();
+ });
+});
+
+describe("captureRegionCrop", () => {
+ it("raises deviceScaleFactor for the clip shot, then restores the original viewport", async () => {
+ const original = { width: 1920, height: 1080, deviceScaleFactor: 1 };
+ const setViewport = vi.fn(async () => undefined);
+ const screenshot = vi.fn(async () => new Uint8Array([1, 2, 3]));
+ const page = { viewport: () => original, setViewport, screenshot };
+ const region = { x: 10, y: 20, width: 100, height: 50 };
+
+ const buffer = await captureRegionCrop(page, region, 3);
+
+ expect(setViewport).toHaveBeenNthCalledWith(1, { ...original, deviceScaleFactor: 3 });
+ expect(screenshot).toHaveBeenCalledWith({ clip: region, type: "png" });
+ expect(setViewport).toHaveBeenNthCalledWith(2, original);
+ expect(buffer).toBeInstanceOf(Buffer);
+ expect(Array.from(buffer)).toEqual([1, 2, 3]);
+ });
+
+ it("honors an explicit scale other than the default", async () => {
+ const original = { width: 800, height: 600 };
+ const setViewport = vi.fn(async () => undefined);
+ const screenshot = vi.fn(async () => new Uint8Array());
+ const page = { viewport: () => original, setViewport, screenshot };
+
+ await captureRegionCrop(page, { x: 0, y: 0, width: 10, height: 10 }, 2);
+
+ expect(setViewport).toHaveBeenNthCalledWith(1, { ...original, deviceScaleFactor: 2 });
+ });
+});
+
describe("runFfmpegOnce", () => {
it("returns the process exit code and collected stderr", async () => {
const dir = tempDir();
@@ -37,3 +340,21 @@ describe("runFfmpegOnce", () => {
}
});
});
+
+describe("installPageFunctionGuard", () => {
+ it("defines the keepNames __name shim in the page before any script runs", async () => {
+ const evaluateOnNewDocument = vi.fn(async (_source: string) => undefined);
+
+ await installPageFunctionGuard({ evaluateOnNewDocument });
+
+ expect(evaluateOnNewDocument).toHaveBeenCalledOnce();
+ const source = evaluateOnNewDocument.mock.calls[0]?.[0] ?? "";
+ expect(source).toContain("self.__name");
+ // The shim must be a no-op passthrough so wrapped functions stay callable.
+ const shim = new Function(`const self = {}; ${source}; return self.__name;`)() as (
+ fn: unknown,
+ ) => unknown;
+ const marker = () => 42;
+ expect(shim(marker)).toBe(marker);
+ });
+});
diff --git a/packages/cli/src/capture/captureCompositionFrame.ts b/packages/cli/src/capture/captureCompositionFrame.ts
index fbc15331a5..2b0d164094 100644
--- a/packages/cli/src/capture/captureCompositionFrame.ts
+++ b/packages/cli/src/capture/captureCompositionFrame.ts
@@ -3,17 +3,44 @@ import type { Browser, Page } from "puppeteer-core";
import { c } from "../ui/colors.js";
import { resolveCompositionViewportFromHtml } from "../utils/compositionViewport.js";
-const CHROME_LAUNCH_ARGS = [
- "--no-sandbox",
- "--disable-gpu",
- "--disable-dev-shm-usage",
- "--enable-webgl",
- "--use-gl=angle",
- "--use-angle=swiftshader",
-];
-
const SHADER_TRANSITIONS_TIMEOUT_MS = 90_000;
const CAPTURE_SETTLE_MS = 1500;
+const PREFERRED_SEEK_TARGET_WAIT_MS = 500;
+
+// The audit-grade seek tuning shared by check and the deprecated inspect/layout:
+// bridge+timeline fallback, ordered double-rAF settle, bounded font wait, sleep.
+export const AUDIT_SEEK_OPTIONS = {
+ fallbackToBridgeAndTimelines: true,
+ animationFrameSettle: "double",
+ waitForFontsMs: 500,
+ settleMs: 120,
+} as const;
+
+export interface SeekCompositionTimelineOptions {
+ fallbackToBridgeAndTimelines?: boolean;
+ waitForPreferredSeekTargetMs?: number;
+ animationFrameSettle?: "race" | "double" | "none";
+ waitForFontsMs?: number;
+ settleMs?: number;
+}
+
+type CompositionPageFunction =
+ | string
+ | (() => unknown)
+ | ((value: number) => unknown)
+ | ((value: number, fallbackToBridgeAndTimelines: boolean) => unknown);
+
+export interface CompositionEvaluationPage {
+ evaluate(
+ pageFunction: CompositionPageFunction,
+ value?: number,
+ fallbackToBridgeAndTimelines?: boolean,
+ ): Promise;
+}
+
+export interface CompositionSeekPage extends CompositionEvaluationPage {
+ waitForFunction?(pageFunction: () => boolean, options: { timeout: number }): Promise;
+}
export interface SettledCompositionPage {
browser: Browser;
@@ -26,6 +53,12 @@ export interface SettledCompositionPage {
export interface OpenSettledCompositionPageOptions {
renderReadyTimeoutMs: number;
renderReadyWarningSuffix: string;
+ // Screenshot paths take the engine's software-GPU default; validate/check
+ // thread the PRODUCER_BROWSER_GPU_MODE opt-in through here.
+ browserGpuMode?: "software" | "hardware";
+ // Runs after the page exists but before page.goto, so console/pageerror/
+ // request listeners can attach without missing load-time events.
+ beforeNavigate?: (page: Page) => void | Promise;
}
export interface FfmpegRunResult {
@@ -34,6 +67,12 @@ export interface FfmpegRunResult {
timedOut: boolean;
}
+export function resolveCliChromeGpuMode(
+ envMode = process.env.PRODUCER_BROWSER_GPU_MODE,
+): "software" | "hardware" {
+ return envMode === "software" ? "software" : "hardware";
+}
+
function compositionRuntimeReadyInBrowser(): boolean {
return Boolean(Reflect.get(window, "__renderReady"));
}
@@ -92,25 +131,45 @@ async function waitForCompositionSettle(
return runtimeReady;
}
+// tsx/esbuild-style dev transpilers run with keepNames, which rewrites named
+// inner functions in serialized page closures into __name(...) calls — a
+// helper that exists in the Node bundle but not in the browser realm, so any
+// page.evaluate/waitForFunction whose closure defines a named function throws
+// "__name is not defined" when the CLI runs from source. Defining a no-op in
+// the page before any script runs immunizes every serialized closure, current
+// and future, regardless of how the CLI was built.
+export async function installPageFunctionGuard(page: {
+ evaluateOnNewDocument(source: string): Promise;
+}): Promise {
+ await page.evaluateOnNewDocument("self.__name = self.__name || ((fn) => fn);");
+}
+
export async function openSettledCompositionPage(
html: string,
url: string,
options: OpenSettledCompositionPageOptions,
): Promise {
+ const viewport = resolveCompositionViewportFromHtml(html);
const { ensureBrowser } = await import("../browser/manager.js");
const browser = await ensureBrowser();
const puppeteer = await import("puppeteer-core");
+ const { buildChromeArgs } = await import("@hyperframes/engine");
let chromeBrowser: Browser | undefined;
try {
chromeBrowser = await puppeteer.default.launch({
headless: true,
executablePath: browser.executablePath,
- args: CHROME_LAUNCH_ARGS,
+ args: buildChromeArgs(
+ { ...viewport, captureMode: "screenshot" },
+ { browserGpuMode: options.browserGpuMode },
+ ),
});
const page = await chromeBrowser.newPage();
- await page.setViewport(resolveCompositionViewportFromHtml(html));
+ await installPageFunctionGuard(page);
+ await page.setViewport(viewport);
+ await options.beforeNavigate?.(page);
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 10000 });
const renderReadyTimedOut = !(await waitForCompositionSettle(page, options));
return { browser: chromeBrowser, page, renderReadyTimedOut };
@@ -121,29 +180,271 @@ export async function openSettledCompositionPage(
}
export async function seekCompositionTimeline(
- page: Pick,
+ page: CompositionSeekPage,
timeSeconds: number,
+ options: SeekCompositionTimelineOptions = {},
): Promise {
- await page.evaluate((t: number) => {
- const player = (window as any).__player;
- if (!player) return;
- const safe = Math.max(0, Number(t) || 0);
- if (typeof player.renderSeek === "function") {
- player.renderSeek(safe);
- } else if (typeof player.seek === "function") {
- player.seek(safe);
- }
- if ((window as any).gsap?.ticker?.tick) {
- (window as any).gsap.ticker.tick();
- }
- }, timeSeconds);
-
- await page.evaluate(`new Promise(function(r) {
- var settled = false;
- function finish() { if (settled) return; settled = true; r(); }
- window.setTimeout(finish, 100);
- requestAnimationFrame(function() { requestAnimationFrame(finish); });
- })`);
+ if (options.waitForPreferredSeekTargetMs !== undefined) {
+ await waitForPreferredSeekTarget(page, options.waitForPreferredSeekTargetMs);
+ }
+
+ await page.evaluate(
+ // Serialized into the page; the seek-target cascade must stay one function.
+ // fallow-ignore-next-line complexity
+ (t: number, fallbackToBridgeAndTimelines: boolean) => {
+ const getProperty = (target: unknown, key: string): unknown => {
+ if ((typeof target !== "object" || target === null) && typeof target !== "function") {
+ return undefined;
+ }
+ return Reflect.get(target, key);
+ };
+ const call = (fn: unknown, receiver: unknown, args: unknown[]): boolean => {
+ if (typeof fn !== "function") return false;
+ Reflect.apply(fn, receiver, args);
+ return true;
+ };
+
+ const player = Reflect.get(window, "__player");
+ if (!player && !fallbackToBridgeAndTimelines) return;
+
+ const safe = Math.max(0, Number(t) || 0);
+ const renderSeek = getProperty(player, "renderSeek");
+ const playerSeek = getProperty(player, "seek");
+ const hf = Reflect.get(window, "__hf");
+ const bridgeSeek = getProperty(hf, "seek");
+
+ // Prefer renderSeek because it also runs the runtime's data-start/data-duration
+ // visibility sync; raw timeline seeks leave off-window clips visible to audits.
+ if (call(renderSeek, player, [safe])) {
+ // Preferred runtime target handled the seek.
+ } else if (fallbackToBridgeAndTimelines && call(bridgeSeek, hf, [safe])) {
+ // Producer bridge handled the seek.
+ } else if (call(playerSeek, player, [safe])) {
+ // Legacy player target handled the seek.
+ } else if (fallbackToBridgeAndTimelines) {
+ const timelines = Reflect.get(window, "__timelines");
+ if (typeof timelines === "object" && timelines !== null) {
+ for (const key of Object.keys(timelines)) {
+ const timeline = Reflect.get(timelines, key);
+ call(getProperty(timeline, "pause"), timeline, []);
+ call(getProperty(timeline, "seek"), timeline, [safe]);
+ }
+ }
+ }
+
+ const gsap = Reflect.get(window, "gsap");
+ const ticker = getProperty(gsap, "ticker");
+ call(getProperty(ticker, "tick"), ticker, []);
+ },
+ timeSeconds,
+ options.fallbackToBridgeAndTimelines === true,
+ );
+
+ const animationFrameSettle = options.animationFrameSettle ?? "race";
+ if (animationFrameSettle === "race") {
+ await page.evaluate(`new Promise(function(r) {
+ var settled = false;
+ function finish() { if (settled) return; settled = true; r(); }
+ window.setTimeout(finish, 100);
+ requestAnimationFrame(function() { requestAnimationFrame(finish); });
+ })`);
+ } else if (animationFrameSettle === "double") {
+ await page.evaluate(
+ () =>
+ new Promise((resolveFrame) =>
+ requestAnimationFrame(() => requestAnimationFrame(() => resolveFrame())),
+ ),
+ );
+ }
+
+ if (options.waitForFontsMs !== undefined) {
+ await waitForCompositionFonts(page, options.waitForFontsMs);
+ }
+ if (options.settleMs !== undefined) {
+ const settleMs = Math.max(0, options.settleMs);
+ await new Promise((resolveSettle) => setTimeout(resolveSettle, settleMs));
+ }
+}
+
+export async function waitForPreferredSeekTarget(
+ page: Pick,
+ timeoutMs = PREFERRED_SEEK_TARGET_WAIT_MS,
+): Promise {
+ if (!page.waitForFunction) return;
+ try {
+ await page.waitForFunction(
+ () => {
+ const player = Reflect.get(window, "__player");
+ const hf = Reflect.get(window, "__hf");
+ const renderSeek =
+ typeof player === "object" && player !== null
+ ? Reflect.get(player, "renderSeek")
+ : undefined;
+ const bridgeSeek =
+ typeof hf === "object" && hf !== null ? Reflect.get(hf, "seek") : undefined;
+ return typeof renderSeek === "function" || typeof bridgeSeek === "function";
+ },
+ { timeout: timeoutMs },
+ );
+ } catch {
+ // Legacy/static pages may only expose raw timelines; keep that fallback available.
+ }
+}
+
+export async function waitForCompositionFonts(
+ page: CompositionEvaluationPage,
+ timeoutMs: number,
+): Promise {
+ await page
+ .evaluate((ms: number) => {
+ const fonts = Reflect.get(document, "fonts");
+ if (typeof fonts !== "object" || fonts === null) return Promise.resolve();
+ const ready = Reflect.get(fonts, "ready");
+ if (!ready) return Promise.resolve();
+ return Promise.race([
+ Promise.resolve(ready).then(() => undefined),
+ new Promise((resolve) => setTimeout(resolve, ms)),
+ ]);
+ }, timeoutMs)
+ .catch(() => {});
+}
+
+export interface CropRegion {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+}
+
+export interface CropCanvas {
+ width: number;
+ height: number;
+}
+
+export type ZoomTarget =
+ | { kind: "selector"; selector: string }
+ | { kind: "region"; region: CropRegion };
+
+// Four bare comma-separated numbers is unambiguous — no valid CSS selector
+// parses as that shape — so it always means "exact pixel region".
+const ZOOM_REGION_PATTERN = /^-?\d+(?:\.\d+)?(?:,-?\d+(?:\.\d+)?){3}$/;
+export const DEFAULT_ZOOM_PADDING_PX = 24;
+// One knob for every zoom/crop consumer (snapshot --zoom-scale default, check's
+// finding crops): density of the captured pixels relative to CSS pixels.
+export const DEFAULT_ZOOM_SCALE = 3;
+
+/** Parse `snapshot --zoom` into either a CSS selector or an exact pixel region "x,y,w,h". */
+export function parseZoomTarget(value: string): ZoomTarget {
+ const trimmed = value.trim();
+ if (ZOOM_REGION_PATTERN.test(trimmed)) {
+ const [x, y, width, height] = trimmed.split(",").map(Number) as [
+ number,
+ number,
+ number,
+ number,
+ ];
+ return { kind: "region", region: { x, y, width, height } };
+ }
+ return { kind: "selector", selector: trimmed };
+}
+
+/** Clamp a region to the canvas bounds — Puppeteer's clip screenshot rejects a
+ * region that spills outside the viewport. Keeps at least 1px on each side. */
+export function clampCropRegion(region: CropRegion, canvas: CropCanvas): CropRegion {
+ const x = Math.max(0, Math.min(region.x, canvas.width));
+ const y = Math.max(0, Math.min(region.y, canvas.height));
+ const x2 = Math.max(x + 1, Math.min(region.x + region.width, canvas.width));
+ const y2 = Math.max(y + 1, Math.min(region.y + region.height, canvas.height));
+ return { x, y, width: x2 - x, height: y2 - y };
+}
+
+/** Pad a region on every side (context around a zoomed element), then clamp. */
+export function padCropRegion(
+ region: CropRegion,
+ canvas: CropCanvas,
+ paddingPx: number,
+): CropRegion {
+ return clampCropRegion(
+ {
+ x: region.x - paddingPx,
+ y: region.y - paddingPx,
+ width: region.width + paddingPx * 2,
+ height: region.height + paddingPx * 2,
+ },
+ canvas,
+ );
+}
+
+export interface ZoomSelectorPage {
+ evaluate(
+ pageFunction: (selector: string) => CropRegion | null,
+ selector: string,
+ ): Promise;
+}
+
+/**
+ * Resolve a `--zoom` target to a concrete crop region. A selector resolves to
+ * its live bbox (padded ~24px, then clamped); an explicit region is used
+ * as-is (clamped only, never padded — region form crops exactly). A selector
+ * matching nothing throws: a loud error beats a silent full-frame fallback.
+ */
+// A selector can match an element whose visible box is gone at the sampled
+// time — collapsed (display:none mid-timeline) or animated off-canvas, where
+// clamping leaves a pixel-wide remnant. Either way the crop would be a sliver
+// that tells an agent nothing, so the final clamped region is what's guarded
+// and callers skip the frame on null. Explicit x,y,w,h regions stay literal.
+const MIN_CROP_REGION_PX = 8;
+
+export async function resolveCropRegion(
+ page: ZoomSelectorPage,
+ target: ZoomTarget,
+ canvas: CropCanvas,
+ paddingPx = DEFAULT_ZOOM_PADDING_PX,
+): Promise {
+ if (target.kind === "region") return clampCropRegion(target.region, canvas);
+ const bbox = await page.evaluate((selector) => {
+ const element = document.querySelector(selector);
+ if (!element) return null;
+ const rect = element.getBoundingClientRect();
+ return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
+ }, target.selector);
+ if (!bbox) throw new Error(`--zoom selector matched no element: ${target.selector}`);
+ const region = padCropRegion(bbox, canvas, paddingPx);
+ if (region.width < MIN_CROP_REGION_PX || region.height < MIN_CROP_REGION_PX) return null;
+ return region;
+}
+
+export interface CropCapturePage {
+ viewport(): { width: number; height: number; deviceScaleFactor?: number } | null;
+ setViewport(viewport: {
+ width: number;
+ height: number;
+ deviceScaleFactor?: number;
+ }): Promise;
+ screenshot(options: { clip: CropRegion; type: "png" }): Promise;
+}
+
+/**
+ * Capture a high-density crop of `region`: raise `deviceScaleFactor` to
+ * `scale`, take a clip screenshot, then restore the original viewport.
+ * Deliberately NOT CSS zoom or a viewport resize — DSF only changes how
+ * densely Chrome rasterizes the existing CSS-pixel layout, so the
+ * composition's layout (and its render determinism) is untouched. The PNG
+ * comes out at `region.width * scale` real device pixels, not an upscale.
+ */
+export async function captureRegionCrop(
+ page: CropCapturePage,
+ region: CropRegion,
+ scale: number,
+): Promise {
+ const original = page.viewport();
+ if (original) await page.setViewport({ ...original, deviceScaleFactor: scale });
+ try {
+ const shot = await page.screenshot({ clip: region, type: "png" });
+ return Buffer.isBuffer(shot) ? shot : Buffer.from(shot);
+ } finally {
+ if (original) await page.setViewport(original);
+ }
}
export async function runFfmpegOnce(
diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts
index b98ad6d445..69c7281d74 100644
--- a/packages/cli/src/cli.ts
+++ b/packages/cli/src/cli.ts
@@ -101,6 +101,7 @@ try {
import { defineCommand, runMain } from "citty";
import type { ArgsDef, CommandDef } from "citty";
+import { getRunId } from "./telemetry/runId.js";
import { reportCommandFailure, trackCommandFailures } from "./utils/command-failure-tracking.js";
const isHelp = process.argv.includes("--help") || process.argv.includes("-h");
@@ -119,6 +120,7 @@ const commandLoaders = {
publish: () => import("./commands/publish.js").then((m) => m.default),
render: () => import("./commands/render.js").then((m) => m.default),
lint: () => import("./commands/lint.js").then((m) => m.default),
+ check: () => import("./commands/check.js").then((m) => m.default),
beats: () => import("./commands/beats.js").then((m) => m.default),
inspect: () => import("./commands/inspect.js").then((m) => m.default),
keyframes: () => import("./commands/keyframes.js").then((m) => m.default),
@@ -195,7 +197,13 @@ let _trackCliError:
}) => void)
| undefined;
let _trackCommandResult:
- | ((props: { command: string; success: boolean; exitCode: number; durationMs: number }) => void)
+ | ((props: {
+ command: string;
+ success: boolean;
+ exitCode: number;
+ durationMs: number;
+ runId?: string;
+ }) => void)
| undefined;
let _printUpdateNotice: (() => void) | undefined;
let _printSkillsUpdateNotice: (() => void) | undefined;
@@ -210,7 +218,7 @@ if (!isHelp && command !== "telemetry" && command !== "events" && command !== "u
_trackCliError = mod.trackCliError;
_trackCommandResult = mod.trackCommandResult;
mod.showTelemetryNotice();
- mod.trackCommand(command);
+ mod.trackCommand(command, runId);
if (mod.shouldTrack()) mod.incrementCommandCount();
});
}
@@ -241,6 +249,7 @@ if (!isHelp && !hasJsonFlag && command !== "upgrade" && command !== "events") {
}
const commandStart = Date.now();
+const runId = getRunId();
// Async flush for normal exit. `beforeExit` re-fires every time the
// event loop drains, and the async `_flush()` itself schedules new
@@ -264,6 +273,7 @@ process.on("exit", (code) => {
success: code === 0 && !commandFailed,
exitCode: code,
durationMs: Date.now() - commandStart,
+ runId,
});
_flushSync?.();
});
diff --git a/packages/cli/src/commands/check.test.ts b/packages/cli/src/commands/check.test.ts
new file mode 100644
index 0000000000..06af01eb14
--- /dev/null
+++ b/packages/cli/src/commands/check.test.ts
@@ -0,0 +1,1331 @@
+import { runCommand } from "citty";
+import { readFileSync } from "node:fs";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+const trackCheckReport = vi.fn();
+vi.mock("../telemetry/events.js", () => ({
+ trackCheckReport: (...args: unknown[]) => trackCheckReport(...args),
+ trackCommandFailure: vi.fn(),
+}));
+
+import { contrastRatio, parseColorRGBA } from "./contrast-bg.js";
+import { createCheckCommand } from "./check.js";
+import {
+ DEFAULT_CHECK_OPTIONS,
+ checkExitCode,
+ findingCropFilename,
+ runAuditGrid,
+ runCheckPipeline,
+ selectContrastTimes,
+ selectFindingCropRequests,
+ type AnchoredLayoutIssue,
+ type CheckAnchor,
+ type CheckAuditDriver,
+ type CheckBrowserResult,
+ type CheckDependencies,
+ type CheckFinding,
+ type CheckFindingCropRequest,
+ type CheckOptions,
+ type CheckReport,
+ type ContrastAuditEntry,
+ type MotionSpecResolution,
+} from "../utils/checkPipeline.js";
+import { resolveCompositionViewportFromHtml } from "../utils/compositionViewport.js";
+import type { ProjectLintResult } from "../utils/lintProject.js";
+import type {
+ LayoutIssue,
+ LayoutIssueCode,
+ LayoutOverflow,
+ LayoutRect,
+} from "../utils/layoutAudit.js";
+import type { ProjectDir } from "../utils/project.js";
+
+const PROJECT: ProjectDir = {
+ dir: "/project",
+ name: "project",
+ indexPath: "/project/index.html",
+};
+const PNG_BASE64 = Buffer.from("png-bytes").toString("base64");
+const ORIGINAL_EXIT_CODE = process.exitCode;
+
+afterEach(() => {
+ process.exitCode = ORIGINAL_EXIT_CODE;
+ trackCheckReport.mockClear();
+ vi.restoreAllMocks();
+});
+
+function cleanLint(): ProjectLintResult {
+ return {
+ results: [
+ {
+ file: "index.html",
+ result: {
+ ok: true,
+ errorCount: 0,
+ warningCount: 0,
+ infoCount: 0,
+ findings: [],
+ },
+ },
+ ],
+ totalErrors: 0,
+ totalWarnings: 0,
+ totalInfos: 0,
+ };
+}
+
+function lintWith(
+ severity: "error" | "warning" | "info",
+ code: string,
+ message: string,
+): ProjectLintResult {
+ return {
+ results: [
+ {
+ file: "index.html",
+ result: {
+ ok: severity !== "error",
+ errorCount: severity === "error" ? 1 : 0,
+ warningCount: severity === "warning" ? 1 : 0,
+ infoCount: severity === "info" ? 1 : 0,
+ findings: [{ severity, code, message }],
+ },
+ },
+ ],
+ totalErrors: severity === "error" ? 1 : 0,
+ totalWarnings: severity === "warning" ? 1 : 0,
+ totalInfos: severity === "info" ? 1 : 0,
+ };
+}
+
+function anchor(selector: string, time: number): CheckAnchor {
+ return {
+ selector,
+ dataAttributes: { "data-layout-name": "hero" },
+ sourceFile: "compositions/scene.html",
+ bbox: { x: 10, y: 20, width: 300, height: 80 },
+ time,
+ };
+}
+
+function layoutIssue(
+ severity: "error" | "warning" | "info" = "error",
+ overrides: { time?: number; code?: AnchoredLayoutIssue["code"] } = {},
+): AnchoredLayoutIssue {
+ const time = overrides.time ?? 0.5;
+ return {
+ ...anchor("#hero", time),
+ code: overrides.code ?? (severity === "warning" ? "content_overlap" : "clipped_text"),
+ severity,
+ text: "Hero",
+ message: severity === "warning" ? "Text may overlap." : "Text is clipped.",
+ rect: { left: 10, top: 20, right: 310, bottom: 100, width: 300, height: 80 },
+ };
+}
+
+function contrastEntry(overrides: Partial = {}): ContrastAuditEntry {
+ return {
+ ...anchor("#hero", 0.5),
+ text: "Body text",
+ ratio: 2.5,
+ wcagAA: false,
+ large: false,
+ fg: "rgb(110,110,110)",
+ bg: "rgb(30,30,30)",
+ ...overrides,
+ };
+}
+
+function fakeDriver(overrides: Partial = {}): CheckAuditDriver {
+ // A distinct string per call so the frozen-sweep guard (#U10) never fires
+ // by accident in unrelated scenarios — tests that want it force a constant
+ // via `collectLayoutGeometry: vi.fn(async () => "same")`.
+ let geometryCallCount = 0;
+ return {
+ initialize: vi.fn(async (_contrast: boolean) => undefined),
+ getDuration: vi.fn(async () => 9),
+ getTransitionBoundaries: vi.fn(async () => []),
+ getCanvas: vi.fn(async () => ({ width: 1920, height: 1080 })),
+ findAmbiguousSelectors: vi.fn(async (_selectors: string[]) => []),
+ seek: vi.fn(async (_time: number) => undefined),
+ collectLayout: vi.fn(async (_time: number, _tolerance: number) => []),
+ collectLayoutGeometry: vi.fn(async () => `geometry-${geometryCallCount++}`),
+ collectGeometryCandidates: vi.fn(async () => []),
+ collectMotionFrame: vi.fn(async (time: number) => ({ time, data: {}, liveness: {} })),
+ anchorMotionIssues: vi.fn(async (issues: LayoutIssue[]) =>
+ issues.map((issue) => ({
+ ...issue,
+ ...anchor(issue.selector, issue.time),
+ })),
+ ),
+ collectContrast: vi.fn(async (_time: number) => ({ entries: [], pngBase64: PNG_BASE64 })),
+ ...overrides,
+ };
+}
+
+interface GeometryFixture {
+ kind: "text" | "media";
+ tag: string;
+ text: string;
+ selector: string;
+ rect: LayoutRect;
+ elementRect?: LayoutRect;
+ time: number;
+ overflow?: LayoutOverflow;
+}
+
+function geometryCandidate(fixture: GeometryFixture) {
+ return {
+ ...anchor(fixture.selector, fixture.time),
+ kind: fixture.kind,
+ tag: fixture.tag,
+ text: fixture.text,
+ rect: fixture.rect,
+ elementRect: fixture.elementRect ?? fixture.rect,
+ bbox: {
+ x: fixture.rect.left,
+ y: fixture.rect.top,
+ width: fixture.rect.width,
+ height: fixture.rect.height,
+ },
+ overflow: fixture.overflow,
+ };
+}
+
+function fixtureRect(left: number, top: number, width: number, height: number): LayoutRect {
+ return { left, top, right: left + width, bottom: top + height, width, height };
+}
+
+function checkBrowserSource(): string {
+ return readFileSync(new URL("../utils/checkBrowser.ts", import.meta.url), "utf8");
+}
+
+async function gateCandidates(
+ time: number,
+ request: { text: boolean; media: boolean; tolerance: number },
+) {
+ const candidates = [];
+ if (request.text) {
+ candidates.push(
+ geometryCandidate({
+ kind: "text",
+ tag: "h2",
+ text: "Repeated heading",
+ selector: time === 2 ? "#first-heading" : "#later-heading",
+ rect: fixtureRect(800, 880, 320, 60),
+ time,
+ }),
+ );
+ }
+ if (request.media) {
+ candidates.push(
+ geometryCandidate({
+ kind: "media",
+ tag: "video",
+ text: "video",
+ selector: "#midpoint-video",
+ rect: fixtureRect(-140, 100, 100, 100),
+ overflow: { left: 140 },
+ time,
+ }),
+ );
+ }
+ return candidates;
+}
+
+function noMotion(): MotionSpecResolution {
+ return { kind: "none" };
+}
+
+function heroMotionFrame(time: number, visibleAt: (time: number) => boolean) {
+ return {
+ time,
+ data: {
+ "#hero": {
+ rect: { left: 10, top: 20, right: 310, bottom: 100, width: 300, height: 80 },
+ opacity: visibleAt(time) ? 1 : 0,
+ visible: visibleAt(time),
+ },
+ },
+ liveness: {},
+ };
+}
+
+function dependencies(
+ driver: CheckAuditDriver,
+ options: {
+ lint?: ProjectLintResult;
+ motion?: MotionSpecResolution;
+ runtime?: CheckFinding[];
+ writeSnapshot?: CheckDependencies["writeSnapshot"];
+ captureFindingCrops?: CheckDependencies["captureFindingCrops"];
+ } = {},
+): { deps: CheckDependencies; runBrowserCheck: ReturnType } {
+ const runBrowserCheck = vi.fn(
+ async (
+ _project: ProjectDir,
+ checkOptions: CheckOptions,
+ motion: MotionSpecResolution,
+ ): Promise => {
+ const result = await runAuditGrid(driver, checkOptions, motion);
+ return { ...result, runtimeFindings: options.runtime ?? [] };
+ },
+ );
+ const deps: CheckDependencies = {
+ lintProject: vi.fn(async () => options.lint ?? cleanLint()),
+ resolveMotionSpec: vi.fn(() => options.motion ?? noMotion()),
+ runBrowserCheck,
+ writeSnapshot:
+ options.writeSnapshot ??
+ vi.fn((_projectDir: string, index: number, time: number, _pngBase64: string) =>
+ Promise.resolve(
+ `snapshots/frame-${String(index).padStart(2, "0")}-at-${time.toFixed(1)}s.png`,
+ ),
+ ),
+ captureFindingCrops: options.captureFindingCrops ?? vi.fn(async () => []),
+ };
+ return { deps, runBrowserCheck };
+}
+
+async function runScenario(
+ driver: CheckAuditDriver,
+ optionOverrides: Partial = {},
+ dependencyOverrides: Parameters[1] = {},
+): Promise<{ report: CheckReport; deps: CheckDependencies; browser: ReturnType }> {
+ const { deps, runBrowserCheck } = dependencies(driver, dependencyOverrides);
+ const report = await runCheckPipeline(
+ PROJECT,
+ { ...DEFAULT_CHECK_OPTIONS, ...optionOverrides },
+ deps,
+ );
+ return { report, deps, browser: runBrowserCheck };
+}
+
+function runtimeError(): CheckFinding {
+ return {
+ code: "console_error",
+ severity: "error",
+ message: "boom",
+ ...anchor("[data-composition-id]", 0),
+ };
+}
+
+describe("contrast sample selection", () => {
+ it("chooses five evenly distributed grid points including both ends", () => {
+ expect(selectContrastTimes([0.5, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5, 8.5])).toEqual([
+ 0.5, 2.5, 4.5, 6.5, 8.5,
+ ]);
+ expect(selectContrastTimes([1, 2, 3])).toEqual([1, 2, 3]);
+ });
+});
+
+it("parses the caption-zone grammar and enables the frame gate", async () => {
+ const { report } = await runScenario(fakeDriver());
+ const runPipeline = vi.fn(async (_project: ProjectDir, _options: CheckOptions) => report);
+ vi.spyOn(console, "log").mockImplementation(() => undefined);
+ const command = createCheckCommand({
+ resolveProject: () => PROJECT,
+ runPipeline,
+ withMeta: (value) => value,
+ });
+
+ await runCommand(command, {
+ rawArgs: [
+ "--json",
+ "--caption-zone",
+ "x0=0;y0=.82;x1=1;y1=1;severity=error;seek=.25,1",
+ "--frame-check",
+ ],
+ });
+
+ expect(runPipeline).toHaveBeenCalledWith(
+ PROJECT,
+ expect.objectContaining({
+ captionZone: {
+ x0: 0,
+ y0: 0.82,
+ x1: 1,
+ y1: 1,
+ severity: "error",
+ seek: [0.25, 1],
+ },
+ frameCheck: {},
+ }),
+ );
+});
+
+it("rejects malformed caption-zone specs instead of silently disabling the gate", async () => {
+ const { report } = await runScenario(fakeDriver());
+ const runPipeline = vi.fn(async () => report);
+ const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
+ const command = createCheckCommand({
+ resolveProject: () => PROJECT,
+ runPipeline,
+ withMeta: (value) => ({ ...value, _meta: { version: "test" } }),
+ });
+
+ await runCommand(command, {
+ rawArgs: ["--json", "--caption-zone", "x0=0;y0=.8;x1=1;y1=1.2"],
+ });
+
+ expect(runPipeline).not.toHaveBeenCalled();
+ expect(process.exitCode).toBe(1);
+ expect(log).toHaveBeenCalledTimes(1);
+ expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual({
+ ok: false,
+ error: expect.stringContaining("Invalid --caption-zone"),
+ _meta: { version: "test" },
+ });
+});
+
+it("flags only text whose center is inside the caption band at the default end seek", async () => {
+ const collectGeometryCandidates = vi.fn(async (time: number) => [
+ geometryCandidate({
+ kind: "text",
+ tag: "div",
+ text: "Centered title",
+ selector: "#centered",
+ rect: fixtureRect(860, 870, 200, 60),
+ time,
+ }),
+ geometryCandidate({
+ kind: "text",
+ tag: "div",
+ text: "Overlap only",
+ selector: "#overlap-only",
+ rect: fixtureRect(860, 830, 200, 60),
+ time,
+ }),
+ ]);
+ const { report } = await runScenario(
+ fakeDriver({
+ getDuration: vi.fn(async () => 10),
+ collectGeometryCandidates,
+ }),
+ {
+ samples: 1,
+ contrast: false,
+ captionZone: { x0: 0, y0: 0.8, x1: 1, y1: 0.9 },
+ },
+ );
+
+ expect(collectGeometryCandidates).toHaveBeenCalledTimes(1);
+ expect(collectGeometryCandidates).toHaveBeenCalledWith(10, {
+ text: true,
+ media: false,
+ tolerance: 2,
+ });
+ expect(report.layout.samples).toEqual([5, 10]);
+ expect(report.layout.findings).toEqual([
+ expect.objectContaining({
+ code: "caption_zone_collision",
+ severity: "warning",
+ selector: "#centered",
+ text: "Centered title",
+ time: 10,
+ }),
+ ]);
+ expect(report.ok).toBe(true);
+});
+
+it("filters caption candidates by the element box while centering the text rect", async () => {
+ const collectGeometryCandidates = vi.fn(async (time: number) => [
+ geometryCandidate({
+ kind: "text",
+ tag: "div",
+ text: "Full-frame wrapper copy",
+ selector: "#full-frame-wrapper",
+ rect: fixtureRect(860, 870, 200, 60),
+ elementRect: fixtureRect(0, 0, 1920, 1080),
+ time,
+ }),
+ geometryCandidate({
+ kind: "text",
+ tag: "span",
+ text: "Tiny wrapper copy",
+ selector: "#tiny-wrapper",
+ rect: fixtureRect(860, 870, 200, 60),
+ elementRect: fixtureRect(860, 870, 3, 3),
+ time,
+ }),
+ ]);
+ const { report } = await runScenario(fakeDriver({ collectGeometryCandidates }), {
+ contrast: false,
+ captionZone: { x0: 0, y0: 0.8, x1: 1, y1: 0.9 },
+ });
+
+ expect(report.layout.findings).toEqual([]);
+});
+
+it("checks media overflow at the default midpoint and applies warning severity", async () => {
+ const collectGeometryCandidates = vi.fn(async (time: number) => [
+ geometryCandidate({
+ kind: "media",
+ tag: "img",
+ text: "img",
+ selector: "#hero-image",
+ rect: fixtureRect(1840, 100, 220, 200),
+ overflow: { right: 140 },
+ time,
+ }),
+ ]);
+ const { report } = await runScenario(
+ fakeDriver({ getDuration: vi.fn(async () => 10), collectGeometryCandidates }),
+ { samples: 1, contrast: false, frameCheck: {} },
+ );
+
+ expect(collectGeometryCandidates).toHaveBeenCalledWith(5, {
+ text: false,
+ media: true,
+ tolerance: 2,
+ });
+ expect(report.layout.findings[0]).toMatchObject({
+ code: "frame_out_of_frame",
+ severity: "warning",
+ selector: "#hero-image",
+ overflow: { right: 140 },
+ time: 5,
+ });
+});
+
+it("converts progress seeks to time, gates each collector, and keeps the first caption hit", async () => {
+ const collectGeometryCandidates = vi.fn(gateCandidates);
+ const { report } = await runScenario(
+ fakeDriver({
+ getDuration: vi.fn(async () => 8),
+ collectGeometryCandidates,
+ }),
+ {
+ samples: 1,
+ contrast: false,
+ captionZone: {
+ x0: 0,
+ y0: 0.8,
+ x1: 1,
+ y1: 0.9,
+ severity: "error",
+ seek: [0.25, 0.75],
+ },
+ frameCheck: { severity: "error" },
+ },
+ );
+
+ expect(report.layout.samples).toEqual([2, 4, 6]);
+ expect(collectGeometryCandidates.mock.calls).toEqual([
+ [2, { text: true, media: false, tolerance: 2 }],
+ [4, { text: false, media: true, tolerance: 2 }],
+ [6, { text: true, media: false, tolerance: 2 }],
+ ]);
+ expect(report.layout.findings).toEqual([
+ expect.objectContaining({
+ code: "caption_zone_collision",
+ severity: "error",
+ selector: "#first-heading",
+ time: 2,
+ }),
+ expect.objectContaining({ code: "frame_out_of_frame", severity: "error", time: 4 }),
+ ]);
+ expect(report.ok).toBe(false);
+});
+
+it("does not collect or emit opt-in geometry findings when both flags are off", async () => {
+ const collectGeometryCandidates = vi.fn(async () => [
+ geometryCandidate({
+ kind: "media",
+ tag: "video",
+ text: "video",
+ selector: "#video",
+ rect: fixtureRect(1900, 0, 200, 200),
+ overflow: { right: 180 },
+ time: 4.5,
+ }),
+ ]);
+ const { report } = await runScenario(fakeDriver({ collectGeometryCandidates }), {
+ contrast: false,
+ });
+
+ expect(collectGeometryCandidates).not.toHaveBeenCalled();
+ expect(JSON.stringify(report)).not.toContain("caption_zone_collision");
+ expect(JSON.stringify(report)).not.toContain("frame_out_of_frame");
+});
+
+it("computes caption bands from a portrait composition viewport", async () => {
+ const viewport = resolveCompositionViewportFromHtml(
+ '',
+ );
+ const collectGeometryCandidates = vi.fn(async (time: number) => [
+ geometryCandidate({
+ kind: "text",
+ tag: "p",
+ text: "Portrait caption collision",
+ selector: "#portrait-copy",
+ rect: fixtureRect(480, 1570, 120, 60),
+ time,
+ }),
+ ]);
+ const getCanvas = vi.fn(async () => viewport);
+ const { report } = await runScenario(
+ fakeDriver({
+ getDuration: vi.fn(async () => 4),
+ getCanvas,
+ collectGeometryCandidates,
+ }),
+ {
+ samples: 1,
+ contrast: false,
+ captionZone: { x0: 0.4, y0: 0.8, x1: 0.6, y1: 0.9 },
+ },
+ );
+
+ expect(viewport).toEqual({ width: 1080, height: 1920 });
+ expect(getCanvas).toHaveBeenCalled();
+ expect(report.layout.findings).toEqual([
+ expect.objectContaining({ code: "caption_zone_collision", selector: "#portrait-copy" }),
+ ]);
+});
+
+it("suppresses frame breaches below the per-canvas floor and reports those above it", async () => {
+ const collectGeometryCandidates = vi.fn(async (time: number) => [
+ geometryCandidate({
+ kind: "media",
+ tag: "canvas",
+ text: "canvas",
+ selector: "#under-floor",
+ rect: fixtureRect(3980, 100, 199, 100),
+ overflow: { right: 179 },
+ time,
+ }),
+ geometryCandidate({
+ kind: "media",
+ tag: "canvas",
+ text: "canvas",
+ selector: "#over-floor",
+ rect: fixtureRect(3980, 300, 201, 100),
+ overflow: { right: 181 },
+ time,
+ }),
+ ]);
+ const { report } = await runScenario(
+ fakeDriver({
+ getCanvas: vi.fn(async () => ({ width: 4000, height: 3000 })),
+ collectGeometryCandidates,
+ }),
+ {
+ contrast: false,
+ frameCheck: {},
+ },
+ );
+
+ expect(report.layout.findings).toEqual([
+ expect.objectContaining({
+ code: "frame_out_of_frame",
+ selector: "#over-floor",
+ overflow: { right: 181 },
+ }),
+ ]);
+});
+
+it("keeps frame findings at distinct rounded positions across requested seeks", async () => {
+ const collectGeometryCandidates = vi.fn(async (time: number) => [
+ geometryCandidate({
+ kind: "media",
+ tag: "img",
+ text: "img",
+ selector: "#moving-image",
+ rect: fixtureRect(1920, time === 2 ? 100 : 300, 130, 100),
+ overflow: { right: 130 },
+ time,
+ }),
+ ]);
+ const { report } = await runScenario(
+ fakeDriver({
+ getDuration: vi.fn(async () => 8),
+ collectGeometryCandidates,
+ }),
+ {
+ samples: 1,
+ contrast: false,
+ frameCheck: { seek: [0.25, 0.75] },
+ },
+ );
+
+ expect(report.layout.findings).toEqual([
+ expect.objectContaining({ code: "frame_out_of_frame", time: 2 }),
+ expect.objectContaining({ code: "frame_out_of_frame", time: 6 }),
+ ]);
+});
+
+it("keeps contrast and snapshot sampling on the pre-gate layout grid", async () => {
+ const collectContrast = vi.fn(async () => ({ entries: [], pngBase64: PNG_BASE64 }));
+ const { report } = await runScenario(
+ fakeDriver({
+ getDuration: vi.fn(async () => 10),
+ collectContrast,
+ }),
+ {
+ samples: 1,
+ captionZone: { x0: 0, y0: 0.8, x1: 1, y1: 1 },
+ },
+ );
+
+ expect(report.layout.samples).toEqual([5, 10]);
+ expect(report.contrast.samples).toEqual([5]);
+ expect(collectContrast).toHaveBeenCalledTimes(1);
+ expect(collectContrast).toHaveBeenCalledWith(5);
+});
+
+function layoutFindingOf(
+ code: LayoutIssueCode,
+ severity: "error" | "warning" | "info",
+ bbox: { x: number; y: number; width: number; height: number },
+ time = 1,
+): AnchoredLayoutIssue {
+ return {
+ code,
+ severity,
+ message: code,
+ ...anchor("#el", time),
+ bbox,
+ rect: {
+ left: bbox.x,
+ top: bbox.y,
+ right: bbox.x + bbox.width,
+ bottom: bbox.y + bbox.height,
+ ...bbox,
+ },
+ };
+}
+
+function checkFindingOf(
+ code: string,
+ severity: "error" | "warning" | "info",
+ bbox: { x: number; y: number; width: number; height: number },
+ time = 1,
+): CheckFinding {
+ return { code, severity, message: code, ...anchor("#el", time), bbox };
+}
+
+function emptySection(findings: T[] = []) {
+ return { ok: true, errorCount: 0, warningCount: 0, infoCount: 0, findings };
+}
+
+function reportWithFindings(overrides: Partial = {}): CheckReport {
+ return {
+ ok: true,
+ strict: false,
+ lint: { ...emptySection(), filesScanned: 0 },
+ runtime: emptySection(),
+ layout: {
+ ...emptySection(),
+ duration: 10,
+ samples: [],
+ transitionSamples: [],
+ transitionSamplesDropped: 0,
+ tolerance: 2,
+ totalIssueCount: 0,
+ truncated: false,
+ },
+ motion: { ...emptySection(), enabled: false, samples: 0 },
+ contrast: { ...emptySection(), enabled: true, samples: [], checked: 0, passed: 0 },
+ snapshots: { enabled: false, files: [], times: [], findingFiles: [] },
+ ...overrides,
+ };
+}
+
+describe("selectFindingCropRequests", () => {
+ const NON_ZERO_BBOX = { x: 10, y: 20, width: 100, height: 50 };
+ const ZERO_BBOX = { x: 0, y: 0, width: 0, height: 0 };
+
+ it("filenames a request finding-NN-.png with the finding's time and bbox", () => {
+ const report = reportWithFindings({
+ layout: {
+ ...reportWithFindings().layout,
+ findings: [layoutFindingOf("clipped_text", "error", NON_ZERO_BBOX, 2.5)],
+ },
+ });
+
+ expect(selectFindingCropRequests(report)).toEqual([
+ { filename: "finding-00-clipped_text.png", time: 2.5, bbox: NON_ZERO_BBOX },
+ ]);
+ });
+
+ it("skips warnings/info and findings without a real bbox", () => {
+ const report = reportWithFindings({
+ layout: {
+ ...reportWithFindings().layout,
+ findings: [
+ layoutFindingOf("content_overlap", "warning", NON_ZERO_BBOX),
+ layoutFindingOf("clipped_text", "error", ZERO_BBOX),
+ ],
+ },
+ runtime: { ...emptySection([checkFindingOf("console_error", "info", NON_ZERO_BBOX)]) },
+ });
+
+ expect(selectFindingCropRequests(report)).toEqual([]);
+ });
+
+ it("caps at 12 requests across sections", () => {
+ const findings = Array.from({ length: 15 }, (_, index) =>
+ checkFindingOf(`code_${index}`, "error", NON_ZERO_BBOX, index),
+ );
+ const report = reportWithFindings({
+ runtime: { ...emptySection(findings) },
+ });
+
+ const requests = selectFindingCropRequests(report);
+ expect(requests).toHaveLength(12);
+ expect(requests[0]?.filename).toBe(findingCropFilename(0, "code_0"));
+ expect(requests[11]?.filename).toBe(findingCropFilename(11, "code_11"));
+ });
+
+ it("sanitizes unusual characters out of the code when building a filename", () => {
+ expect(findingCropFilename(3, "weird code/name")).toBe("finding-03-weird_code_name.png");
+ });
+});
+
+describe("check pipeline", () => {
+ const originalExitCode = process.exitCode;
+
+ afterEach(() => {
+ process.exitCode = originalExitCode;
+ vi.restoreAllMocks();
+ });
+
+ it("emits one clean JSON envelope with every section and exit 0", async () => {
+ const { report } = await runScenario(fakeDriver());
+ const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
+ const command = createCheckCommand({
+ resolveProject: () => PROJECT,
+ runPipeline: vi.fn(async () => report),
+ withMeta: (value) => ({ ...value, _meta: { version: "test" } }),
+ });
+
+ await runCommand(command, { rawArgs: ["--json"] });
+
+ expect(report.ok).toBe(true);
+ expect(checkExitCode(report)).toBe(0);
+ expect(process.exitCode).toBe(0);
+ expect(log).toHaveBeenCalledTimes(1);
+ const output = log.mock.calls[0]?.[0];
+ expect(typeof output).toBe("string");
+ if (typeof output !== "string") throw new Error("expected JSON output");
+ const envelope = JSON.parse(output);
+ expect(envelope).toMatchObject({
+ ok: true,
+ lint: { ok: true },
+ runtime: { ok: true },
+ layout: { ok: true },
+ motion: { ok: true },
+ contrast: { ok: true },
+ snapshots: { enabled: false },
+ _meta: { version: "test" },
+ });
+ });
+
+ it("short-circuits on lint errors without launching a browser", async () => {
+ const lint = lintWith(
+ "error",
+ "root_missing_composition_id",
+ "Root element needs data-composition-id.",
+ );
+ const { report, browser } = await runScenario(fakeDriver(), {}, { lint });
+
+ expect(report.ok).toBe(false);
+ expect(checkExitCode(report)).toBe(1);
+ expect(report.lint.findings).toHaveLength(1);
+ expect(browser).not.toHaveBeenCalled();
+ });
+
+ it("gates AA contrast failures and --no-contrast skips the pass", async () => {
+ const failingContrast = vi.fn(async (time: number) => ({
+ entries: time === 0.5 ? [contrastEntry()] : [],
+ pngBase64: PNG_BASE64,
+ }));
+ const { report } = await runScenario(fakeDriver({ collectContrast: failingContrast }));
+ expect(report.ok).toBe(false);
+ expect(checkExitCode(report)).toBe(1);
+ expect(report.contrast.errorCount).toBe(1);
+
+ const skippedContrast = vi.fn(async () => ({
+ entries: [contrastEntry()],
+ pngBase64: PNG_BASE64,
+ }));
+ const skipped = await runScenario(fakeDriver({ collectContrast: skippedContrast }), {
+ contrast: false,
+ });
+ expect(skipped.report.ok).toBe(true);
+ expect(checkExitCode(skipped.report)).toBe(0);
+ expect(skipped.report.contrast.enabled).toBe(false);
+ expect(skippedContrast).not.toHaveBeenCalled();
+ });
+
+ it("includes measured colors, thresholds, and a passing palette-direction suggestion", async () => {
+ const { report } = await runScenario(
+ fakeDriver({
+ collectContrast: vi.fn(async () => ({
+ entries: [contrastEntry()],
+ pngBase64: PNG_BASE64,
+ })),
+ }),
+ );
+ const finding = report.contrast.findings[0];
+ expect(finding).toMatchObject({
+ fg: "rgb(110,110,110)",
+ bg: "rgb(30,30,30)",
+ ratio: 2.5,
+ requiredRatio: 4.5,
+ });
+ if (!finding) throw new Error("expected contrast finding");
+ const suggested = parseColorRGBA(finding.suggestedColor);
+ const background = parseColorRGBA(finding.bg);
+ expect(suggested).not.toBeNull();
+ expect(background).not.toBeNull();
+ if (!suggested || !background) throw new Error("expected parseable colors");
+ expect(
+ contrastRatio(
+ [suggested[0], suggested[1], suggested[2]],
+ [background[0], background[1], background[2]],
+ ),
+ ).toBeGreaterThanOrEqual(finding.requiredRatio);
+ expect(suggested[0]).toBeGreaterThan(110);
+ });
+
+ it("preserves a resolving selector, source file, identity, bbox, and sample time", async () => {
+ const { report } = await runScenario(
+ fakeDriver({
+ collectLayout: vi.fn(async (time: number) => [layoutIssue("error", { time })]),
+ }),
+ );
+ expect(report.layout.findings[0]).toMatchObject({
+ selector: "#hero",
+ dataAttributes: { "data-layout-name": "hero" },
+ sourceFile: "compositions/scene.html",
+ bbox: { x: 10, y: 20, width: 300, height: 80 },
+ time: 0.5,
+ });
+ });
+
+ it("reports layout and runtime errors from one browser session", async () => {
+ const { report, browser } = await runScenario(
+ fakeDriver({
+ collectLayout: vi.fn(async (time: number) => [layoutIssue("error", { time })]),
+ }),
+ {},
+ { runtime: [runtimeError()] },
+ );
+ expect(report.runtime.errorCount).toBe(1);
+ expect(report.layout.errorCount).toBe(1);
+ expect(browser).toHaveBeenCalledTimes(1);
+ });
+
+ it("reports a failing appearsBy sidecar as motion_appears_late", async () => {
+ const motion: MotionSpecResolution = {
+ kind: "valid",
+ path: "/project/index.motion.json",
+ spec: { assertions: [{ kind: "appearsBy", selector: "#hero", bySec: 0.2 }] },
+ };
+ const driver = fakeDriver({
+ getDuration: vi.fn(async () => 1),
+ collectMotionFrame: vi.fn(async (time: number) => heroMotionFrame(time, (t) => t >= 0.5)),
+ });
+ const { report } = await runScenario(driver, {}, { motion });
+
+ expect(report.motion.findings).toEqual([
+ expect.objectContaining({
+ code: "motion_appears_late",
+ severity: "error",
+ selector: "#hero",
+ }),
+ ]);
+ expect(report.ok).toBe(false);
+ });
+
+ it("writes cached contrast PNGs only with --snapshots at the contrast timestamps", async () => {
+ const writer = vi.fn(
+ async (_projectDir: string, index: number, time: number, _pngBase64: string) =>
+ `snapshots/frame-${String(index).padStart(2, "0")}-at-${time.toFixed(1)}s.png`,
+ );
+ const captured = fakeDriver({
+ collectContrast: vi.fn(async () => ({ entries: [], pngBase64: PNG_BASE64 })),
+ });
+ const { report } = await runScenario(captured, { snapshots: true }, { writeSnapshot: writer });
+
+ expect(report.snapshots.times).toEqual([0.5, 2.5, 4.5, 6.5, 8.5]);
+ expect(report.snapshots.files).toEqual([
+ "snapshots/frame-00-at-0.5s.png",
+ "snapshots/frame-01-at-2.5s.png",
+ "snapshots/frame-02-at-4.5s.png",
+ "snapshots/frame-03-at-6.5s.png",
+ "snapshots/frame-04-at-8.5s.png",
+ ]);
+ expect(writer).toHaveBeenCalledTimes(5);
+
+ const absentWriter = vi.fn(async () => "unused.png");
+ await runScenario(fakeDriver(), { snapshots: false }, { writeSnapshot: absentWriter });
+ expect(absentWriter).not.toHaveBeenCalled();
+ });
+
+ it("captures finding crops for error findings with bboxes only when --snapshots is set", async () => {
+ const capture = vi.fn(
+ async (
+ _project: ProjectDir,
+ _options: CheckOptions,
+ _requests: CheckFindingCropRequest[],
+ ) => ["snapshots/finding-00-clipped_text.png"],
+ );
+ const { report } = await runScenario(
+ fakeDriver({
+ collectLayout: vi.fn(async (time: number) => [layoutIssue("error", { time })]),
+ }),
+ { snapshots: true },
+ { captureFindingCrops: capture },
+ );
+
+ expect(capture).toHaveBeenCalledTimes(1);
+ expect(capture.mock.calls[0]?.[2]).toEqual([
+ {
+ filename: "finding-00-clipped_text.png",
+ time: 0.5,
+ bbox: { x: 10, y: 20, width: 300, height: 80 },
+ },
+ ]);
+ expect(report.snapshots.findingFiles).toEqual(["snapshots/finding-00-clipped_text.png"]);
+
+ const withoutSnapshots = vi.fn(async () => ["unused.png"]);
+ await runScenario(
+ fakeDriver({
+ collectLayout: vi.fn(async (time: number) => [layoutIssue("error", { time })]),
+ }),
+ { snapshots: false },
+ { captureFindingCrops: withoutSnapshots },
+ );
+ expect(withoutSnapshots).not.toHaveBeenCalled();
+
+ const noErrors = vi.fn(async () => ["unused.png"]);
+ await runScenario(
+ fakeDriver({
+ collectLayout: vi.fn(
+ async (time: number) =>
+ // container_overflow, not content_overlap: this fixture wants a plain
+ // warning-severity finding held across the whole run, unaffected by
+ // content_overlap's #U10 held-duration re-promotion to error.
+ [layoutIssue("warning", { time, code: "container_overflow" })],
+ ),
+ }),
+ { snapshots: true },
+ { captureFindingCrops: noErrors },
+ );
+ expect(noErrors).not.toHaveBeenCalled();
+ });
+
+ it("--strict flips a warnings-only result from exit 0 to exit 1", async () => {
+ const warningDriver = () =>
+ fakeDriver({
+ collectLayout: vi.fn(
+ async (time: number) =>
+ // container_overflow, not content_overlap: this fixture wants a plain
+ // warning-severity finding held across the whole run, unaffected by
+ // content_overlap's #U10 held-duration re-promotion to error.
+ [layoutIssue("warning", { time, code: "container_overflow" })],
+ ),
+ });
+ const normal = await runScenario(warningDriver(), { strict: false });
+ const strict = await runScenario(warningDriver(), { strict: true });
+
+ expect(checkExitCode(normal.report)).toBe(0);
+ expect(checkExitCode(strict.report)).toBe(1);
+ });
+
+ it("fails clearly without samples when no timeline duration is available, without hanging", async () => {
+ const driver = fakeDriver({ getDuration: vi.fn(async () => 0) });
+ await expect(runAuditGrid(driver, DEFAULT_CHECK_OPTIONS, noMotion())).rejects.toThrow(
+ "Could not determine composition duration — no layout samples run",
+ );
+ await expect(
+ runAuditGrid(
+ driver,
+ {
+ ...DEFAULT_CHECK_OPTIONS,
+ captionZone: { x0: 0, y0: 0.8, x1: 1, y1: 1 },
+ },
+ noMotion(),
+ ),
+ ).rejects.toThrow("Could not determine composition duration — no layout samples run");
+
+ const { report, browser } = await runScenario(driver);
+ expect(browser).toHaveBeenCalledTimes(1);
+ expect(report.runtime.findings[0]?.message).toContain(
+ "Could not determine composition duration — no layout samples run",
+ );
+ expect(checkExitCode(report)).toBe(1);
+ });
+
+ describe("frozen-sweep guard (#U10)", () => {
+ it("fails with sweep_static when a 6s composition's geometry never changes across samples", async () => {
+ const driver = fakeDriver({
+ getDuration: vi.fn(async () => 6),
+ collectLayoutGeometry: vi.fn(async () => "frozen"),
+ });
+ const { report } = await runScenario(driver);
+
+ expect(report.ok).toBe(false);
+ expect(
+ report.layout.findings.some(
+ (finding) =>
+ finding.code === "sweep_static" &&
+ finding.severity === "error" &&
+ finding.message.includes("did not advance"),
+ ),
+ ).toBe(true);
+ });
+
+ it("does not flag a 1.5s static title card — too short for the guard to apply", async () => {
+ const driver = fakeDriver({
+ getDuration: vi.fn(async () => 1.5),
+ collectLayoutGeometry: vi.fn(async () => "frozen"),
+ });
+ const { report } = await runScenario(driver);
+
+ expect(report.layout.findings.some((finding) => finding.code === "sweep_static")).toBe(false);
+ });
+
+ it("does not double-report when a motion_frozen finding already covers the same symptom", async () => {
+ const motion: MotionSpecResolution = {
+ kind: "valid",
+ path: "/project/index.motion.json",
+ spec: { assertions: [{ kind: "keepsMoving" }] },
+ };
+ const driver = fakeDriver({
+ getDuration: vi.fn(async () => 6),
+ collectLayoutGeometry: vi.fn(async () => "frozen"),
+ collectMotionFrame: vi.fn(async (time: number) => ({
+ time,
+ data: {},
+ liveness: { "*": "unchanging" },
+ })),
+ });
+ const { report } = await runScenario(driver, {}, { motion });
+
+ expect(report.motion.findings.some((finding) => finding.code === "motion_frozen")).toBe(true);
+ expect(report.layout.findings.some((finding) => finding.code === "sweep_static")).toBe(false);
+ });
+ });
+});
+
+describe("frame-check flag grammar", () => {
+ it("keeps bare --frame-check on defaults and parses the value form", async () => {
+ const { parseFrameCheck } = await import("./check.js");
+ expect(parseFrameCheck(undefined)).toBeUndefined();
+ expect(parseFrameCheck(true)).toEqual({});
+ expect(parseFrameCheck("")).toEqual({});
+ expect(parseFrameCheck("severity=error;seek=.25,.75;tol=4")).toEqual({
+ severity: "error",
+ seek: [0.25, 0.75],
+ tol: 4,
+ });
+ expect(() => parseFrameCheck("bogus=1")).toThrow("Invalid --frame-check");
+ expect(() => parseFrameCheck("tol=-2")).toThrow("Invalid --frame-check");
+ });
+});
+
+describe("contrast persistence", () => {
+ it("demotes a single-sample contrast failure to warning but gates held failures", async () => {
+ const driver = fakeDriver({
+ collectContrast: vi.fn(async (time: number) => ({
+ entries: [
+ // #hero fails at every sample: held, stays an error.
+ contrastEntry({ time, selector: "#hero" }),
+ // #entrance fails only at the first sample (mid-entrance): demoted.
+ ...(time < 1
+ ? [contrastEntry({ time, selector: "#entrance", text: "Fading in" })]
+ : [
+ contrastEntry({
+ time,
+ selector: "#entrance",
+ text: "Fading in",
+ ratio: 8,
+ wcagAA: true,
+ }),
+ ]),
+ ],
+ pngBase64: PNG_BASE64,
+ })),
+ });
+ const { report } = await runScenario(driver);
+
+ const bySelector = new Map(
+ report.contrast.findings.map((finding) => [finding.selector, finding.severity]),
+ );
+ expect(bySelector.get("#hero")).toBe("error");
+ expect(bySelector.get("#entrance")).toBe("warning");
+ expect(checkExitCode(report)).toBe(1);
+ });
+
+ it("keeps full severity when only one sample time exists", async () => {
+ const driver = fakeDriver({
+ collectContrast: vi.fn(async (time: number) => ({
+ entries: [contrastEntry({ time })],
+ pngBase64: PNG_BASE64,
+ })),
+ });
+ const { report } = await runScenario(driver, { samples: 1, at: [2] });
+
+ expect(report.contrast.findings[0]?.severity).toBe("error");
+ });
+});
+
+describe("check report telemetry", () => {
+ it("reports one clean run with every gate and sampled-point count", async () => {
+ const motion: MotionSpecResolution = {
+ kind: "valid",
+ path: "/project/index.motion.json",
+ spec: { duration: 1, assertions: [{ kind: "appearsBy", selector: "#hero", bySec: 1 }] },
+ };
+ const driver = fakeDriver({
+ getDuration: vi.fn(async () => 1),
+ collectMotionFrame: vi.fn(async (time: number) => heroMotionFrame(time, () => true)),
+ collectContrast: vi.fn(async (time: number) => ({
+ entries: [contrastEntry({ time, ratio: 7, wcagAA: true })],
+ pngBase64: PNG_BASE64,
+ })),
+ });
+
+ const { report } = await runScenario(
+ driver,
+ {
+ samples: 1,
+ captionZone: { x0: 0, y0: 0.8, x1: 1, y1: 1 },
+ frameCheck: {},
+ snapshots: true,
+ },
+ { motion },
+ );
+
+ expect(trackCheckReport).toHaveBeenCalledTimes(1);
+ expect(trackCheckReport).toHaveBeenCalledWith(
+ expect.objectContaining({
+ contrastGate: true,
+ motionGate: true,
+ captionZoneGate: true,
+ frameCheckGate: true,
+ snapshotsGate: true,
+ gridPoints: 2,
+ contrastPoints: 1,
+ ok: true,
+ exitCode: 0,
+ }),
+ );
+ expect(report.ok).toBe(true);
+ });
+
+ it("reports one failing contrast run with its section error count", async () => {
+ const collectContrast = vi.fn(async (time: number) => ({
+ entries: time === 0.5 ? [contrastEntry()] : [],
+ pngBase64: PNG_BASE64,
+ }));
+
+ const { report } = await runScenario(fakeDriver({ collectContrast }));
+
+ expect(trackCheckReport).toHaveBeenCalledTimes(1);
+ expect(trackCheckReport).toHaveBeenCalledWith(
+ expect.objectContaining({
+ ok: false,
+ exitCode: 1,
+ contrastErrors: report.contrast.errorCount,
+ }),
+ );
+ expect(report.contrast.errorCount).toBe(1);
+ });
+
+ it("reports zero browser samples and timings after a lint short circuit", async () => {
+ const lint = lintWith(
+ "error",
+ "root_missing_composition_id",
+ "Root element needs data-composition-id.",
+ );
+
+ const { report, browser } = await runScenario(fakeDriver(), {}, { lint });
+
+ expect(browser).not.toHaveBeenCalled();
+ expect(trackCheckReport).toHaveBeenCalledTimes(1);
+ expect(trackCheckReport).toHaveBeenCalledWith(
+ expect.objectContaining({
+ ok: false,
+ exitCode: 1,
+ gridPoints: 0,
+ contrastPoints: 0,
+ launchSettleMs: 0,
+ seekLoopMs: 0,
+ contrastMs: 0,
+ }),
+ );
+ expect(report.ok).toBe(false);
+ });
+
+ it("matches report counts for mixed findings across classes", async () => {
+ const lint = lintWith("warning", "lint_warning", "Lint warning.");
+ const driver = fakeDriver({
+ collectLayout: vi.fn(async () => [layoutIssue(), layoutIssue("warning")]),
+ collectContrast: vi.fn(async () => ({
+ entries: [contrastEntry()],
+ pngBase64: PNG_BASE64,
+ })),
+ });
+
+ const { report } = await runScenario(
+ driver,
+ { samples: 1 },
+ { lint, runtime: [runtimeError()] },
+ );
+
+ expect(trackCheckReport).toHaveBeenCalledTimes(1);
+ expect(trackCheckReport).toHaveBeenCalledWith(
+ expect.objectContaining({
+ lintErrors: report.lint.errorCount,
+ lintWarnings: report.lint.warningCount,
+ runtimeErrors: report.runtime.errorCount,
+ runtimeWarnings: report.runtime.warningCount,
+ layoutErrors: report.layout.errorCount,
+ layoutWarnings: report.layout.warningCount,
+ motionErrors: report.motion.errorCount,
+ motionWarnings: report.motion.warningCount,
+ contrastErrors: report.contrast.errorCount,
+ contrastWarnings: report.contrast.warningCount,
+ }),
+ );
+ expect(report.lint.warningCount).toBe(1);
+ expect(report.runtime.errorCount).toBe(1);
+ expect(report.layout.errorCount).toBe(1);
+ expect(report.layout.warningCount).toBe(1);
+ });
+
+ it("measures contrast work inside the overall seek loop", async () => {
+ vi.spyOn(Date, "now")
+ .mockReturnValueOnce(100)
+ .mockReturnValueOnce(105)
+ .mockReturnValueOnce(110)
+ .mockReturnValueOnce(120);
+
+ const result = await runAuditGrid(
+ fakeDriver(),
+ { ...DEFAULT_CHECK_OPTIONS, samples: 1 },
+ noMotion(),
+ );
+
+ expect(result.timings).toEqual({ launchSettleMs: 0, seekLoopMs: 20, contrastMs: 5 });
+ });
+});
+
+describe("contrast candidate round-trip", () => {
+ it("passes the browser script's raw candidates back to finish, never the normalized copies", () => {
+ const source = checkBrowserSource();
+
+ // __contrastAuditFinish samples pixels via the page script's own bbox
+ // shape ({x, y, w, h}); sending the Node-normalized candidate
+ // ({width, height}) makes every sample rect NaN and the audit silently
+ // reports zero checked elements. The raw object must round-trip verbatim.
+ expect(source).toMatch(/prepared\.map\(\(entry\) => entry\.raw\)/);
+ expect(source).toMatch(/raw: unknown;/);
+ expect(source).not.toMatch(/prepared\.map\(\(entry\) => entry\.candidate\)/);
+ });
+});
diff --git a/packages/cli/src/commands/check.ts b/packages/cli/src/commands/check.ts
new file mode 100644
index 0000000000..a0adf37678
--- /dev/null
+++ b/packages/cli/src/commands/check.ts
@@ -0,0 +1,409 @@
+import { defineCommand } from "citty";
+import type { Example } from "./_examples.js";
+import { parseAt } from "./layout.js";
+import { c } from "../ui/colors.js";
+import { normalizeErrorMessage } from "../utils/errorMessage.js";
+import { formatLayoutIssue } from "../utils/layoutAudit.js";
+import { resolveProject, type ProjectDir } from "../utils/project.js";
+import { withMeta } from "../utils/updateCheck.js";
+import {
+ DEFAULT_CHECK_OPTIONS,
+ checkExitCode,
+ runCheckPipeline,
+ type CheckFinding,
+ type CheckOptions,
+ type CheckReport,
+ type CheckSection,
+} from "../utils/checkPipeline.js";
+import type { CaptionZoneOptions, FrameCheckOptions } from "../utils/checkTypes.js";
+
+export const examples: Example[] = [
+ ["Run the full verification gate", "hyperframes check"],
+ ["Output one agent-readable envelope", "hyperframes check --json"],
+ ["Persist the five audited contrast frames", "hyperframes check --snapshots"],
+ ["Also fail on warnings", "hyperframes check --strict"],
+];
+
+export interface CheckCommandDependencies {
+ resolveProject(dir: string | undefined): ProjectDir;
+ runPipeline(project: ProjectDir, options: CheckOptions): Promise;
+ withMeta(value: object): object;
+}
+
+const DEFAULT_COMMAND_DEPENDENCIES: CheckCommandDependencies = {
+ resolveProject,
+ runPipeline: runCheckPipeline,
+ withMeta,
+};
+
+export function createCheckCommand(
+ dependencies: CheckCommandDependencies = DEFAULT_COMMAND_DEPENDENCIES,
+) {
+ return defineCommand({
+ meta: {
+ name: "check",
+ description:
+ "Run lint, runtime, layout, motion, and WCAG contrast verification in one browser session",
+ },
+ args: {
+ dir: { type: "positional", description: "Project directory", required: false },
+ json: { type: "boolean", description: "Output agent-readable JSON", default: false },
+ samples: {
+ type: "string",
+ description: "Number of midpoint samples across the duration (default: 9)",
+ default: "9",
+ },
+ at: {
+ type: "string",
+ description: "Comma-separated timestamps in seconds (e.g., --at 1.5,4,7.25)",
+ },
+ "at-transitions": {
+ type: "boolean",
+ description:
+ "Also sample at every tween start/end boundary (plus segment midpoints) to catch transient overlaps at transition seams",
+ default: false,
+ },
+ "max-transition-samples": {
+ type: "string",
+ description:
+ "Optional cap on transition-derived samples; when it truncates, the omitted count is reported (default: unlimited)",
+ },
+ "max-issues": {
+ type: "string",
+ description: "Maximum issues to print or return after static collapse (default: 80)",
+ default: "80",
+ },
+ "collapse-static": {
+ type: "boolean",
+ description: "Collapse repeated static issues across samples (default: true)",
+ default: true,
+ },
+ tolerance: {
+ type: "string",
+ description: "Allowed pixel overflow before reporting an issue (default: 2)",
+ default: "2",
+ },
+ timeout: {
+ type: "string",
+ description: "Ms to wait for scripts and media to settle initially (default: 3000)",
+ default: "3000",
+ },
+ contrast: {
+ type: "boolean",
+ description: "Run the WCAG AA contrast pass (enabled by default)",
+ default: true,
+ },
+ strict: {
+ type: "boolean",
+ description: "Exit non-zero on warnings too",
+ default: false,
+ },
+ snapshots: {
+ type: "boolean",
+ description: "Save the five contrast-pass PNGs under snapshots/",
+ default: false,
+ },
+ "caption-zone": {
+ type: "string",
+ description:
+ 'Caption band "x0=0;y0=.82;x1=1;y1=1[;severity=warning|error][;seek=.5,1]" (fractions 0-1; defaults: warning, seek=1)',
+ },
+ "frame-check": {
+ type: "string",
+ description:
+ 'Bare --frame-check uses defaults (tol=2px, severity=warning, seek=.5; breach floor=max(120px, 6% of shorter canvas edge)); or pass "severity=error;seek=.25,.75;tol=4" to tune',
+ },
+ },
+ async run({ args }) {
+ const asJson = args.json === true;
+
+ try {
+ const project = dependencies.resolveProject(args.dir);
+ const options = parseCheckOptions(args);
+ if (!asJson) {
+ console.log(`${c.accent("◆")} Checking ${c.accent(project.name)}`);
+ }
+ const report = await dependencies.runPipeline(project, options);
+ if (asJson) {
+ console.log(JSON.stringify(dependencies.withMeta(report), null, 2));
+ } else {
+ printHumanReport(report);
+ }
+ process.exitCode = checkExitCode(report);
+ } catch (error) {
+ const message = normalizeErrorMessage(error);
+ if (asJson) {
+ console.log(
+ JSON.stringify(dependencies.withMeta({ ok: false, error: message }), null, 2),
+ );
+ } else {
+ console.error(`${c.error("✗")} Check failed: ${message}`);
+ }
+ process.exitCode = 1;
+ }
+ },
+ });
+}
+
+function parseCheckOptions(args: Record): CheckOptions {
+ const maxTransitionSamples = positiveInteger(args["max-transition-samples"], 0);
+ return {
+ samples: positiveInteger(args.samples, DEFAULT_CHECK_OPTIONS.samples),
+ at: parseAt(args.at),
+ atTransitions: args["at-transitions"] === true,
+ maxTransitionSamples: maxTransitionSamples > 0 ? maxTransitionSamples : undefined,
+ maxIssues: positiveInteger(args["max-issues"], DEFAULT_CHECK_OPTIONS.maxIssues),
+ collapseStatic: args["collapse-static"] !== false,
+ tolerance: nonNegativeNumber(args.tolerance, DEFAULT_CHECK_OPTIONS.tolerance),
+ timeout: Math.max(500, positiveInteger(args.timeout, DEFAULT_CHECK_OPTIONS.timeout)),
+ contrast: args.contrast !== false,
+ strict: args.strict === true,
+ snapshots: args.snapshots === true,
+ captionZone: parseCaptionZone(args["caption-zone"]),
+ frameCheck: parseFrameCheck(args["frame-check"]),
+ };
+}
+
+const CAPTION_ZONE_FIELDS = new Set(["x0", "y0", "x1", "y1", "severity", "seek"]);
+
+const FRAME_CHECK_FIELDS = new Set(["severity", "seek", "tol"]);
+
+// Mirrors --caption-zone's spec grammar so the EF bridge's severity/seek/tol
+// options survive the migration instead of being silently dropped by a
+// boolean flag (bare --frame-check keeps today's defaults).
+export function parseFrameCheck(value: unknown): FrameCheckOptions | undefined {
+ if (value === undefined || value === null || value === false) return undefined;
+ if (value === true || value === "") return {};
+ if (typeof value !== "string") throw frameCheckError();
+ const fields = parseFrameCheckFields(value);
+ const severity = captionSeverity(fields.get("severity"));
+ const seek = captionSeeks(fields.get("seek"));
+ const tol = parseFrameCheckTolerance(fields.get("tol"));
+ return {
+ ...(severity ? { severity } : {}),
+ ...(seek ? { seek } : {}),
+ ...(tol !== undefined ? { tol } : {}),
+ };
+}
+
+function parseFrameCheckFields(value: string): Map {
+ const fields = new Map();
+ for (const part of value.split(";")) {
+ const { key, entry } = parseCaptionField(part);
+ if (!FRAME_CHECK_FIELDS.has(key) || fields.has(key)) throw frameCheckError();
+ fields.set(key, entry);
+ }
+ return fields;
+}
+
+function parseFrameCheckTolerance(raw: string | undefined): number | undefined {
+ if (raw === undefined) return undefined;
+ const tol = Number.parseFloat(raw);
+ if (!Number.isFinite(tol) || tol < 0) throw frameCheckError();
+ return tol;
+}
+
+function frameCheckError(): Error {
+ return new Error(
+ 'Invalid --frame-check: use bare --frame-check or "severity=warning|error;seek=.25,.75;tol=4" (all fields optional)',
+ );
+}
+
+function parseCaptionZone(value: unknown): CaptionZoneOptions | undefined {
+ if (value === undefined || value === null) return undefined;
+ const fields = parseCaptionFields(captionZoneString(value));
+ const { x0, y0, x1, y1 } = parseCaptionBounds(fields);
+ const severity = captionSeverity(fields.get("severity"));
+ const seek = captionSeeks(fields.get("seek"));
+ return {
+ x0,
+ y0,
+ x1,
+ y1,
+ ...(severity ? { severity } : {}),
+ ...(seek ? { seek } : {}),
+ };
+}
+
+function captionZoneString(value: unknown): string {
+ if (typeof value !== "string" || value.trim() === "") throw captionZoneError();
+ return value;
+}
+
+function parseCaptionFields(value: string): Map {
+ const fields = new Map();
+ for (const part of value.split(";")) {
+ const { key, entry } = parseCaptionField(part);
+ if (!CAPTION_ZONE_FIELDS.has(key) || fields.has(key)) throw captionZoneError();
+ fields.set(key, entry);
+ }
+ return fields;
+}
+
+function parseCaptionField(part: string): { key: string; entry: string } {
+ const separator = part.indexOf("=");
+ if (separator <= 0) throw captionZoneError();
+ return {
+ key: part.slice(0, separator).trim(),
+ entry: part.slice(separator + 1).trim(),
+ };
+}
+
+function parseCaptionBounds(fields: Map): {
+ x0: number;
+ y0: number;
+ x1: number;
+ y1: number;
+} {
+ const x0 = requiredCaptionFraction(fields, "x0");
+ const y0 = requiredCaptionFraction(fields, "y0");
+ const x1 = requiredCaptionFraction(fields, "x1");
+ const y1 = requiredCaptionFraction(fields, "y1");
+ if (x0 > x1 || y0 > y1) throw captionZoneError();
+ return { x0, y0, x1, y1 };
+}
+
+function requiredCaptionFraction(fields: Map, key: string): number {
+ const value = captionFraction(fields.get(key));
+ if (value === null) throw captionZoneError();
+ return value;
+}
+
+function captionFraction(value: string | undefined): number | null {
+ if (value === undefined || value === "") return null;
+ const parsed = Number(value);
+ return Number.isFinite(parsed) && parsed >= 0 && parsed <= 1 ? parsed : null;
+}
+
+function captionSeverity(value: string | undefined): "error" | "warning" | undefined {
+ if (value === undefined) return undefined;
+ if (value === "error" || value === "warning") return value;
+ throw captionZoneError();
+}
+
+function captionSeeks(value: string | undefined): number[] | undefined {
+ if (value === undefined) return undefined;
+ if (value === "") return [];
+ const values = value.split(",").map(captionFraction);
+ if (values.some((entry) => entry === null)) throw captionZoneError();
+ return values.flatMap((entry) => (entry === null ? [] : entry));
+}
+
+function captionZoneError(): Error {
+ return new Error(
+ 'Invalid --caption-zone; use "x0=0;y0=.82;x1=1;y1=1[;severity=warning|error][;seek=.5,1]" with fractions from 0 to 1.',
+ );
+}
+
+function positiveInteger(value: unknown, fallback: number): number {
+ const parsed = parseInt(String(value ?? ""), 10);
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
+}
+
+function nonNegativeNumber(value: unknown, fallback: number): number {
+ const parsed = parseFloat(String(value ?? ""));
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
+}
+
+function printHumanReport(report: CheckReport): void {
+ printSection("Lint", report.lint);
+ printSection("Runtime", report.runtime);
+ printLayoutSection("Layout", report.layout);
+ printSection("Motion", report.motion);
+ printContrastSection(report);
+ printSnapshotSection(report);
+ console.log();
+ const label = report.ok ? c.success("Check passed") : c.error("Check failed");
+ console.log(`${report.ok ? c.success("◇") : c.error("◇")} ${label}`);
+}
+
+function printSection(title: string, section: CheckSection): void {
+ console.log();
+ console.log(c.bold(title));
+ if (section.findings.length === 0) {
+ console.log(` ${c.success("◇")} 0 errors, 0 warnings`);
+ return;
+ }
+ for (const finding of section.findings) printFinding(finding);
+ printCounts(section);
+}
+
+function printLayoutSection(title: string, section: CheckReport["layout"]): void {
+ console.log();
+ console.log(c.bold(title));
+ if (section.findings.length === 0) {
+ console.log(` ${c.success("◇")} 0 issues across ${section.samples.length} sample(s)`);
+ } else {
+ for (const finding of section.findings) {
+ const formatted = formatLayoutIssue(finding).replace(/\n/g, "\n ");
+ console.log(` ${findingIcon(finding)} ${formatted}`);
+ }
+ printCounts(section);
+ }
+ if (section.transitionSamplesDropped > 0) {
+ console.log(
+ ` ${c.warn("⚠")} ${section.transitionSamplesDropped} transition sample(s) omitted`,
+ );
+ }
+}
+
+function printContrastSection(report: CheckReport): void {
+ const section = report.contrast;
+ console.log();
+ console.log(c.bold("Contrast"));
+ if (!section.enabled) {
+ console.log(` ${c.dim("◇")} skipped`);
+ return;
+ }
+ if (section.findings.length === 0) {
+ console.log(
+ ` ${c.success("◇")} ${section.passed}/${section.checked} text checks pass WCAG AA`,
+ );
+ return;
+ }
+ for (const finding of section.findings) {
+ console.log(
+ ` ${c.error("✗")} ${finding.selector} ${finding.ratio}:1 (need ${finding.requiredRatio}:1, t=${finding.time}s)`,
+ );
+ console.log(` ${c.dim(`Try ${finding.suggestedColor}; source ${finding.sourceFile}`)}`);
+ }
+ printCounts(section);
+}
+
+function printSnapshotSection(report: CheckReport): void {
+ console.log();
+ console.log(c.bold("Snapshots"));
+ if (!report.snapshots.enabled) {
+ console.log(` ${c.dim("◇")} disabled`);
+ } else {
+ console.log(` ${c.success("◇")} ${report.snapshots.files.length} PNG(s) saved`);
+ for (const file of report.snapshots.files) console.log(` ${c.dim(file)}`);
+ if (report.snapshots.findingFiles.length > 0) {
+ console.log(
+ ` ${c.success("◇")} ${report.snapshots.findingFiles.length} finding crop(s) saved`,
+ );
+ for (const file of report.snapshots.findingFiles) console.log(` ${c.dim(file)}`);
+ }
+ }
+}
+
+function printFinding(finding: CheckFinding): void {
+ const where = `${finding.sourceFile} ${finding.selector} t=${finding.time}s`;
+ console.log(` ${findingIcon(finding)} ${finding.code}: ${finding.message}`);
+ console.log(` ${c.dim(where)}`);
+ if (finding.fixHint) console.log(` ${c.dim(`Fix: ${finding.fixHint}`)}`);
+}
+
+function findingIcon(finding: CheckFinding): string {
+ if (finding.severity === "error") return c.error("✗");
+ if (finding.severity === "warning") return c.warn("⚠");
+ return c.dim("ℹ");
+}
+
+function printCounts(section: CheckSection): void {
+ console.log(
+ ` ${c.dim(`${section.errorCount} error(s), ${section.warningCount} warning(s), ${section.infoCount} info(s)`)}`,
+ );
+}
+
+export default createCheckCommand();
diff --git a/packages/cli/src/commands/contrast-audit.browser.js b/packages/cli/src/commands/contrast-audit.browser.js
index f89478e502..0aa7efe505 100644
--- a/packages/cli/src/commands/contrast-audit.browser.js
+++ b/packages/cli/src/commands/contrast-audit.browser.js
@@ -158,6 +158,22 @@ window.__contrastAuditPrepare = function () {
}
if (!hasText) continue;
+ // Same decorative opt-out the layout audit honors: text marked (or inside)
+ // data-layout-ignore is set dressing, not copy a viewer must read —
+ // deliberately dim rail labels, ghost typography, texture text.
+ if (el.closest && el.closest("[data-layout-ignore]")) continue;
+
+ // Text that has (nearly) left the canvas — a cursor exiting the frame, an
+ // element parked off-screen — is not readable content, and sampling its
+ // clamped edge reads whatever pixels happen to sit at the border (the
+ // classic false "white-on-white"). Require a minimally-visible on-canvas
+ // intersection before judging contrast; the layout audit separately owns
+ // off-canvas detection as its own finding class.
+ var vis = el.getBoundingClientRect();
+ var onX = Math.min(vis.right, window.innerWidth) - Math.max(vis.left, 0);
+ var onY = Math.min(vis.bottom, window.innerHeight) - Math.max(vis.top, 0);
+ if (onX < 8 || onY < 8) continue;
+
var cs = getComputedStyle(el);
if (cs.visibility === "hidden" || cs.display === "none") continue;
if (parseFloat(cs.opacity) <= 0.01) continue;
diff --git a/packages/cli/src/commands/contrast-bg.test.ts b/packages/cli/src/commands/contrast-bg.test.ts
index 8a58b30793..fd1dddb85f 100644
--- a/packages/cli/src/commands/contrast-bg.test.ts
+++ b/packages/cli/src/commands/contrast-bg.test.ts
@@ -1,5 +1,12 @@
import { describe, expect, it } from "vitest";
-import { parseColorRGBA, pickOpaqueBackground } from "./contrast-bg.js";
+import {
+ contrastRatio,
+ parseColorRGBA,
+ pickOpaqueBackground,
+ relativeLuminance,
+ requiredContrastRatio,
+ suggestCompliantForegroundColor,
+} from "./contrast-bg.js";
const opaque = (bg: string) => ({ backgroundColor: bg, backgroundImage: "none" });
@@ -53,3 +60,51 @@ describe("pickOpaqueBackground", () => {
expect(pickOpaqueBackground([opaque("rgba(0, 0, 0, 0)")])).toBeNull();
});
});
+
+describe("relativeLuminance", () => {
+ it("uses the WCAG sRGB transfer function", () => {
+ expect(relativeLuminance([0, 0, 0])).toBe(0);
+ expect(relativeLuminance([255, 255, 255])).toBe(1);
+ expect(relativeLuminance([255, 0, 0])).toBeCloseTo(0.2126, 4);
+ });
+});
+
+describe("contrastRatio", () => {
+ it("is symmetric and reaches 21:1 for black and white", () => {
+ expect(contrastRatio([0, 0, 0], [255, 255, 255])).toBe(21);
+ expect(contrastRatio([255, 255, 255], [0, 0, 0])).toBe(21);
+ });
+});
+
+describe("requiredContrastRatio", () => {
+ it("requires 3:1 for large text and 4.5:1 otherwise", () => {
+ expect(requiredContrastRatio(true)).toBe(3);
+ expect(requiredContrastRatio(false)).toBe(4.5);
+ });
+});
+
+describe("suggestCompliantForegroundColor", () => {
+ it("brightens a failing foreground on a dark background until it passes", () => {
+ const background: [number, number, number] = [20, 20, 20];
+ const foreground: [number, number, number] = [80, 80, 80];
+ const suggested = suggestCompliantForegroundColor(foreground, background, 4.5);
+
+ expect(suggested[0]).toBeGreaterThan(foreground[0]);
+ expect(contrastRatio(suggested, background)).toBeGreaterThanOrEqual(4.5);
+ });
+
+ it("darkens a failing foreground on a light background until it passes", () => {
+ const background: [number, number, number] = [245, 245, 245];
+ const foreground: [number, number, number] = [180, 180, 180];
+ const suggested = suggestCompliantForegroundColor(foreground, background, 4.5);
+
+ expect(suggested[0]).toBeLessThan(foreground[0]);
+ expect(contrastRatio(suggested, background)).toBeGreaterThanOrEqual(4.5);
+ });
+
+ it("preserves a foreground that already passes", () => {
+ expect(suggestCompliantForegroundColor([255, 255, 255], [0, 0, 0], 4.5)).toEqual([
+ 255, 255, 255,
+ ]);
+ });
+});
diff --git a/packages/cli/src/commands/contrast-bg.ts b/packages/cli/src/commands/contrast-bg.ts
index fa221a2d9a..778a9e64ae 100644
--- a/packages/cli/src/commands/contrast-bg.ts
+++ b/packages/cli/src/commands/contrast-bg.ts
@@ -20,6 +20,59 @@
export type Rgb = [number, number, number];
export type Rgba = [number, number, number, number];
+/** WCAG relative luminance for an sRGB color. Mirrors contrast-audit.browser.js. */
+export function relativeLuminance(color: Rgb): number {
+ const channel = (value: number) => {
+ const srgb = value / 255;
+ return srgb <= 0.03928 ? srgb / 12.92 : ((srgb + 0.055) / 1.055) ** 2.4;
+ };
+
+ return 0.2126 * channel(color[0]) + 0.7152 * channel(color[1]) + 0.0722 * channel(color[2]);
+}
+
+/** WCAG contrast ratio between two opaque sRGB colors. */
+export function contrastRatio(first: Rgb, second: Rgb): number {
+ const firstLuminance = relativeLuminance(first);
+ const secondLuminance = relativeLuminance(second);
+ const lighter = Math.max(firstLuminance, secondLuminance);
+ const darker = Math.min(firstLuminance, secondLuminance);
+ return (lighter + 0.05) / (darker + 0.05);
+}
+
+/** WCAG AA minimum contrast for body or large text. */
+export function requiredContrastRatio(large: boolean): number {
+ return large ? 3 : 4.5;
+}
+
+/**
+ * Find the nearest passing foreground on the line toward the higher-contrast
+ * pole: white for a dark background, black for a light background.
+ */
+export function suggestCompliantForegroundColor(
+ foreground: Rgb,
+ background: Rgb,
+ requiredRatio: number,
+): Rgb {
+ if (contrastRatio(foreground, background) >= requiredRatio) return [...foreground];
+
+ const black: Rgb = [0, 0, 0];
+ const white: Rgb = [255, 255, 255];
+ const target =
+ contrastRatio(white, background) >= contrastRatio(black, background) ? white : black;
+
+ for (let step = 1; step <= 255; step += 1) {
+ const amount = step / 255;
+ const candidate: Rgb = [
+ Math.round(foreground[0] + (target[0] - foreground[0]) * amount),
+ Math.round(foreground[1] + (target[1] - foreground[1]) * amount),
+ Math.round(foreground[2] + (target[2] - foreground[2]) * amount),
+ ];
+ if (contrastRatio(candidate, background) >= requiredRatio) return candidate;
+ }
+
+ return [...target];
+}
+
/** Parse a CSS `rgb()`/`rgba()` string. Returns null if it is not rgb(a). */
export function parseColorRGBA(color: string | null | undefined): Rgba | null {
const body = /rgba?\(([^)]+)\)/.exec(color ?? "")?.[1];
diff --git a/packages/cli/src/commands/deprecationTestHarness.ts b/packages/cli/src/commands/deprecationTestHarness.ts
new file mode 100644
index 0000000000..bce26290db
--- /dev/null
+++ b/packages/cli/src/commands/deprecationTestHarness.ts
@@ -0,0 +1,115 @@
+// Shared scaffolding for the U5 deprecation tests in inspect.test.ts,
+// layout.test.ts, and validate.test.ts: those commands all fail fast (via a
+// mocked dynamic import) so the tests can assert the shared deprecation
+// envelope (stderr notice, JSON `_meta.deprecated`) without needing a real
+// project or headless Chrome.
+//
+// vi.mock factories are hoisted above imports, so each test file keeps its
+// own thin `vi.mock("", () => someFactory())` call (mocking a module
+// path can't itself be shared across files) but delegates the factory body
+// here.
+import type { ArgsDef, CommandDef } from "citty";
+import { runCommand } from "citty";
+import { expect, vi } from "vitest";
+
+const FAKE_PROJECT = {
+ dir: "/fake-project",
+ name: "fake-project",
+ indexPath: "/fake-project/index.html",
+};
+
+export function resolveProjectMock() {
+ return { resolveProject: vi.fn(() => FAKE_PROJECT) };
+}
+
+export function bundleToSingleHtmlFailureMock() {
+ return {
+ bundleToSingleHtml: vi.fn(async () => {
+ throw new Error("bundling failed (test double)");
+ }),
+ };
+}
+
+export function lintProjectFailureMock() {
+ return {
+ lintProject: vi.fn(async () => {
+ throw new Error("lint failed (test double)");
+ }),
+ };
+}
+
+/**
+ * citty's `meta` is `Resolvable` (object | promise | thunk).
+ * These test files always define it as a synchronous object literal, so
+ * narrow to that shape instead of asserting it with `as`.
+ */
+export function metaDescription(command: CommandDef): string {
+ const meta = command.meta;
+ if (meta && typeof meta === "object" && "description" in meta) {
+ return String(meta.description ?? "");
+ }
+ throw new Error("expected a synchronous meta object");
+}
+
+/**
+ * Run a command with stdout/stderr writes captured (and process.exit /
+ * console.log stubbed so the run stays silent and non-terminating), and
+ * return the captured text for the caller to assert on.
+ */
+export async function runAndCaptureStdio(
+ command: CommandDef,
+ rawArgs: string[] = ["--json"],
+): Promise<{ stderrText: string; stdoutText: string }> {
+ const stderrWrites: string[] = [];
+ const stdoutWrites: string[] = [];
+ vi.spyOn(process.stderr, "write").mockImplementation((chunk: unknown) => {
+ stderrWrites.push(String(chunk));
+ return true;
+ });
+ vi.spyOn(process.stdout, "write").mockImplementation((chunk: unknown) => {
+ stdoutWrites.push(String(chunk));
+ return true;
+ });
+ vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
+ vi.spyOn(console, "log").mockImplementation(() => {});
+
+ await runCommand(command, { rawArgs });
+
+ return { stderrText: stderrWrites.join(""), stdoutText: stdoutWrites.join("") };
+}
+
+/**
+ * Run a command with process.exit stubbed and console.log spied, returning
+ * the first console.log call that looks like a JSON object (the `--json`
+ * failure envelope). Callers assert on definedness/shape themselves, since
+ * that differs slightly per call site.
+ */
+export async function runAndFindJsonLogCall(
+ command: CommandDef,
+ rawArgs: string[] = ["--json"],
+): Promise {
+ vi.spyOn(process.stderr, "write").mockImplementation(() => true);
+ vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
+
+ await runCommand(command, { rawArgs });
+
+ return logSpy.mock.calls.find(([arg]) => typeof arg === "string" && arg.trim().startsWith("{"));
+}
+
+/**
+ * Convenience wrapper: parse the JSON envelope found by runAndFindJsonLogCall.
+ * `parsed` is intentionally left as JSON.parse's inferred `any` (matching
+ * every call site's prior inline `JSON.parse(...)` usage) rather than
+ * annotated `unknown`, since callers assert directly into its shape
+ * (`.ok`, `._meta.deprecated`) the same way the original inline tests did.
+ */
+export async function runAndParseJsonEnvelope(
+ command: CommandDef,
+ rawArgs: string[] = ["--json"],
+) {
+ const jsonCall = await runAndFindJsonLogCall(command, rawArgs);
+ expect(jsonCall).toBeDefined();
+ const parsed = JSON.parse(String(jsonCall?.[0]));
+ return { jsonCall, parsed };
+}
diff --git a/packages/cli/src/commands/init.test.ts b/packages/cli/src/commands/init.test.ts
index e913d61191..33248460be 100644
--- a/packages/cli/src/commands/init.test.ts
+++ b/packages/cli/src/commands/init.test.ts
@@ -29,6 +29,19 @@ function runInit(args: string[]): { status: number; stdout: string; stderr: stri
};
}
+function expectScaffoldedScripts(target: string): void {
+ const pkg = JSON.parse(readFileSync(join(target, "package.json"), "utf-8")) as {
+ scripts?: Record;
+ };
+ expect(pkg.scripts).toMatchObject({
+ dev: "npx --yes hyperframes preview",
+ check: "npx --yes hyperframes check",
+ render: "npx --yes hyperframes render",
+ publish: "npx --yes hyperframes publish",
+ });
+ expect(Object.keys(pkg.scripts ?? {}).sort()).toEqual(["check", "dev", "publish", "render"]);
+}
+
describe("hyperframes init flag rename", () => {
it("--example blank scaffolds a bundled project with npm scripts", () => {
const dir = mkdtempSync(join(tmpdir(), "hf-init-test-"));
@@ -44,18 +57,10 @@ describe("hyperframes init flag rename", () => {
const pkg = JSON.parse(readFileSync(join(target, "package.json"), "utf-8")) as {
private?: boolean;
type?: string;
- scripts?: Record;
};
expect(pkg.private).toBe(true);
expect(pkg.type).toBe("module");
- expect(pkg.scripts).toMatchObject({
- dev: "npx --yes hyperframes preview",
- check:
- "npx --yes hyperframes lint && npx --yes hyperframes validate && npx --yes hyperframes inspect",
- render: "npx --yes hyperframes render",
- publish: "npx --yes hyperframes publish",
- });
- expect(Object.keys(pkg.scripts ?? {}).sort()).toEqual(["check", "dev", "publish", "render"]);
+ expectScaffoldedScripts(target);
} finally {
rmSync(dir, { recursive: true, force: true });
}
@@ -79,17 +84,7 @@ describe("hyperframes init flag rename", () => {
expect(html).toContain(tailwindScript);
expect(html).toContain("window.__tailwindReady");
- const pkg = JSON.parse(readFileSync(join(target, "package.json"), "utf-8")) as {
- scripts?: Record;
- };
- expect(pkg.scripts).toMatchObject({
- dev: "npx --yes hyperframes preview",
- check:
- "npx --yes hyperframes lint && npx --yes hyperframes validate && npx --yes hyperframes inspect",
- render: "npx --yes hyperframes render",
- publish: "npx --yes hyperframes publish",
- });
- expect(Object.keys(pkg.scripts ?? {}).sort()).toEqual(["check", "dev", "publish", "render"]);
+ expectScaffoldedScripts(target);
} finally {
rmSync(dir, { recursive: true, force: true });
}
diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts
index 3866a4fbc9..9a8ed3cbe8 100644
--- a/packages/cli/src/commands/init.ts
+++ b/packages/cli/src/commands/init.ts
@@ -1,3 +1,8 @@
+// The scaffolding command predates the complexity gate: run(), probeVideo,
+// handleVideoFile, and applyResolutionPreset carry its interactive branching.
+// This branch only repointed the scaffolded npm scripts; the refactor is its
+// own task.
+// fallow-ignore-file complexity
import { defineCommand, runCommand } from "citty";
import type { Example } from "./_examples.js";
@@ -224,9 +229,7 @@ function hyperframesScript(command: string): string {
function buildPackageScripts(): Record {
return {
dev: hyperframesScript("preview"),
- check:
- `${hyperframesScript("lint")} && ${hyperframesScript("validate")} && ` +
- `${hyperframesScript("inspect")}`,
+ check: hyperframesScript("check"),
render: hyperframesScript("render"),
publish: hyperframesScript("publish"),
};
diff --git a/packages/cli/src/commands/inspect.test.ts b/packages/cli/src/commands/inspect.test.ts
new file mode 100644
index 0000000000..22fd6d0277
--- /dev/null
+++ b/packages/cli/src/commands/inspect.test.ts
@@ -0,0 +1,33 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import {
+ bundleToSingleHtmlFailureMock,
+ metaDescription,
+ resolveProjectMock,
+ runAndCaptureStdio,
+} from "./deprecationTestHarness.js";
+
+// See layout.test.ts for why these two dynamic-import targets are mocked:
+// resolveProject skips real filesystem resolution, and bundleToSingleHtml
+// gives a fast, deterministic failure that exercises run()'s outer catch
+// (the JSON failure envelope) without needing headless Chrome.
+vi.mock("../utils/project.js", () => resolveProjectMock());
+vi.mock("@hyperframes/core/compiler", () => bundleToSingleHtmlFailureMock());
+
+import inspectCommand from "./inspect.js";
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe("inspect command deprecation (U5)", () => {
+ it("is the compatibility alias for layout, sharing its deprecated description", () => {
+ expect(metaDescription(inspectCommand)).toContain("(deprecated, use check)");
+ });
+
+ it("prints a one-line deprecation notice naming 'inspect' on stderr, never stdout", async () => {
+ const { stderrText, stdoutText } = await runAndCaptureStdio(inspectCommand);
+ expect(stderrText).toContain("hyperframes inspect");
+ expect(stderrText).toContain("hyperframes check");
+ expect(stdoutText).toBe("");
+ });
+});
diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js
index c67ed809e3..f939a9dcee 100644
--- a/packages/cli/src/commands/layout-audit.browser.js
+++ b/packages/cli/src/commands/layout-audit.browser.js
@@ -81,6 +81,22 @@
return `${selectorFor(parent)} > ${element.tagName.toLowerCase()}:nth-of-type(${index})`;
}
+ function uniqueSelectorFor(element) {
+ const preferred = selectorFor(element);
+ try {
+ if (document.querySelectorAll(preferred).length === 1) return preferred;
+ } catch {
+ // Fall through to a structural selector.
+ }
+ const parent = element.parentElement;
+ if (!parent) return preferred;
+ const siblings = Array.from(parent.children).filter(
+ (child) => child.tagName === element.tagName,
+ );
+ const index = siblings.indexOf(element) + 1;
+ return `${uniqueSelectorFor(parent)} > ${element.tagName.toLowerCase()}:nth-of-type(${index})`;
+ }
+
function hasIgnoreFlag(element) {
return !!element.closest("[data-layout-ignore], [data-layout-check='ignore']");
}
@@ -98,6 +114,14 @@
return opacity;
}
+ function hasOpacityBelow(element, floor) {
+ for (let current = element; current; current = current.parentElement) {
+ const parsed = Number.parseFloat(getComputedStyle(current).opacity || "1");
+ if (Number.isFinite(parsed) && parsed < floor) return true;
+ }
+ return false;
+ }
+
// A clip-path can shrink an element's painted region to nothing (e.g. a
// typewriter span pre-reveal at `inset(0 100% 0 0)`, or `circle(0px)`) while
// its layout box, opacity, visibility and display all still read as present.
@@ -142,9 +166,20 @@
return !paintsAnyProbePoint(element, rect);
}
- function isVisibleElement(element) {
+ function isVisibleElement(element, opacityFloor, probeClipPath) {
if (IGNORE_TAGS.has(element.tagName)) return false;
if (hasIgnoreFlag(element)) return false;
+ if (
+ opacityFloor != null &&
+ typeof element.checkVisibility === "function" &&
+ !element.checkVisibility({
+ opacityProperty: true,
+ visibilityProperty: true,
+ contentVisibilityAuto: true,
+ })
+ ) {
+ return false;
+ }
const style = getComputedStyle(element);
if (
style.display === "none" ||
@@ -153,32 +188,57 @@
) {
return false;
}
- if (opacityChain(element) < 0.2) return false;
+ if (
+ opacityFloor == null ? opacityChain(element) < 0.2 : hasOpacityBelow(element, opacityFloor)
+ ) {
+ return false;
+ }
const rect = element.getBoundingClientRect();
if (rect.width <= 0.5 || rect.height <= 0.5) return false;
- return !isClippedAway(element);
+ return probeClipPath === false || !isClippedAway(element);
}
- function textContentFor(element) {
- return (element.innerText || element.textContent || "").replace(/\s+/g, " ").trim();
+ function directTextNodes(element) {
+ return Array.from(element.childNodes).filter((node) => node.nodeType === 3);
}
- function hasOwnTextCandidate(element) {
- const text = textContentFor(element);
+ function textContentFor(element, ownTextOnly) {
+ const content = ownTextOnly
+ ? directTextNodes(element)
+ .map((node) => node.textContent || "")
+ .join("")
+ : element.innerText || element.textContent || "";
+ return content.replace(/\s+/g, " ").trim();
+ }
+
+ function hasOwnTextCandidate(element, directOnly) {
+ const text = textContentFor(element, directOnly);
if (!text) return false;
+ if (directOnly) return true;
for (const child of Array.from(element.children)) {
if (isVisibleElement(child) && textContentFor(child)) return false;
}
return true;
}
- function textRectFor(element) {
- const range = document.createRange();
- range.selectNodeContents(element);
- const rects = Array.from(range.getClientRects()).filter(
- (rect) => rect.width > 0.5 && rect.height > 0.5,
- );
- range.detach();
+ function textClientRects(element, directOnly) {
+ const subjects = directOnly ? directTextNodes(element) : [element];
+ const rects = [];
+ for (const subject of subjects) {
+ const range = document.createRange();
+ range.selectNodeContents(subject);
+ rects.push(
+ ...Array.from(range.getClientRects()).filter(
+ (rect) => rect.width > 0.5 && rect.height > 0.5,
+ ),
+ );
+ range.detach();
+ }
+ return rects;
+ }
+
+ function textRectFor(element, directOnly) {
+ const rects = textClientRects(element, directOnly);
if (rects.length === 0) return null;
const union = rects.reduce(
@@ -567,9 +627,12 @@
const area = intersectionArea(a.rect, b.rect);
if (area <= Math.min(rectArea(a.rect), rectArea(b.rect)) * 0.2) return null;
return {
- // Warning, not error: must not fail the exit code (ok = errorCount === 0)
- // for compositions that intentionally layer text. Re-promote once the
- // data-layout-allow-overlap opt-out is widely adopted.
+ // Warning at the per-sample level: a single-sample overlap is usually an
+ // entrance/exit transient (two blocks crossing mid-animation), not a real
+ // collision. `collapseStaticLayoutIssues` (utils/layoutAudit.ts) re-promotes
+ // this to error once the SAME overlap is held across >= 2 adjacent samples
+ // (or ~500ms of timeline) — a persistence-tiered replacement for the old
+ // "re-promote once data-layout-allow-overlap is widely adopted" plan (#U10).
code: "content_overlap",
severity: "warning",
time,
@@ -602,6 +665,7 @@
}
const RASTER_TAGS = new Set(["IMG", "VIDEO", "CANVAS"]);
+ const FRAME_MEDIA_TAGS = new Set([...RASTER_TAGS, "SVG"]);
// An element hides text beneath it when it paints opaque pixels at near-full
// opacity: raster content (img/video/canvas), a background image, or a solid
@@ -667,42 +731,135 @@
return hit;
}
+ const OCCLUSION_PROBE_Y_FRACTIONS = [0.25, 0.5, 0.75];
+ const OCCLUSION_PROBE_X_FRACTIONS = [0.03, 0.1, 0.2, 0.35, 0.5, 0.65, 0.8, 0.9, 0.97];
+ const OCCLUSION_GRID_POINTS =
+ OCCLUSION_PROBE_Y_FRACTIONS.length * OCCLUSION_PROBE_X_FRACTIONS.length;
+
+ // Short, atomic text (a label/button/word, no whitespace) reads as a single
+ // unit — ANY covered probe point changes what it says, so flag at any hit
+ // (the pre-#U10 behaviour). Longer prose survives a nibbled edge; only flag
+ // once a real share of it is covered — see `occludedTextIssue`.
+ const ATOMIC_LABEL_MAX_CHARS = 16;
+ const PROSE_COVERAGE_FLOOR = 0.15;
+
+ function isAtomicLabel(text) {
+ return text.length > 0 && text.length <= ATOMIC_LABEL_MAX_CHARS && !/\s/.test(text);
+ }
+
// Sweep a grid across the text box (three rows, not just the mid-line, so
- // overlays covering only part of a multi-line block are caught) and return
- // the first opaque element painted over any sample point.
- function firstOccluder(element, textRect) {
- for (const yFraction of [0.25, 0.5, 0.75]) {
+ // overlays covering only part of a multi-line block are caught). Unlike a
+ // first-hit scan, this keeps sampling every point so it can report what
+ // fraction of the box is actually covered — a corner nibble on a paragraph
+ // reads very differently from a label buried under an overlay. Still
+ // returns the first opaque element found, for `containerSelector`.
+ function occlusionCoverage(element, textRect) {
+ let occluder = null;
+ let hits = 0;
+ for (const yFraction of OCCLUSION_PROBE_Y_FRACTIONS) {
const y = textRect.top + textRect.height * yFraction;
- for (const xFraction of [0.03, 0.1, 0.2, 0.35, 0.5, 0.65, 0.8, 0.9, 0.97]) {
- const occluder = occluderAt(element, textRect.left + textRect.width * xFraction, y);
- if (occluder) return occluder;
+ for (const xFraction of OCCLUSION_PROBE_X_FRACTIONS) {
+ const hit = occluderAt(element, textRect.left + textRect.width * xFraction, y);
+ if (!hit) continue;
+ hits += 1;
+ if (!occluder) occluder = hit;
}
}
- return null;
+ return { occluder, coveredFraction: round(hits / OCCLUSION_GRID_POINTS) };
}
// Catches the blind spot the overflow checks miss: text that fits its box
- // perfectly but is covered by a later sibling/overlay.
+ // perfectly but is covered by a later sibling/overlay. An atomic label
+ // (short, no whitespace) flags at any coverage; ordinary prose only flags
+ // once coveredFraction clears PROSE_COVERAGE_FLOOR, since a sliver of edge
+ // cover on a paragraph is usually a styling artifact, not a reading defect.
function occludedTextIssue(element, time) {
if (hasAllowOcclusionFlag(element)) return null;
const textRect = textRectFor(element);
if (!textRect) return null;
- const occluder = firstOccluder(element, textRect);
+ const text = textContentFor(element);
+ const { occluder, coveredFraction } = occlusionCoverage(element, textRect);
if (!occluder) return null;
+ if (!isAtomicLabel(text) && coveredFraction < PROSE_COVERAGE_FLOOR) return null;
return {
code: "text_occluded",
severity: "error",
time,
selector: selectorFor(element),
containerSelector: selectorFor(occluder),
- text: textContentFor(element),
+ text,
message: "Text is hidden beneath an opaque element.",
rect: textRect,
+ coveredFraction,
fixHint:
"Give the text its own zone, raise its stacking order above the covering element, or mark intentional layering with data-layout-allow-occlusion.",
};
}
+ function candidateAnchor(element) {
+ const dataAttributes = {};
+ 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: uniqueSelectorFor(element),
+ dataAttributes,
+ sourceFile: source || "index.html",
+ };
+ }
+
+ function geometryCandidate(element, kind, rect, elementRect, rootRect, tolerance) {
+ const tag = element.tagName.toLowerCase();
+ const text = kind === "text" ? textContentFor(element, true) : tag;
+ const overflow = kind === "media" ? overflowFor(elementRect, rootRect, tolerance) : null;
+ return {
+ kind,
+ tag,
+ text,
+ rect,
+ elementRect,
+ ...candidateAnchor(element),
+ ...(overflow ? { overflow } : {}),
+ };
+ }
+
+ window.__hyperframesGeometryCandidates = function collectGeometryCandidates(options) {
+ const includeText = options?.text === true;
+ const includeMedia = options?.media === true;
+ if (!includeText && !includeMedia) return [];
+ const tolerance = typeof options?.tolerance === "number" ? options.tolerance : 2;
+ const root =
+ document.querySelector("[data-composition-id][data-width][data-height]") ||
+ document.querySelector("[data-composition-id]") ||
+ document.body;
+ const rootRect = rootRectFor(root);
+ const candidates = [];
+ for (const element of Array.from(document.querySelectorAll("body *"))) {
+ if (element.closest('[data-composition-id="captions"], .caption-layer, #caption-stage')) {
+ continue;
+ }
+ if (!isVisibleElement(element, 0.05, false)) continue;
+ const elementRect = toRect(element.getBoundingClientRect());
+ if (includeText && hasOwnTextCandidate(element, true)) {
+ const rect = textRectFor(element, true);
+ if (rect) {
+ candidates.push(
+ geometryCandidate(element, "text", rect, elementRect, rootRect, tolerance),
+ );
+ }
+ }
+ if (includeMedia && FRAME_MEDIA_TAGS.has(element.tagName.toUpperCase())) {
+ candidates.push(
+ geometryCandidate(element, "media", elementRect, elementRect, rootRect, tolerance),
+ );
+ }
+ }
+ return candidates;
+ };
+
window.__hyperframesLayoutAudit = function auditLayout(options) {
const time = options && typeof options.time === "number" ? options.time : 0;
const tolerance =
@@ -712,7 +869,9 @@
document.querySelector("[data-composition-id]") ||
document.body;
const rootRect = rootRectFor(root);
- const elements = Array.from(root.querySelectorAll("*")).filter(isVisibleElement);
+ const elements = Array.from(root.querySelectorAll("*")).filter((element) =>
+ isVisibleElement(element),
+ );
const issues = [];
for (const element of elements) {
@@ -728,4 +887,28 @@
issues.push(...contentOverlapIssues(root, time));
return issues;
};
+
+ // Frozen-sweep guard (#U10, checkPipeline.ts): a compact per-sample
+ // fingerprint of every visible element's box + opacity, in DOM order. Node
+ // calls this once per seeked grid point and compares the strings across the
+ // whole run — if every sample produces the identical string, the seek never
+ // actually moved anything and the whole audit run is unreliable. Deliberately
+ // a single opaque string (not a structured array) since Node only ever needs
+ // equality, not per-element diffing.
+ window.__hyperframesLayoutGeometry = function collectLayoutGeometry() {
+ const root =
+ document.querySelector("[data-composition-id][data-width][data-height]") ||
+ document.querySelector("[data-composition-id]") ||
+ document.body;
+ const elements = Array.from(root.querySelectorAll("*")).filter((element) =>
+ isVisibleElement(element),
+ );
+ return elements
+ .map((element) => {
+ const rect = toRect(element.getBoundingClientRect());
+ const opacity = round(opacityChain(element));
+ return `${rect.left},${rect.top},${rect.width},${rect.height},${opacity}`;
+ })
+ .join("|");
+ };
})();
diff --git a/packages/cli/src/commands/layout-audit.browser.test.ts b/packages/cli/src/commands/layout-audit.browser.test.ts
index 061e18da99..821bad8c14 100644
--- a/packages/cli/src/commands/layout-audit.browser.test.ts
+++ b/packages/cli/src/commands/layout-audit.browser.test.ts
@@ -15,11 +15,20 @@ interface RectInput {
height: number;
}
+afterEach(() => {
+ vi.restoreAllMocks();
+ document.body.innerHTML = "";
+ Reflect.deleteProperty(document, "elementFromPoint");
+ Reflect.deleteProperty(window, "__hyperframesLayoutAudit");
+ clearGeometryCollector();
+});
+
describe("layout-audit.browser", () => {
afterEach(() => {
vi.restoreAllMocks();
document.body.innerHTML = "";
delete (window as unknown as { __hyperframesLayoutAudit?: unknown }).__hyperframesLayoutAudit;
+ clearGeometryCollector();
});
it("uses authored canvas dimensions when the root bounding rect is degenerate", () => {
@@ -135,6 +144,206 @@ describe("layout-audit.browser", () => {
expect(runAudit().some((issue) => issue.code === "text_box_overflow")).toBe(true);
});
+
+ it("keeps auditing visible descendants beyond the second element", () => {
+ document.body.innerHTML = `
+
+
+
+
+ Late visible copy
+
+ `;
+ installGeometry({
+ root: rect({ left: 0, top: 0, width: 640, height: 360 }),
+ late: rect({ left: 700, top: 100, width: 140, height: 40 }),
+ text: rect({ left: 700, top: 100, width: 140, height: 40 }),
+ });
+ installAuditScript();
+
+ expect(runAudit()).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ code: "canvas_overflow", selector: "#late" }),
+ ]),
+ );
+ });
+});
+
+it("is inert unless text or media candidates are explicitly requested", () => {
+ document.body.innerHTML = `
+
+ Visible copy
+
+ `;
+ installGeometry({
+ root: rect({ left: 0, top: 0, width: 640, height: 360 }),
+ copy: rect({ left: 100, top: 100, width: 200, height: 40 }),
+ text: rect({ left: 100, top: 100, width: 200, height: 40 }),
+ });
+ installAuditScript();
+
+ expect(runGeometryCandidates({ text: false, media: false, tolerance: 2 })).toEqual([]);
+});
+
+it("returns own-text rects and media overflow while excluding caption layers", () => {
+ document.body.innerHTML = `
+
+
+ Own copy Nested
+
+
+
+ Authored captions
+
+ `;
+ installGeometry({
+ root: rect({ left: 0, top: 0, width: 640, height: 360 }),
+ copy: rect({ left: 100, top: 260, width: 180, height: 40 }),
+ headline: rect({ left: 100, top: 260, width: 180, height: 40 }),
+ nested: rect({ left: 220, top: 260, width: 60, height: 40 }),
+ image: rect({ left: 600, top: 40, width: 200, height: 100 }),
+ vector: rect({ left: -130, top: 160, width: 100, height: 100 }),
+ caption: rect({ left: 200, top: 300, width: 240, height: 40 }),
+ text: rect({ left: 100, top: 260, width: 100, height: 40 }),
+ });
+ installAuditScript();
+
+ const candidates = runGeometryCandidates({ text: true, media: true, tolerance: 2 });
+
+ expect(candidates).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ kind: "text",
+ tag: "div",
+ text: "Own copy",
+ selector: "#copy",
+ sourceFile: "scenes/hero.html",
+ rect: { left: 100, top: 260, right: 200, bottom: 300, width: 100, height: 40 },
+ elementRect: { left: 100, top: 260, right: 280, bottom: 300, width: 180, height: 40 },
+ }),
+ expect.objectContaining({
+ kind: "media",
+ tag: "img",
+ selector: "#image",
+ overflow: { right: 160 },
+ }),
+ expect.objectContaining({
+ kind: "media",
+ tag: "svg",
+ selector: "#vector",
+ overflow: { left: 130 },
+ }),
+ ]),
+ );
+ expect(candidates.some((candidate) => candidate.selector === "#caption")).toBe(false);
+});
+
+it("scans body-level composition siblings and includes a media boundary root", () => {
+ document.body.innerHTML = `
+
+
+ Portal copy
+
+
+ `;
+ installGeometry({
+ root: rect({ left: 0, top: 0, width: 640, height: 360 }),
+ "portal-copy": rect({ left: 100, top: 260, width: 180, height: 40 }),
+ "portal-image": rect({ left: 600, top: 80, width: 180, height: 100 }),
+ text: rect({ left: 100, top: 260, width: 180, height: 40 }),
+ });
+ installAuditScript();
+
+ const candidates = runGeometryCandidates({ text: true, media: true, tolerance: 2 });
+
+ expect(candidates.map((candidate) => candidate.selector)).toEqual(
+ expect.arrayContaining(["#boundary", "#portal-copy", "#portal-image"]),
+ );
+});
+
+it("returns unique structural selectors for repeated class-only media", () => {
+ document.body.innerHTML = `
+
+
+
+
+ `;
+ installGeometry({
+ root: rect({ left: 0, top: 0, width: 640, height: 360 }),
+ "": rect({ left: 100, top: 100, width: 100, height: 100 }),
+ });
+ installAuditScript();
+
+ const candidates = runGeometryCandidates({ text: false, media: true, tolerance: 2 });
+ const images = Array.from(document.querySelectorAll("img"));
+
+ expect(candidates).toHaveLength(2);
+ expect(new Set(candidates.map((candidate) => candidate.selector)).size).toBe(2);
+ expect(document.querySelector(candidates[0]?.selector ?? "")).toBe(images[0]);
+ expect(document.querySelector(candidates[1]?.selector ?? "")).toBe(images[1]);
+});
+
+it("keeps visible clip-path text when pointer events do not participate in hit testing", () => {
+ document.body.innerHTML = `
+
+ Visible clipped copy
+
+ `;
+ installGeometry(
+ {
+ root: rect({ left: 0, top: 0, width: 640, height: 360 }),
+ "clipped-copy": rect({ left: 100, top: 100, width: 200, height: 40 }),
+ text: rect({ left: 100, top: 100, width: 200, height: 40 }),
+ },
+ { "clipped-copy": { clipPath: "inset(0 10% 0 0)", pointerEvents: "none" } },
+ );
+ Reflect.set(
+ document,
+ "elementFromPoint",
+ vi.fn(() => document.getElementById("root")),
+ );
+ installAuditScript();
+
+ const candidates = runGeometryCandidates({ text: true, media: false, tolerance: 2 });
+
+ expect(candidates).toEqual(
+ expect.arrayContaining([expect.objectContaining({ selector: "#clipped-copy" })]),
+ );
+});
+
+it("uses the bridge opacity floor across the ancestor chain", () => {
+ document.body.innerHTML = `
+
+
+ Visible copy
+ Stacked opacity copy
+
+ `;
+ installGeometry(
+ {
+ root: rect({ left: 0, top: 0, width: 640, height: 360 }),
+ "faint-parent": rect({ left: 40, top: 40, width: 200, height: 40 }),
+ "hidden-copy": rect({ left: 40, top: 40, width: 200, height: 40 }),
+ "soft-parent": rect({ left: 40, top: 120, width: 200, height: 40 }),
+ "visible-copy": rect({ left: 40, top: 120, width: 200, height: 40 }),
+ "stacked-parent": rect({ left: 40, top: 200, width: 200, height: 40 }),
+ "stacked-copy": rect({ left: 40, top: 200, width: 200, height: 40 }),
+ text: rect({ left: 40, top: 120, width: 200, height: 40 }),
+ },
+ {
+ "faint-parent": { opacity: "0.04" },
+ "soft-parent": { opacity: "0.1" },
+ "stacked-parent": { opacity: "0.2" },
+ "stacked-copy": { opacity: "0.2" },
+ },
+ );
+ installAuditScript();
+
+ const candidates = runGeometryCandidates({ text: true, media: false, tolerance: 2 });
+
+ expect(candidates.some((candidate) => candidate.selector === "#hidden-copy")).toBe(false);
+ expect(candidates.some((candidate) => candidate.selector === "#visible-copy")).toBe(true);
+ expect(candidates.some((candidate) => candidate.selector === "#stacked-copy")).toBe(true);
});
describe("layout-audit.browser content overlap", () => {
@@ -143,6 +352,7 @@ describe("layout-audit.browser content overlap", () => {
document.body.innerHTML = "";
delete (document as unknown as { elementFromPoint?: unknown }).elementFromPoint;
delete (window as unknown as { __hyperframesLayoutAudit?: unknown }).__hyperframesLayoutAudit;
+ clearGeometryCollector();
});
it("flags two solid text blocks that overlap", () => {
@@ -246,6 +456,84 @@ describe("contrast-audit.browser clip-path visibility", () => {
expect(await runContrastAudit()).toEqual([]);
});
+
+ it("excludes data-layout-ignore set dressing from contrast reports", async () => {
+ document.body.innerHTML = `
+
+
+ SHAPE
+
+ Readable copy
+
+ `;
+
+ vi.spyOn(window, "getComputedStyle").mockImplementation(
+ () =>
+ ({
+ display: "block",
+ visibility: "visible",
+ opacity: "1",
+ color: "rgb(30, 30, 42)",
+ fontSize: "32px",
+ fontWeight: "400",
+ clipPath: "none",
+ }) as unknown as CSSStyleDeclaration,
+ );
+ for (const id of ["rail-label", "headline"]) {
+ vi.spyOn(document.getElementById(id)!, "getBoundingClientRect").mockReturnValue(
+ rect({ left: 100, top: id === "headline" ? 200 : 100, width: 400, height: 40 }),
+ );
+ }
+ (document as unknown as { elementFromPoint: () => Element | null }).elementFromPoint = () =>
+ null;
+
+ installContrastScript();
+
+ const entries = await runContrastAudit();
+ const selectors = entries.map((entry) => entry.selector);
+ expect(selectors).toContain("#headline");
+ expect(selectors).not.toContain("#rail-label");
+ });
+
+ it("excludes text that has left the canvas from contrast reports", async () => {
+ document.body.innerHTML = `
+
+ You
+ Readable copy
+
+ `;
+
+ vi.spyOn(window, "getComputedStyle").mockImplementation(
+ () =>
+ ({
+ display: "block",
+ visibility: "visible",
+ opacity: "1",
+ color: "rgb(255, 255, 255)",
+ fontSize: "32px",
+ fontWeight: "400",
+ clipPath: "none",
+ }) as unknown as CSSStyleDeclaration,
+ );
+ Object.defineProperty(window, "innerWidth", { configurable: true, value: 640 });
+ Object.defineProperty(window, "innerHeight", { configurable: true, value: 360 });
+ // The cursor-exit shape: element parked far past the top-left corner.
+ vi.spyOn(document.getElementById("exited")!, "getBoundingClientRect").mockReturnValue(
+ rect({ left: -1420, top: -500, width: 60, height: 24 }),
+ );
+ vi.spyOn(document.getElementById("headline")!, "getBoundingClientRect").mockReturnValue(
+ rect({ left: 100, top: 200, width: 400, height: 40 }),
+ );
+ (document as unknown as { elementFromPoint: () => Element | null }).elementFromPoint = () =>
+ null;
+
+ installContrastScript();
+
+ const entries = await runContrastAudit();
+ const selectors = entries.map((entry) => entry.selector);
+ expect(selectors).toContain("#headline");
+ expect(selectors).not.toContain("#exited");
+ });
});
describe("contrast-audit.browser background sampling", () => {
@@ -404,6 +692,7 @@ describe("layout-audit.browser occlusion", () => {
document.body.innerHTML = "";
delete (document as unknown as { elementFromPoint?: unknown }).elementFromPoint;
delete (window as unknown as { __hyperframesLayoutAudit?: unknown }).__hyperframesLayoutAudit;
+ clearGeometryCollector();
});
it("flags text painted over by an opaque sibling overlay", () => {
@@ -440,8 +729,92 @@ describe("layout-audit.browser occlusion", () => {
});
expect(issues.some((issue) => issue.code === "text_occluded")).toBe(false);
});
+
+ it("carries the fully-covered fraction when the occluder hits every probe point", () => {
+ const occluded = auditOcclusionScene({
+ overlayStyle: { backgroundColor: "rgb(10, 10, 10)" },
+ topmostId: "overlay",
+ }).find((issue) => issue.code === "text_occluded");
+ expect(occluded?.coveredFraction).toBe(1);
+ });
+
+ // #U10: a 2-point hit on the 27-point probe grid (3 rows x 9 columns) is a
+ // sliver of edge cover — reports ~0.07 coverage either way, but only GATES
+ // (produces a finding) for short atomic labels; ordinary prose survives it.
+ it("reports ~0.07 coverage for a 2-of-27 grid hit and flags an atomic label at that coverage", () => {
+ const issues = auditCoverageScene({ text: "SUBSCRIBE", hitCount: 2 });
+ const occluded = issues.find((issue) => issue.code === "text_occluded");
+ expect(occluded).toBeDefined();
+ expect(occluded?.coveredFraction).toBe(0.07);
+ });
+
+ it("does not flag ordinary prose at the same ~0.07 coverage a label would flag at", () => {
+ const issues = auditCoverageScene({
+ text: "This paragraph is long enough to read as ordinary prose, not a label.",
+ hitCount: 2,
+ });
+ expect(issues.some((issue) => issue.code === "text_occluded")).toBe(false);
+ });
+
+ it("flags prose once coverage clears the 0.15 floor", () => {
+ // 5/27 ≈ 0.185, comfortably over the ~0.15 prose floor.
+ const issues = auditCoverageScene({
+ text: "This paragraph is long enough to read as ordinary prose, not a label.",
+ hitCount: 5,
+ });
+ expect(issues.some((issue) => issue.code === "text_occluded")).toBe(true);
+ });
});
+// Mirrors OCCLUSION_PROBE_Y_FRACTIONS / OCCLUSION_PROBE_X_FRACTIONS in
+// layout-audit.browser.js, so a test can force an exact number of grid hits
+// against the same probe coordinates the audit itself sweeps.
+const OCCLUSION_PROBE_Y_FRACTIONS = [0.25, 0.5, 0.75];
+const OCCLUSION_PROBE_X_FRACTIONS = [0.03, 0.1, 0.2, 0.35, 0.5, 0.65, 0.8, 0.9, 0.97];
+
+function occlusionProbePoints(textRect: RectInput): Array<{ x: number; y: number }> {
+ const points: Array<{ x: number; y: number }> = [];
+ for (const yFraction of OCCLUSION_PROBE_Y_FRACTIONS) {
+ const y = textRect.top + textRect.height * yFraction;
+ for (const xFraction of OCCLUSION_PROBE_X_FRACTIONS) {
+ points.push({ x: textRect.left + textRect.width * xFraction, y });
+ }
+ }
+ return points;
+}
+
+// Builds an occlusion scene where exactly `hitCount` of the 27 probe points
+// are covered by an opaque overlay and the rest hit the headline itself
+// (self-hit — not foreign, so not counted as occluded).
+function auditCoverageScene(options: {
+ text: string;
+ hitCount: number;
+}): ReturnType {
+ const textRect = { left: 200, top: 500, width: 600, height: 80 };
+ document.body.innerHTML = `
+
+ ${options.text}
+
+
+ `;
+ installOcclusionGeometry({
+ styleOverrides: { overlay: { backgroundColor: "rgb(10, 10, 10)" } },
+ headlineTextRect: rect(textRect),
+ topmostId: "headline",
+ });
+ const hitPoints = occlusionProbePoints(textRect).slice(0, options.hitCount);
+ (
+ document as unknown as { elementFromPoint: (x: number, y: number) => Element | null }
+ ).elementFromPoint = (x, y) => {
+ const isHit = hitPoints.some(
+ (point) => Math.abs(point.x - x) < 0.01 && Math.abs(point.y - y) < 0.01,
+ );
+ return document.getElementById(isHit ? "overlay" : "headline");
+ };
+ installAuditScript();
+ return runAudit();
+}
+
function auditOcclusionScene(options: {
headlineAttrs?: string;
overlayStyle: Partial>;
@@ -601,28 +974,31 @@ async function runContrastAudit(): Promise>> {
return w.__contrastAuditFinish("stub", 0, candidates);
}
-function runAudit(): Array<{
+interface AuditIssue {
code: string;
selector: string;
containerSelector?: string;
overflow?: Record;
message?: string;
-}> {
+ coveredFraction?: number;
+}
+
+function runAudit(): AuditIssue[] {
const audit = (
window as unknown as {
- __hyperframesLayoutAudit: (options: { time: number; tolerance: number }) => Array<{
- code: string;
- selector: string;
- containerSelector?: string;
- overflow?: Record;
- message?: string;
- }>;
+ __hyperframesLayoutAudit: (options: { time: number; tolerance: number }) => AuditIssue[];
}
).__hyperframesLayoutAudit;
return audit({ time: 1, tolerance: 2 });
}
-function installGeometry(rects: Record): void {
+function installGeometry(
+ rects: Record,
+ styleOverrides: Record> = {},
+): void {
+ // Style-fixture branching mirrors the audit's per-property reads; splitting
+ // it would scatter one mock across helpers.
+ // fallow-ignore-next-line complexity
vi.spyOn(window, "getComputedStyle").mockImplementation((element) => {
const el = element as Element;
const isBubble = el.id === "bubble";
@@ -648,6 +1024,7 @@ function installGeometry(rects: Record): void {
paddingBottom: isBubble ? "16px" : "0px",
paddingLeft: isBubble ? "16px" : "0px",
fontSize: "36px",
+ ...styleOverrides[el.id],
} as unknown as CSSStyleDeclaration;
});
@@ -678,6 +1055,41 @@ function installGeometry(rects: Record): void {
});
}
+interface GeometryCandidateResult {
+ kind: "text" | "media";
+ tag: string;
+ text: string;
+ selector: string;
+ sourceFile: string;
+ rect: Record;
+ elementRect: Record;
+ overflow?: Record;
+}
+
+declare global {
+ interface Window {
+ __hyperframesGeometryCandidates?: (options: {
+ text: boolean;
+ media: boolean;
+ tolerance: number;
+ }) => GeometryCandidateResult[];
+ }
+}
+
+function runGeometryCandidates(options: {
+ text: boolean;
+ media: boolean;
+ tolerance: number;
+}): GeometryCandidateResult[] {
+ const collector = window.__hyperframesGeometryCandidates;
+ if (!collector) throw new Error("Geometry collector was not installed");
+ return collector(options);
+}
+
+function clearGeometryCollector(): void {
+ delete window.__hyperframesGeometryCandidates;
+}
+
function rect({ left, top, width, height }: RectInput): DOMRect {
return {
left,
diff --git a/packages/cli/src/commands/layout.test.ts b/packages/cli/src/commands/layout.test.ts
new file mode 100644
index 0000000000..ab63315c5e
--- /dev/null
+++ b/packages/cli/src/commands/layout.test.ts
@@ -0,0 +1,51 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import {
+ bundleToSingleHtmlFailureMock,
+ metaDescription,
+ resolveProjectMock,
+ runAndCaptureStdio,
+ runAndFindJsonLogCall,
+ runAndParseJsonEnvelope,
+} from "./deprecationTestHarness.js";
+
+// resolveProject and bundleToSingleHtml are both reached via a dynamic
+// `await import(...)` inside layout.ts's run() / runLayoutAudit(), so
+// vi.mock intercepts them the same way it would a static import. Mocking
+// resolveProject skips real filesystem project resolution; mocking
+// bundleToSingleHtml gives a deterministic, fast failure well before any
+// real browser or network work — exercising run()'s outer catch (the JSON
+// failure envelope) without needing headless Chrome.
+vi.mock("../utils/project.js", () => resolveProjectMock());
+vi.mock("@hyperframes/core/compiler", () => bundleToSingleHtmlFailureMock());
+
+import { createInspectCommand } from "./layout.js";
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe("layout command deprecation (U5)", () => {
+ it("marks both the layout and inspect command names' shared description as deprecated", () => {
+ expect(metaDescription(createInspectCommand("layout"))).toContain("(deprecated, use check)");
+ expect(metaDescription(createInspectCommand("inspect"))).toContain("(deprecated, use check)");
+ });
+
+ it("prints a one-line deprecation notice to stderr and never to stdout", async () => {
+ const { stderrText, stdoutText } = await runAndCaptureStdio(createInspectCommand("layout"));
+ expect(stderrText).toContain("hyperframes layout");
+ expect(stderrText).toContain("hyperframes check");
+ expect(stdoutText).toBe("");
+ });
+
+ it("--json output is valid JSON with _meta.deprecated === true on failure", async () => {
+ const { parsed } = await runAndParseJsonEnvelope(createInspectCommand("layout"));
+ expect(parsed.ok).toBe(false);
+ expect(parsed._meta.deprecated).toBe(true);
+ });
+
+ it("the inspect command name produces the same _meta.deprecated === true envelope", async () => {
+ const jsonCall = await runAndFindJsonLogCall(createInspectCommand("inspect"));
+ const parsed = JSON.parse(String(jsonCall?.[0]));
+ expect(parsed._meta.deprecated).toBe(true);
+ });
+});
diff --git a/packages/cli/src/commands/layout.ts b/packages/cli/src/commands/layout.ts
index 34a70eb5f4..a7025d787e 100644
--- a/packages/cli/src/commands/layout.ts
+++ b/packages/cli/src/commands/layout.ts
@@ -7,7 +7,7 @@ import { c } from "../ui/colors.js";
import { resolveProject } from "../utils/project.js";
import { normalizeErrorMessage } from "../utils/errorMessage.js";
import { serveStaticProjectHtml } from "../utils/staticProjectServer.js";
-import { withMeta } from "../utils/updateCheck.js";
+import { printDeprecationNotice, withMeta } from "../utils/updateCheck.js";
import {
buildLayoutSampleTimes,
buildTransitionSampleTimes,
@@ -26,10 +26,17 @@ import {
type MotionFrame,
} from "../utils/motionAudit.js";
import { findMotionSpec, readMotionSpec, type MotionSpec } from "../utils/motionSpec.js";
+import {
+ AUDIT_SEEK_OPTIONS,
+ installPageFunctionGuard,
+ seekCompositionTimeline,
+ waitForCompositionFonts,
+ type SeekCompositionTimelineOptions,
+} from "../capture/captureCompositionFrame.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
-const SEEK_SETTLE_MS = 120;
+const LAYOUT_SEEK_OPTIONS: SeekCompositionTimelineOptions = AUDIT_SEEK_OPTIONS;
// All new envelope fields are optional (?); additive changes don't bump this.
const INSPECT_SCHEMA_VERSION = 1;
// Motion verification (#1437): dense sampling grid for the seeked-timeline checks.
@@ -68,6 +75,8 @@ function buildMotionSampleTimes(duration: number): number[] {
}
async function getCompositionDuration(page: import("puppeteer-core").Page): Promise {
+ // Serialized into the page; the duration-source cascade cannot be split.
+ // fallow-ignore-next-line complexity
return page.evaluate(() => {
const win = window as unknown as {
__hf?: { duration?: number };
@@ -96,52 +105,6 @@ async function getCompositionDuration(page: import("puppeteer-core").Page): Prom
});
}
-async function waitForFonts(page: import("puppeteer-core").Page, timeoutMs: number): Promise {
- await page
- .evaluate((ms: number) => {
- const fonts = (document as Document & { fonts?: FontFaceSet }).fonts;
- if (!fonts?.ready) return Promise.resolve();
- return Promise.race([
- fonts.ready.then(() => undefined),
- new Promise((resolve) => setTimeout(resolve, ms)),
- ]);
- }, timeoutMs)
- .catch(() => {});
-}
-
-async function seekTo(page: import("puppeteer-core").Page, time: number): Promise {
- await page.evaluate((t: number) => {
- const win = window as unknown as {
- __hf?: { seek?: (time: number) => void };
- __player?: { seek?: (time: number) => void };
- __timelines?: Record void; seek?: (time: number) => void }>;
- };
- if (typeof win.__hf?.seek === "function") {
- win.__hf.seek(t);
- return;
- }
- if (typeof win.__player?.seek === "function") {
- win.__player.seek(t);
- return;
- }
- const timelines = win.__timelines;
- if (timelines) {
- for (const timeline of Object.values(timelines)) {
- if (typeof timeline.pause === "function") timeline.pause();
- if (typeof timeline.seek === "function") timeline.seek(t);
- }
- }
- }, time);
- await page.evaluate(
- () =>
- new Promise((resolveFrame) =>
- requestAnimationFrame(() => requestAnimationFrame(() => resolveFrame())),
- ),
- );
- await waitForFonts(page, 500);
- await new Promise((resolveSettle) => setTimeout(resolveSettle, SEEK_SETTLE_MS));
-}
-
/**
* Collect every tween start/end boundary from the registered timelines,
* expressed in the registered timeline's own time (what seekTo consumes).
@@ -234,6 +197,7 @@ async function runLayoutAudit(
): Promise {
const { ensureBrowser } = await import("../browser/manager.js");
const puppeteer = await import("puppeteer-core");
+ const { buildChromeArgs } = await import("@hyperframes/engine");
const html = await bundleProjectHtml(projectDir);
const server = await serveStaticProjectHtml(
projectDir,
@@ -247,17 +211,11 @@ async function runLayoutAudit(
chromeBrowser = await puppeteer.default.launch({
headless: true,
executablePath: browser.executablePath,
- args: [
- "--no-sandbox",
- "--disable-gpu",
- "--disable-dev-shm-usage",
- "--enable-webgl",
- "--use-gl=angle",
- "--use-angle=swiftshader",
- ],
+ args: buildChromeArgs({ width: 1920, height: 1080, captureMode: "screenshot" }),
});
const page = await chromeBrowser.newPage();
+ await installPageFunctionGuard(page);
await page.setViewport({ width: 1920, height: 1080 });
await page.goto(server.url, { waitUntil: "domcontentloaded", timeout: 10000 });
await alignViewportToComposition(page, server.url);
@@ -266,7 +224,7 @@ async function runLayoutAudit(
timeout: opts.timeout,
})
.catch(() => {});
- await waitForFonts(page, 750);
+ await waitForCompositionFonts(page, 750);
await new Promise((resolveSettle) => setTimeout(resolveSettle, 250));
const duration = await getCompositionDuration(page);
@@ -308,7 +266,7 @@ async function runLayoutAudit(
}
}
-function loadBrowserScript(name: string): string {
+export function loadBrowserScript(name: string): string {
const candidates = [join(__dirname, name), join(__dirname, "commands", name)];
for (const candidate of candidates) {
if (existsSync(candidate)) return readFileSync(candidate, "utf-8");
@@ -330,7 +288,7 @@ async function collectLayoutIssues(
const issues: LayoutIssue[] = [];
for (const time of samples) {
- await seekTo(page, time);
+ await seekCompositionTimeline(page, time, LAYOUT_SEEK_OPTIONS);
const sampleIssues = await page.evaluate(
(auditOptions: { time: number; tolerance: number }) => {
const win = window as unknown as {
@@ -373,7 +331,7 @@ async function collectMotionFrames(
): Promise {
const frames: MotionFrame[] = [];
for (const time of times) {
- await seekTo(page, time);
+ await seekCompositionTimeline(page, time, LAYOUT_SEEK_OPTIONS);
const sample = await page.evaluate(
(options: { selectors: string[]; livenessScopes: string[] }) => {
const win = window as unknown as {
@@ -427,16 +385,19 @@ function resolveMotionSpec(specPath: string, json: boolean): MotionSpec {
if (json) {
console.log(
JSON.stringify(
- withMeta({
- schemaVersion: INSPECT_SCHEMA_VERSION,
- ok: false,
- error: message,
- issues: [],
- errorCount: 0,
- warningCount: 0,
- infoCount: 0,
- issueCount: 0,
- }),
+ withMeta(
+ {
+ schemaVersion: INSPECT_SCHEMA_VERSION,
+ ok: false,
+ error: message,
+ issues: [],
+ errorCount: 0,
+ warningCount: 0,
+ infoCount: 0,
+ issueCount: 0,
+ },
+ { deprecated: true },
+ ),
null,
2,
),
@@ -447,7 +408,7 @@ function resolveMotionSpec(specPath: string, json: boolean): MotionSpec {
process.exit(1);
}
-function parseAt(value: unknown): number[] | undefined {
+export function parseAt(value: unknown): number[] | undefined {
if (!value) return undefined;
const times = String(value)
.split(",")
@@ -461,7 +422,7 @@ export function createInspectCommand(commandName: "inspect" | "layout") {
meta: {
name: commandName,
description:
- "Inspect rendered composition layout for text/container overflow, plus optional motion verification via a *.motion.json sidecar",
+ "Inspect rendered composition layout for text/container overflow, plus optional motion verification via a *.motion.json sidecar (deprecated, use check)",
},
args: {
dir: { type: "positional", description: "Project directory", required: false },
@@ -512,7 +473,10 @@ export function createInspectCommand(commandName: "inspect" | "layout") {
default: false,
},
},
+ // Pre-existing command-run branching; U1 only swapped the seek internals.
+ // fallow-ignore-next-line complexity
async run({ args }) {
+ printDeprecationNotice(commandName);
const project = resolveProject(args.dir);
const samples = Math.max(1, parseInt(args.samples as string, 10) || 9);
const tolerance = Math.max(0, parseFloat(args.tolerance as string) || 2);
@@ -562,7 +526,7 @@ export function createInspectCommand(commandName: "inspect" | "layout") {
);
}
const allIssues = collapseStatic
- ? collapseStaticLayoutIssues(result.rawIssues)
+ ? collapseStaticLayoutIssues(result.rawIssues, result.samples.length)
: result.rawIssues;
const limited = limitLayoutIssues(allIssues, maxIssues);
const summary = summarizeLayoutIssues(allIssues);
@@ -571,25 +535,28 @@ export function createInspectCommand(commandName: "inspect" | "layout") {
if (args.json) {
console.log(
JSON.stringify(
- withMeta({
- schemaVersion: INSPECT_SCHEMA_VERSION,
- duration: result.duration,
- samples: result.samples,
- transitionSamples: atTransitions ? result.transitionSamples : undefined,
- transitionSamplesDropped: atTransitions
- ? result.transitionSamplesDropped
- : undefined,
- tolerance,
- strict,
- collapseStatic,
- motionSpec: motionSpec ? motionSpecPath : undefined,
- motionSamples: motionSpec ? result.motionSamples : undefined,
- ...summary,
- totalIssueCount: limited.totalIssueCount,
- truncated: limited.truncated,
- ok,
- issues: limited.issues,
- }),
+ withMeta(
+ {
+ schemaVersion: INSPECT_SCHEMA_VERSION,
+ duration: result.duration,
+ samples: result.samples,
+ transitionSamples: atTransitions ? result.transitionSamples : undefined,
+ transitionSamplesDropped: atTransitions
+ ? result.transitionSamplesDropped
+ : undefined,
+ tolerance,
+ strict,
+ collapseStatic,
+ motionSpec: motionSpec ? motionSpecPath : undefined,
+ motionSamples: motionSpec ? result.motionSamples : undefined,
+ ...summary,
+ totalIssueCount: limited.totalIssueCount,
+ truncated: limited.truncated,
+ ok,
+ issues: limited.issues,
+ },
+ { deprecated: true },
+ ),
null,
2,
),
@@ -639,16 +606,19 @@ export function createInspectCommand(commandName: "inspect" | "layout") {
if (args.json) {
console.log(
JSON.stringify(
- withMeta({
- schemaVersion: INSPECT_SCHEMA_VERSION,
- ok: false,
- error: message,
- issues: [],
- errorCount: 0,
- warningCount: 0,
- infoCount: 0,
- issueCount: 0,
- }),
+ withMeta(
+ {
+ schemaVersion: INSPECT_SCHEMA_VERSION,
+ ok: false,
+ error: message,
+ issues: [],
+ errorCount: 0,
+ warningCount: 0,
+ infoCount: 0,
+ issueCount: 0,
+ },
+ { deprecated: true },
+ ),
null,
2,
),
diff --git a/packages/cli/src/commands/snapshot.test.ts b/packages/cli/src/commands/snapshot.test.ts
index 0537f1705d..4cb0d394c7 100644
--- a/packages/cli/src/commands/snapshot.test.ts
+++ b/packages/cli/src/commands/snapshot.test.ts
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
-import { computeSnapshotTimes, tailFrameTime } from "./snapshot.js";
+import { computeSnapshotTimes, parseZoomScale, tailFrameTime } from "./snapshot.js";
+
+// --zoom's crop-region math (selector bbox + padding + clamp, exact region
+// form, no-match error) is owned by and tested in
+// ../capture/captureCompositionFrame.test.ts alongside its implementation.
describe("tailFrameTime", () => {
it("backs off ~3% of duration so the final frame isn't the blank exact-end", () => {
@@ -59,3 +63,19 @@ describe("computeSnapshotTimes (FINDING [7]: tail is always captured)", () => {
expect(appendedTail).toBe(false);
});
});
+
+describe("parseZoomScale (--zoom-scale)", () => {
+ it("defaults to 3 when unset", () => {
+ expect(parseZoomScale(undefined)).toBe(3);
+ });
+
+ it("honors an explicit scale", () => {
+ expect(parseZoomScale("2")).toBe(2);
+ });
+
+ it("falls back to the default for invalid or non-positive input", () => {
+ expect(parseZoomScale("abc")).toBe(3);
+ expect(parseZoomScale("0")).toBe(3);
+ expect(parseZoomScale("-1")).toBe(3);
+ });
+});
diff --git a/packages/cli/src/commands/snapshot.ts b/packages/cli/src/commands/snapshot.ts
index 51e9f3c7eb..e1f34f9615 100644
--- a/packages/cli/src/commands/snapshot.ts
+++ b/packages/cli/src/commands/snapshot.ts
@@ -4,9 +4,14 @@ import { existsSync, mkdtempSync, readFileSync, mkdirSync, rmSync, writeFileSync
import { tmpdir } from "node:os";
import { resolve, join, relative, isAbsolute, basename } from "node:path";
import {
+ DEFAULT_ZOOM_SCALE,
+ captureRegionCrop,
openSettledCompositionPage,
+ parseZoomTarget,
+ resolveCropRegion,
runFfmpegOnce,
seekCompositionTimeline,
+ type ZoomTarget,
} from "../capture/captureCompositionFrame.js";
import { resolveProject } from "../utils/project.js";
import { normalizeErrorMessage } from "../utils/errorMessage.js";
@@ -104,8 +109,20 @@ export const examples: Example[] = [
["Capture 5 key frames from a composition", "snapshot capture"],
["Capture 10 evenly-spaced frames", "snapshot capture --frames 10"],
["View the 3D stage from an isometric angle", "snapshot capture --angle iso"],
+ ["Zoom into an element for a high-density crop", "snapshot --zoom '#headline'"],
+ [
+ "Zoom into an exact pixel region at 2x density",
+ "snapshot --zoom 100,50,400,300 --zoom-scale 2",
+ ],
];
+/** `--zoom-scale`: the deviceScaleFactor used for zoomed crops. Defaults to 3;
+ * falls back to the default for anything that doesn't parse as a positive number. */
+export function parseZoomScale(value: unknown): number {
+ const parsed = parseFloat(String(value ?? ""));
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_ZOOM_SCALE;
+}
+
/**
* Seeking the timeline to EXACTLY `data-duration` renders blank — the runtime
* treats t >= clip-end as past-end and unmounts the clip (verified on a V4 3D
@@ -172,6 +189,8 @@ async function captureSnapshots(
outputDir?: string;
angle?: Camera;
includeEnd?: boolean;
+ zoom?: ZoomTarget;
+ zoomScale?: number;
},
): Promise {
const { bundleToSingleHtml } = await import("@hyperframes/core/compiler");
@@ -425,7 +444,29 @@ async function captureSnapshots(
const filename = `frame-${String(i).padStart(2, "0")}-at-${timeLabel}.png`;
const framePath = join(snapshotDir, filename);
- await page.screenshot({ path: framePath, type: "png" });
+ if (opts.zoom) {
+ // Clip screenshot at a raised deviceScaleFactor — never CSS zoom or
+ // viewport resizing — so the composition's own layout is untouched.
+ const canvas = await page.evaluate(() => ({
+ width: window.innerWidth,
+ height: window.innerHeight,
+ }));
+ const region = await resolveCropRegion(page, opts.zoom, canvas);
+ if (!region) {
+ console.error(
+ ` ${c.warn("⚠")} --zoom target has no visible box at ${time.toFixed(1)}s — frame skipped`,
+ );
+ continue;
+ }
+ const buffer = await captureRegionCrop(
+ page,
+ region,
+ opts.zoomScale ?? DEFAULT_ZOOM_SCALE,
+ );
+ writeFileSync(framePath, buffer);
+ } else {
+ await page.screenshot({ path: framePath, type: "png" });
+ }
const rel = relative(projectDir, framePath);
savedPaths.push(rel.startsWith("..") || isAbsolute(rel) ? framePath : rel);
}
@@ -480,6 +521,16 @@ export default defineCommand({
"Always include a readable end-of-timeline frame (default: true). Pass --no-end to capture only your exact --at times.",
default: true,
},
+ zoom: {
+ type: "string",
+ description:
+ "Zoom into a CSS selector or an exact pixel region 'x,y,w,h'. Crops a high-density screenshot instead of the full frame — a raised deviceScaleFactor, never CSS zoom or viewport resizing, so layout stays identical. A selector matching nothing is an error, not a silent full-frame shot.",
+ },
+ "zoom-scale": {
+ type: "string",
+ description: "Device-scale-factor density for --zoom crops (default: 3)",
+ default: "3",
+ },
describe: {
type: "string",
description:
@@ -508,6 +559,8 @@ export default defineCommand({
: String(args.describe);
const camera = args.angle ? parseAngle(String(args.angle)) : undefined;
+ const zoomTarget = args.zoom ? parseZoomTarget(String(args.zoom)) : undefined;
+ const zoomScale = parseZoomScale(args["zoom-scale"]);
const label = atTimestamps
? `${atTimestamps.length} frames at [${atTimestamps.map((t) => t.toFixed(1) + "s").join(", ")}]`
@@ -529,6 +582,8 @@ export default defineCommand({
outputDir: snapshotDir,
angle: camera,
includeEnd: args.end !== false,
+ zoom: zoomTarget,
+ zoomScale,
});
if (paths.length === 0) {
diff --git a/packages/cli/src/commands/validate.test.ts b/packages/cli/src/commands/validate.test.ts
index 7353806c41..3fe07fbfb4 100644
--- a/packages/cli/src/commands/validate.test.ts
+++ b/packages/cli/src/commands/validate.test.ts
@@ -1,12 +1,23 @@
-import { describe, expect, it, vi } from "vitest";
+import { afterEach, describe, expect, it, vi } from "vitest";
+// Imported before "./validate.js" below: validate.js's own static import of
+// ../utils/project.js triggers that mocked module's factory as soon as
+// validate.js loads, so resolveProjectMock/lintProjectFailureMock must
+// already be bound by then (see the vi.mock calls a few lines down).
+import {
+ lintProjectFailureMock,
+ metaDescription,
+ resolveProjectMock,
+ runAndCaptureStdio,
+ runAndParseJsonEnvelope,
+} from "./deprecationTestHarness.js";
import {
extractCompositionErrorsFromLint,
navigationTimeoutHint,
raceMediaReady,
resolveNavigationTimeoutMs,
shouldIgnoreRequestFailure,
- waitForPreferredSeekTarget,
} from "./validate.js";
+import { waitForPreferredSeekTarget } from "../capture/captureCompositionFrame.js";
import type { ProjectLintResult } from "../utils/lintProject.js";
// validateInBrowser lazy-loads the producer localize helpers via loadProducer;
@@ -28,6 +39,16 @@ vi.mock("../utils/producer.js", () => ({
})),
}));
+// U5 deprecation tests: resolveProject and lintProject are both reached via a
+// dynamic `await import(...)` inside validate.ts's run() / validateInBrowser(),
+// so vi.mock intercepts them the same way it would a static import. Mocking
+// resolveProject skips real filesystem project resolution; mocking lintProject
+// (the first await inside validateInBrowser) gives a fast, deterministic
+// failure well before any real browser or network work — exercising run()'s
+// outer catch (the JSON failure envelope) without needing headless Chrome.
+vi.mock("../utils/project.js", () => resolveProjectMock());
+vi.mock("../utils/lintProject.js", () => lintProjectFailureMock());
+
// Regression for the validate audio-duration-probe timeout: a slow-loading
// media element's duration was snapshotted once, at a fixed point in time,
// and any element still mid-load was permanently misreported as unreadable.
@@ -131,6 +152,16 @@ describe("waitForPreferredSeekTarget", () => {
await expect(waitForPreferredSeekTarget(page, 1)).resolves.toBeUndefined();
});
+
+ it("does not fail validation when the page stub throws synchronously", async () => {
+ const page = {
+ waitForFunction: vi.fn(() => {
+ throw new Error("waiting failed synchronously");
+ }),
+ };
+
+ await expect(waitForPreferredSeekTarget(page, 1)).resolves.toBeUndefined();
+ });
});
describe("extractCompositionErrorsFromLint", () => {
@@ -272,3 +303,29 @@ describe("navigationTimeoutHint", () => {
expect(navigationTimeoutHint("some string failure", 10000)).toBeNull();
});
});
+
+describe("validate command deprecation (U5)", () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("marks the command description as deprecated", async () => {
+ const { default: validateCommand } = await import("./validate.js");
+ expect(metaDescription(validateCommand)).toContain("(deprecated, use check)");
+ });
+
+ it("prints a one-line deprecation notice to stderr and never to stdout", async () => {
+ const { default: validateCommand } = await import("./validate.js");
+ const { stderrText, stdoutText } = await runAndCaptureStdio(validateCommand);
+ expect(stderrText).toContain("hyperframes validate");
+ expect(stderrText).toContain("hyperframes check");
+ expect(stdoutText).toBe("");
+ });
+
+ it("--json output is valid JSON with _meta.deprecated === true on failure", async () => {
+ const { default: validateCommand } = await import("./validate.js");
+ const { parsed } = await runAndParseJsonEnvelope(validateCommand);
+ expect(parsed.ok).toBe(false);
+ expect(parsed._meta.deprecated).toBe(true);
+ });
+});
diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts
index 5f10ae3a9a..9624615717 100644
--- a/packages/cli/src/commands/validate.ts
+++ b/packages/cli/src/commands/validate.ts
@@ -1,3 +1,8 @@
+// The media-metadata wait exists twice on purpose: once Node-side and once
+// inside a page.evaluate() body, which is serialized into the browser and
+// cannot import the Node helper. Line-level markers don't survive the clone
+// window drifting as the file is edited, hence the file-level suppression.
+// fallow-ignore-file code-duplication
import { defineCommand } from "citty";
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
@@ -8,7 +13,12 @@ import { normalizeErrorMessage } from "../utils/errorMessage.js";
import type { ProjectLintResult } from "../utils/lintProject.js";
import { resolveCompositionViewportFromHtml } from "../utils/compositionViewport.js";
import { c } from "../ui/colors.js";
-import { withMeta } from "../utils/updateCheck.js";
+import { printDeprecationNotice, withMeta } from "../utils/updateCheck.js";
+import {
+ installPageFunctionGuard,
+ resolveCliChromeGpuMode,
+ seekCompositionTimeline,
+} from "../capture/captureCompositionFrame.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
@@ -85,67 +95,6 @@ async function getCompositionDuration(page: import("puppeteer-core").Page): Prom
});
}
-async function seekTo(page: import("puppeteer-core").Page, time: number): Promise {
- await waitForPreferredSeekTarget(page);
- await page.evaluate((t: number) => {
- // window.__player.renderSeek is exposed directly by the composition
- // runtime (packages/core/src/runtime/init.ts) on every page load, and
- // — unlike raw timeline.seek() — it also runs the runtime's own
- // [data-start]/[data-duration] visibility sync, hiding clips outside
- // their timeline window. window.__hf.seek only exists when the
- // producer's render-pipeline bridge script has been injected, which
- // validate's static preview server never does, so it was always
- // falling through to the raw __timelines seek below and skipping that
- // sync — leaving off-window elements looking fully visible to any
- // check (e.g. the contrast audit) that reads computed style afterward.
- const player = (window as unknown as { __player?: { renderSeek?: (t: number) => void } })
- .__player;
- if (player && typeof player.renderSeek === "function") {
- player.renderSeek(t);
- return;
- }
- if (window.__hf && typeof window.__hf.seek === "function") {
- window.__hf.seek(t);
- return;
- }
- const timelines = (window as unknown as Record).__timelines as
- | Record void }>
- | undefined;
- if (timelines) {
- for (const tl of Object.values(timelines)) {
- if (typeof tl.seek === "function") tl.seek(t);
- }
- }
- }, time);
- await new Promise((r) => setTimeout(r, SEEK_SETTLE_MS));
-}
-
-interface WaitForFunctionPage {
- waitForFunction: (pageFunction: () => boolean, options: { timeout: number }) => Promise;
-}
-
-export async function waitForPreferredSeekTarget(
- page: WaitForFunctionPage,
- timeoutMs = PREFERRED_SEEK_TARGET_WAIT_MS,
-): Promise {
- try {
- await page.waitForFunction(
- () => {
- const w = window as unknown as {
- __hf?: { seek?: unknown };
- __player?: { renderSeek?: unknown };
- };
- return typeof w.__player?.renderSeek === "function" || typeof w.__hf?.seek === "function";
- },
- { timeout: timeoutMs },
- );
- } catch {
- // Older/static pages may only expose raw window.__timelines. Keep the
- // legacy fallback path rather than turning a missing player API into a
- // validate failure.
- }
-}
-
/**
* Race a media element's `loadedmetadata`/`error` event against a deadline,
* whichever comes first. Already-ready elements resolve immediately.
@@ -183,7 +132,7 @@ export function raceMediaReady(
* the live page to read each element's intrinsic `.duration`, which static lint
* can't see.
*/
-async function auditClipDurations(
+export async function auditClipDurations(
page: import("puppeteer-core").Page,
analyzeClipMediaFit: typeof import("@hyperframes/engine").analyzeClipMediaFit,
extraWaitMs: number,
@@ -209,7 +158,6 @@ async function auditClipDurations(
nodes.map((el) => {
if (Number.isFinite(el.duration) && el.duration > 0) return Promise.resolve();
return new Promise((resolve) => {
- // fallow-ignore-next-line code-duplication
const cleanup = () => {
el.removeEventListener("loadedmetadata", onReady);
el.removeEventListener("error", onReady);
@@ -300,7 +248,12 @@ async function runContrastAudit(page: import("puppeteer-core").Page): Promise {
@@ -559,13 +514,16 @@ function emitJsonReport(
): void {
console.log(
JSON.stringify(
- withMeta({
- ok: errors.length === 0,
- errors,
- warnings,
- contrast,
- contrastFailures: contrastFailures.length,
- }),
+ withMeta(
+ {
+ ok: errors.length === 0,
+ errors,
+ warnings,
+ contrast,
+ contrastFailures: contrastFailures.length,
+ },
+ { deprecated: true },
+ ),
null,
2,
),
@@ -612,7 +570,11 @@ function emitTextReport(
function emitFailureReport(message: string, asJson: boolean): void {
if (asJson) {
console.log(
- JSON.stringify(withMeta({ ok: false, error: message, errors: [], warnings: [] }), null, 2),
+ JSON.stringify(
+ withMeta({ ok: false, error: message, errors: [], warnings: [] }, { deprecated: true }),
+ null,
+ 2,
+ ),
);
return;
}
@@ -622,7 +584,7 @@ function emitFailureReport(message: string, asJson: boolean): void {
export default defineCommand({
meta: {
name: "validate",
- description: `Load a composition in headless Chrome and report console errors
+ description: `Load a composition in headless Chrome and report console errors (deprecated, use check)
Examples:
hyperframes validate
@@ -647,6 +609,7 @@ Examples:
},
},
async run({ args }) {
+ printDeprecationNotice("validate");
const project = resolveProject(args.dir);
const timeout = parseInt(args.timeout as string, 10) || 3000;
const useContrast = args.contrast ?? true;
diff --git a/packages/cli/src/telemetry/events.test.ts b/packages/cli/src/telemetry/events.test.ts
index 5801d8f9be..68932d58b3 100644
--- a/packages/cli/src/telemetry/events.test.ts
+++ b/packages/cli/src/telemetry/events.test.ts
@@ -12,6 +12,9 @@ vi.mock("./config.js", () => ({
}));
const {
+ trackCommand,
+ trackCommandResult,
+ trackCheckReport,
trackRenderComplete,
trackRenderError,
trackRenderObservation,
@@ -26,6 +29,148 @@ const {
identifyUser,
} = await import("./events.js");
+describe("command telemetry events", () => {
+ beforeEach(() => {
+ trackEvent.mockClear();
+ });
+
+ it("includes run_id in cli_command when a run ID is provided", () => {
+ trackCommand("check", "run-123");
+
+ expect(trackEvent).toHaveBeenCalledWith("cli_command", {
+ command: "check",
+ run_id: "run-123",
+ });
+ });
+
+ it("omits run_id from cli_command when no run ID is provided", () => {
+ trackCommand("check");
+
+ const properties = trackEvent.mock.lastCall?.[1];
+ expect(properties).not.toHaveProperty("run_id");
+ });
+
+ it("includes run_id in cli_command_result when a run ID is provided", () => {
+ trackCommandResult({
+ command: "check",
+ success: true,
+ exitCode: 0,
+ durationMs: 42,
+ runId: "run-123",
+ });
+
+ expect(trackEvent).toHaveBeenCalledWith("cli_command_result", {
+ command: "check",
+ success: true,
+ exit_code: 0,
+ duration_ms: 42,
+ run_id: "run-123",
+ });
+ });
+
+ it("omits run_id from cli_command_result when no run ID is provided", () => {
+ trackCommandResult({
+ command: "check",
+ success: false,
+ exitCode: 1,
+ durationMs: 42,
+ });
+
+ const properties = trackEvent.mock.lastCall?.[1];
+ expect(properties).not.toHaveProperty("run_id");
+ });
+});
+
+describe("trackCheckReport", () => {
+ beforeEach(() => {
+ trackEvent.mockClear();
+ });
+
+ it("emits the check breakdown with snake_case properties and a run ID", () => {
+ trackCheckReport({
+ contrastGate: true,
+ motionGate: false,
+ captionZoneGate: true,
+ frameCheckGate: false,
+ snapshotsGate: true,
+ lintErrors: 1,
+ lintWarnings: 2,
+ runtimeErrors: 3,
+ runtimeWarnings: 4,
+ layoutErrors: 5,
+ layoutWarnings: 6,
+ motionErrors: 7,
+ motionWarnings: 8,
+ contrastErrors: 9,
+ contrastWarnings: 10,
+ launchSettleMs: 11,
+ seekLoopMs: 12,
+ contrastMs: 13,
+ gridPoints: 14,
+ contrastPoints: 15,
+ ok: false,
+ exitCode: 1,
+ runId: "run-123",
+ });
+
+ expect(trackEvent).toHaveBeenCalledWith("check_report", {
+ gate_contrast: true,
+ gate_motion: false,
+ gate_caption_zone: true,
+ gate_frame_check: false,
+ gate_snapshots: true,
+ lint_errors: 1,
+ lint_warnings: 2,
+ runtime_errors: 3,
+ runtime_warnings: 4,
+ layout_errors: 5,
+ layout_warnings: 6,
+ motion_errors: 7,
+ motion_warnings: 8,
+ contrast_errors: 9,
+ contrast_warnings: 10,
+ launch_settle_ms: 11,
+ seek_loop_ms: 12,
+ contrast_ms: 13,
+ grid_points: 14,
+ contrast_points: 15,
+ ok: false,
+ exit_code: 1,
+ run_id: "run-123",
+ });
+ });
+
+ it("omits run_id when no run ID is provided", () => {
+ trackCheckReport({
+ contrastGate: false,
+ motionGate: false,
+ captionZoneGate: false,
+ frameCheckGate: false,
+ snapshotsGate: false,
+ lintErrors: 0,
+ lintWarnings: 0,
+ runtimeErrors: 0,
+ runtimeWarnings: 0,
+ layoutErrors: 0,
+ layoutWarnings: 0,
+ motionErrors: 0,
+ motionWarnings: 0,
+ contrastErrors: 0,
+ contrastWarnings: 0,
+ launchSettleMs: 0,
+ seekLoopMs: 0,
+ contrastMs: 0,
+ gridPoints: 0,
+ contrastPoints: 0,
+ ok: true,
+ exitCode: 0,
+ });
+
+ const properties = trackEvent.mock.lastCall?.[1];
+ expect(properties).not.toHaveProperty("run_id");
+ });
+});
+
describe("render telemetry events", () => {
beforeEach(() => {
trackEvent.mockClear();
diff --git a/packages/cli/src/telemetry/events.ts b/packages/cli/src/telemetry/events.ts
index 89462246ae..9a43538114 100644
--- a/packages/cli/src/telemetry/events.ts
+++ b/packages/cli/src/telemetry/events.ts
@@ -3,6 +3,12 @@ import type { SubTimelineWaitOutcome } from "@hyperframes/engine";
import { trackEvent } from "./client.js";
import { readConfig } from "./config.js";
+// run_id is attached only when the orchestrator set HYPERFRAMES_RUN_ID — an
+// absent property, never null/"" (PostHog treats those as real values).
+function runIdField(runId: string | undefined): { run_id?: string } {
+ return runId !== undefined ? { run_id: runId } : {};
+}
+
export interface RenderObservabilityTelemetryPayload {
/** Worst sub-composition timeline wait outcome across sessions. */
subTimelineWait?: SubTimelineWaitOutcome;
@@ -98,8 +104,11 @@ function redactTelemetryMessage(value: string): string {
return redactTelemetryString(value);
}
-export function trackCommand(command: string): void {
- trackEvent("cli_command", { command });
+export function trackCommand(command: string, runId?: string): void {
+ trackEvent("cli_command", {
+ command,
+ ...runIdField(runId),
+ });
}
export function trackRenderComplete(
@@ -544,11 +553,65 @@ export function trackCommandResult(props: {
success: boolean;
exitCode: number;
durationMs: number;
+ runId?: string;
}): void {
trackEvent("cli_command_result", {
command: props.command,
success: props.success,
exit_code: props.exitCode,
duration_ms: props.durationMs,
+ ...runIdField(props.runId),
+ });
+}
+
+export function trackCheckReport(props: {
+ contrastGate: boolean;
+ motionGate: boolean;
+ captionZoneGate: boolean;
+ frameCheckGate: boolean;
+ snapshotsGate: boolean;
+ lintErrors: number;
+ lintWarnings: number;
+ runtimeErrors: number;
+ runtimeWarnings: number;
+ layoutErrors: number;
+ layoutWarnings: number;
+ motionErrors: number;
+ motionWarnings: number;
+ contrastErrors: number;
+ contrastWarnings: number;
+ launchSettleMs: number;
+ seekLoopMs: number;
+ contrastMs: number;
+ gridPoints: number;
+ contrastPoints: number;
+ ok: boolean;
+ exitCode: number;
+ runId?: string;
+}): void {
+ trackEvent("check_report", {
+ gate_contrast: props.contrastGate,
+ gate_motion: props.motionGate,
+ gate_caption_zone: props.captionZoneGate,
+ gate_frame_check: props.frameCheckGate,
+ gate_snapshots: props.snapshotsGate,
+ lint_errors: props.lintErrors,
+ lint_warnings: props.lintWarnings,
+ runtime_errors: props.runtimeErrors,
+ runtime_warnings: props.runtimeWarnings,
+ layout_errors: props.layoutErrors,
+ layout_warnings: props.layoutWarnings,
+ motion_errors: props.motionErrors,
+ motion_warnings: props.motionWarnings,
+ contrast_errors: props.contrastErrors,
+ contrast_warnings: props.contrastWarnings,
+ launch_settle_ms: props.launchSettleMs,
+ seek_loop_ms: props.seekLoopMs,
+ contrast_ms: props.contrastMs,
+ grid_points: props.gridPoints,
+ contrast_points: props.contrastPoints,
+ ok: props.ok,
+ exit_code: props.exitCode,
+ ...runIdField(props.runId),
});
}
diff --git a/packages/cli/src/telemetry/runId.test.ts b/packages/cli/src/telemetry/runId.test.ts
new file mode 100644
index 0000000000..6a97b0726b
--- /dev/null
+++ b/packages/cli/src/telemetry/runId.test.ts
@@ -0,0 +1,73 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+const originalRunId = process.env["HYPERFRAMES_RUN_ID"];
+
+async function loadGetRunId() {
+ const { getRunId } = await import("./runId.js");
+ return getRunId;
+}
+
+describe("getRunId", () => {
+ beforeEach(() => {
+ delete process.env["HYPERFRAMES_RUN_ID"];
+ vi.resetModules();
+ });
+
+ afterEach(() => {
+ if (originalRunId === undefined) delete process.env["HYPERFRAMES_RUN_ID"];
+ else process.env["HYPERFRAMES_RUN_ID"] = originalRunId;
+ vi.resetModules();
+ });
+
+ it("returns undefined when HYPERFRAMES_RUN_ID is unset", async () => {
+ const getRunId = await loadGetRunId();
+
+ expect(getRunId()).toBeUndefined();
+ });
+
+ it("returns undefined when HYPERFRAMES_RUN_ID contains only whitespace", async () => {
+ process.env["HYPERFRAMES_RUN_ID"] = " \t\n ";
+ const getRunId = await loadGetRunId();
+
+ expect(getRunId()).toBeUndefined();
+ });
+
+ it("returns a normal HYPERFRAMES_RUN_ID value", async () => {
+ process.env["HYPERFRAMES_RUN_ID"] = "run-123";
+ const getRunId = await loadGetRunId();
+
+ expect(getRunId()).toBe("run-123");
+ });
+
+ it("truncates HYPERFRAMES_RUN_ID to exactly 128 characters", async () => {
+ process.env["HYPERFRAMES_RUN_ID"] = "x".repeat(160);
+ const getRunId = await loadGetRunId();
+
+ expect(getRunId()).toBe("x".repeat(128));
+ expect(getRunId()).toHaveLength(128);
+ });
+
+ it("trims whitespace around a real HYPERFRAMES_RUN_ID value", async () => {
+ process.env["HYPERFRAMES_RUN_ID"] = " run-123 \n";
+ const getRunId = await loadGetRunId();
+
+ expect(getRunId()).toBe("run-123");
+ });
+
+ it("memoizes the first environment read", async () => {
+ process.env["HYPERFRAMES_RUN_ID"] = "first-run";
+ const getRunId = await loadGetRunId();
+
+ expect(getRunId()).toBe("first-run");
+ process.env["HYPERFRAMES_RUN_ID"] = "second-run";
+ expect(getRunId()).toBe("first-run");
+ });
+
+ it("memoizes an initial undefined environment read", async () => {
+ const getRunId = await loadGetRunId();
+
+ expect(getRunId()).toBeUndefined();
+ process.env["HYPERFRAMES_RUN_ID"] = "later-run";
+ expect(getRunId()).toBeUndefined();
+ });
+});
diff --git a/packages/cli/src/telemetry/runId.ts b/packages/cli/src/telemetry/runId.ts
new file mode 100644
index 0000000000..5c5e307fb1
--- /dev/null
+++ b/packages/cli/src/telemetry/runId.ts
@@ -0,0 +1,12 @@
+let resolved = false;
+let runId: string | undefined;
+
+export function getRunId(): string | undefined {
+ if (!resolved) {
+ const value = process.env["HYPERFRAMES_RUN_ID"]?.trim().slice(0, 128);
+ runId = value ? value : undefined;
+ resolved = true;
+ }
+
+ return runId;
+}
diff --git a/packages/cli/src/templates/_shared/AGENTS.md b/packages/cli/src/templates/_shared/AGENTS.md
index a30e02dbe4..3e8a31aba6 100644
--- a/packages/cli/src/templates/_shared/AGENTS.md
+++ b/packages/cli/src/templates/_shared/AGENTS.md
@@ -33,7 +33,7 @@ The domain skills (`/hyperframes-core`, `/hyperframes-animation`, `/hyperframes-
```bash
npm run dev # start the preview server (long-running — keep it alive in background)
-npm run check # lint + validate + inspect
+npm run check # lint + runtime + layout + motion + contrast (one command)
npm run render # render to MP4
npm run publish # publish and get a shareable link
npx hyperframes lint --verbose # include info-level findings
@@ -76,7 +76,7 @@ After creating or editing any `.html` composition, **always** run the full check
npm run check
```
-Fix all errors before presenting the result. Inspect warnings should be reviewed before rendering.
+Fix all errors before presenting the result. Warnings should be reviewed before rendering.
## Key Rules
diff --git a/packages/cli/src/templates/_shared/CLAUDE.md b/packages/cli/src/templates/_shared/CLAUDE.md
index a30e02dbe4..3e8a31aba6 100644
--- a/packages/cli/src/templates/_shared/CLAUDE.md
+++ b/packages/cli/src/templates/_shared/CLAUDE.md
@@ -33,7 +33,7 @@ The domain skills (`/hyperframes-core`, `/hyperframes-animation`, `/hyperframes-
```bash
npm run dev # start the preview server (long-running — keep it alive in background)
-npm run check # lint + validate + inspect
+npm run check # lint + runtime + layout + motion + contrast (one command)
npm run render # render to MP4
npm run publish # publish and get a shareable link
npx hyperframes lint --verbose # include info-level findings
@@ -76,7 +76,7 @@ After creating or editing any `.html` composition, **always** run the full check
npm run check
```
-Fix all errors before presenting the result. Inspect warnings should be reviewed before rendering.
+Fix all errors before presenting the result. Warnings should be reviewed before rendering.
## Key Rules
diff --git a/packages/cli/src/utils/checkBrowser.test.ts b/packages/cli/src/utils/checkBrowser.test.ts
new file mode 100644
index 0000000000..3a2a3f0d50
--- /dev/null
+++ b/packages/cli/src/utils/checkBrowser.test.ts
@@ -0,0 +1,302 @@
+// @vitest-environment happy-dom
+import { afterEach, describe, expect, it, vi } from "vitest";
+import {
+ openSettledCompositionPage,
+ type OpenSettledCompositionPageOptions,
+} from "../capture/captureCompositionFrame.js";
+import { DEFAULT_CHECK_OPTIONS, runAuditGrid } from "./checkPipeline.js";
+import { captureOverviewShot, runBrowserCheck } from "./checkBrowser.js";
+import type { ProjectDir } from "./project.js";
+
+const mocks = vi.hoisted(() => ({
+ serverClose: vi.fn(async () => undefined),
+}));
+
+vi.mock("@hyperframes/core/compiler", () => ({
+ bundleToSingleHtml: vi.fn(async () => ""),
+}));
+
+vi.mock("../capture/captureCompositionFrame.js", async (importOriginal) => ({
+ // Partial mock: constants (AUDIT_SEEK_OPTIONS, DEFAULT_ZOOM_*) stay real so
+ // they remain single-sourced; only the browser-touching functions are faked.
+ ...(await importOriginal()),
+ openSettledCompositionPage: vi.fn(),
+ resolveCliChromeGpuMode: vi.fn(() => "hardware"),
+ seekCompositionTimeline: vi.fn(async () => undefined),
+ waitForPreferredSeekTarget: vi.fn(async () => undefined),
+}));
+
+vi.mock("../commands/validate.js", async (importOriginal) => ({
+ // Partial mock: shouldIgnoreRequestFailure stays real; the clip audit is
+ // faked so tests control its findings without loading real media.
+ ...(await importOriginal()),
+ auditClipDurations: vi.fn(async () => [] as Array<{ level: "error" | "warning"; text: string }>),
+}));
+
+vi.mock("./staticProjectServer.js", () => ({
+ serveStaticProjectHtml: vi.fn(async () => ({
+ url: "http://127.0.0.1:3000",
+ close: mocks.serverClose,
+ })),
+}));
+
+const PROJECT: ProjectDir = {
+ dir: "/project",
+ name: "project",
+ indexPath: "/project/index.html",
+};
+
+afterEach(() => {
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+ document.body.innerHTML = "";
+ Reflect.deleteProperty(window, "__hyperframesGeometryCandidates");
+ Reflect.deleteProperty(window, "__hyperframesLayoutAudit");
+ Reflect.deleteProperty(window, "__contrastAuditPrepare");
+ Reflect.deleteProperty(window, "__contrastAuditFinish");
+ Reflect.deleteProperty(window, "__contrastAuditRestores");
+ Reflect.deleteProperty(window, "__contrastAuditRestoreIfPending");
+});
+
+function installSessionMock(page: ReturnType): void {
+ const browser = Object.assign(Object.create(null), {
+ close: vi.fn(async () => undefined),
+ });
+ vi.mocked(openSettledCompositionPage).mockImplementation(
+ async (_html: string, _url: string, options: OpenSettledCompositionPageOptions) => {
+ await options.beforeNavigate?.(page);
+ return { page, browser, renderReadyTimedOut: false };
+ },
+ );
+}
+
+function mountCanvasFixture(inner = ""): void {
+ document.body.innerHTML = `
+ ${inner}
+ `;
+ Object.defineProperty(window, "innerWidth", { configurable: true, value: 640 });
+ Object.defineProperty(window, "innerHeight", { configurable: true, value: 360 });
+}
+
+it("carries raw browser geometry through the page driver and pipeline", async () => {
+ vi.spyOn(Date, "now")
+ .mockReturnValueOnce(100)
+ .mockReturnValueOnce(160)
+ .mockReturnValueOnce(200)
+ .mockReturnValueOnce(240);
+ mountCanvasFixture(`
+
+
+
+ `);
+ installRects();
+ const page = fakePage();
+ installSessionMock(page);
+
+ const result = await runBrowserCheck(
+ PROJECT,
+ { ...DEFAULT_CHECK_OPTIONS, samples: 1, contrast: false, frameCheck: {} },
+ { kind: "none" },
+ runAuditGrid,
+ );
+
+ expect(result.layoutIssues).toEqual([
+ expect.objectContaining({
+ code: "frame_out_of_frame",
+ severity: "warning",
+ selector: "#hero-image",
+ sourceFile: "scenes/hero.html",
+ dataAttributes: { "data-layout-name": "hero" },
+ bbox: { x: 600, y: 80, width: 200, height: 100 },
+ rect: { left: 600, top: 80, right: 800, bottom: 180, width: 200, height: 100 },
+ overflow: { right: 160 },
+ time: 5,
+ }),
+ ]);
+ expect(result.timings).toEqual({ launchSettleMs: 60, seekLoopMs: 40, contrastMs: 0 });
+ expect(mocks.serverClose).toHaveBeenCalledOnce();
+});
+
+it("round-trips the browser script's raw contrast candidates back into finish", async () => {
+ // The U2 regression class: Node parses prepare's candidates for reporting,
+ // but must hand the UNTOUCHED objects back to __contrastAuditFinish — the
+ // page script samples pixels via its own bbox shape (w/h). A normalized
+ // candidate (width/height) makes every sample rect NaN and the audit
+ // silently reports zero checked elements as green.
+ vi.spyOn(Date, "now").mockReturnValue(100);
+ mountCanvasFixture(`
+ Readable copy
+ `);
+ const root = document.querySelector("[data-composition-id]");
+ const headline = document.querySelector("#headline");
+ if (!root || !headline) throw new Error("Contrast fixture failed to mount");
+ vi.spyOn(root, "getBoundingClientRect").mockReturnValue(new DOMRect(0, 0, 640, 360));
+ vi.spyOn(headline, "getBoundingClientRect").mockReturnValue(new DOMRect(50, 50, 300, 40));
+ vi.spyOn(window, "getComputedStyle").mockImplementation(
+ () =>
+ ({
+ display: "block",
+ visibility: "visible",
+ opacity: "1",
+ color: "rgb(255,255,255)",
+ fill: "",
+ backgroundColor: "rgba(0,0,0,0)",
+ backgroundImage: "none",
+ fontSize: "32px",
+ fontWeight: "700",
+ }) as unknown as CSSStyleDeclaration,
+ );
+
+ // happy-dom can't decode PNGs: stub Image (sync onload) and the canvas 2D
+ // context the way layout-audit.browser.test.ts's contrast harness does, so
+ // the REAL __contrastAuditFinish runs its sampling path end to end.
+ class MockImage {
+ onload: (() => void) | null = null;
+ onerror: (() => void) | null = null;
+ naturalWidth = 640;
+ naturalHeight = 360;
+
+ set src(_value: string) {
+ this.onload?.();
+ }
+ }
+ vi.stubGlobal("Image", MockImage);
+ const getContextSpy = vi.spyOn(HTMLCanvasElement.prototype, "getContext") as unknown as {
+ mockReturnValue(value: CanvasRenderingContext2D): void;
+ };
+ getContextSpy.mockReturnValue({
+ drawImage() {},
+ getImageData() {
+ return { data: new Uint8ClampedArray(640 * 360 * 4).fill(255) };
+ },
+ } as unknown as CanvasRenderingContext2D);
+
+ const received: Array> = [];
+ const page = fakePage();
+ const injectScript = page.addScriptTag;
+ page.addScriptTag = vi.fn(async (arg: { content: string }) => {
+ await injectScript(arg);
+ const w = window as unknown as {
+ __contrastAuditFinish?: ((...args: unknown[]) => Promise) & { wrapped?: boolean };
+ };
+ const finish = w.__contrastAuditFinish;
+ if (finish && !finish.wrapped) {
+ const wrapper = Object.assign(
+ async (...args: unknown[]) => {
+ const candidates = args[2];
+ if (Array.isArray(candidates)) {
+ received.push(...(candidates as Array>));
+ }
+ return finish(...args);
+ },
+ { wrapped: true },
+ );
+ w.__contrastAuditFinish = wrapper;
+ }
+ });
+ page.screenshot = vi.fn(async () => "c3R1Yg==");
+ installSessionMock(page);
+
+ await runBrowserCheck(
+ PROJECT,
+ { ...DEFAULT_CHECK_OPTIONS, samples: 1, contrast: true },
+ { kind: "none" },
+ runAuditGrid,
+ );
+
+ expect(received.length).toBeGreaterThan(0);
+ for (const candidate of received) {
+ const bbox = candidate.bbox as Record;
+ // The page script's own shape (w/h), not Node's envelope shape (width/height):
+ expect(typeof bbox.w).toBe("number");
+ expect(typeof bbox.h).toBe("number");
+ }
+});
+
+it("carries validate's clip-duration audit into the runtime findings", async () => {
+ vi.spyOn(Date, "now").mockReturnValue(100);
+ mountCanvasFixture();
+ const validateModule = await import("../commands/validate.js");
+ vi.mocked(validateModule.auditClipDurations).mockResolvedValue([
+ {
+ level: "warning",
+ text: "Audio is 22.10s but its slot (data-duration) is 30.00s — the slot is shortened to the media length when rendered.",
+ },
+ ]);
+ const page = fakePage();
+ installSessionMock(page);
+
+ const result = await runBrowserCheck(
+ PROJECT,
+ { ...DEFAULT_CHECK_OPTIONS, samples: 1, contrast: false },
+ { kind: "none" },
+ runAuditGrid,
+ );
+
+ expect(result.runtimeFindings).toEqual([
+ expect.objectContaining({
+ code: "clip_media_fit",
+ severity: "warning",
+ message: expect.stringContaining("slot is shortened"),
+ }),
+ ]);
+});
+
+describe("captureOverviewShot", () => {
+ it("injects the annotation overlay before the overview shot and removes it right after", async () => {
+ const calls: string[] = [];
+ const evaluate = vi.fn(async (fn: unknown, ...args: unknown[]) => {
+ calls.push("evaluate");
+ return typeof fn === "function" ? Reflect.apply(fn, undefined, args) : undefined;
+ });
+ const screenshot = vi.fn(async () => {
+ calls.push("screenshot");
+ return "annotated-base64";
+ });
+ const page = Object.assign(Object.create(null), { evaluate, screenshot });
+
+ const result = await captureOverviewShot(
+ page,
+ [{ label: "1 clipped_text", bbox: { x: 0, y: 0, width: 10, height: 10 } }],
+ "measurement-base64",
+ );
+
+ // inject overlay -> take the shot -> remove overlay, in that order —
+ // never present while any audit (which runs before this is called) collects.
+ expect(calls).toEqual(["evaluate", "screenshot", "evaluate"]);
+ expect(result).toBe("annotated-base64");
+ });
+
+ it("skips the overlay entirely and returns the plain screenshot when there's nothing to annotate", async () => {
+ const evaluate = vi.fn();
+ const screenshot = vi.fn();
+ const page = Object.assign(Object.create(null), { evaluate, screenshot });
+
+ const result = await captureOverviewShot(page, [], "measurement-base64");
+
+ expect(evaluate).not.toHaveBeenCalled();
+ expect(screenshot).not.toHaveBeenCalled();
+ expect(result).toBe("measurement-base64");
+ });
+});
+
+function installRects(): void {
+ const root = document.querySelector("[data-composition-id]");
+ const image = document.querySelector("#hero-image");
+ if (!root || !image) throw new Error("Geometry fixture failed to mount");
+ vi.spyOn(root, "getBoundingClientRect").mockReturnValue(new DOMRect(0, 0, 640, 360));
+ vi.spyOn(image, "getBoundingClientRect").mockReturnValue(new DOMRect(600, 80, 200, 100));
+}
+
+function fakePage() {
+ return Object.assign(Object.create(null), {
+ on: vi.fn(),
+ addScriptTag: vi.fn(async ({ content }: { content: string }) => {
+ window.eval(content);
+ }),
+ evaluate: vi.fn(async (callback: unknown, ...args: unknown[]) => {
+ if (typeof callback !== "function") throw new Error("Expected an evaluate callback");
+ return Reflect.apply(callback, window, args);
+ }),
+ });
+}
diff --git a/packages/cli/src/utils/checkBrowser.ts b/packages/cli/src/utils/checkBrowser.ts
new file mode 100644
index 0000000000..d889e41b73
--- /dev/null
+++ b/packages/cli/src/utils/checkBrowser.ts
@@ -0,0 +1,1020 @@
+import { mkdirSync, writeFileSync } from "node:fs";
+import { join } 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 { rectToBbox } from "./checkTypes.js";
+import type {
+ AnchoredLayoutIssue,
+ CheckAnchor,
+ CheckAnnotationBox,
+ CheckAuditDriver,
+ CheckBbox,
+ CheckBrowserResult,
+ CheckFinding,
+ CheckFindingCropRequest,
+ CheckGeometryCandidate,
+ CheckOptions,
+ CheckSeverity,
+ ContrastAuditEntry,
+ ContrastCapture,
+ GeometryCandidateRequest,
+ MotionSpecResolution,
+ 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;
+}
+
+export async function runBrowserCheck(
+ project: ProjectDir,
+ options: CheckOptions,
+ motion: MotionSpecResolution,
+ runGrid: RunAuditGrid,
+): Promise {
+ const { bundleToSingleHtml } = await import("@hyperframes/core/compiler");
+ const html = await bundleToSingleHtml(project.dir);
+ const server = await serveStaticProjectHtml(project.dir, html, "Failed to bind check server");
+ 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 {
+ if (requests.length === 0) return [];
+ const { bundleToSingleHtml } = await import("@hyperframes/core/compiler");
+ const html = await bundleToSingleHtml(project.dir);
+ const server = await serveStaticProjectHtml(project.dir, html, "Failed to bind check server");
+ 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();
+ }
+}
+
+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,
+ });
+ }
+ });
+ page.on("pageerror", (error) => {
+ const message = normalizeErrorMessage(error);
+ if (message.includes("Unexpected token '<'") || message.includes("Unexpected token '<'")) {
+ 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),
+ 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 {
+ 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 {
+ // 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 {
+ // 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 {
+ 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 {
+ 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 : "";
+ });
+}
+
+async function collectGeometryCandidates(
+ page: Page,
+ time: number,
+ request: GeometryCandidateRequest,
+): Promise {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 = {};
+ 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 {
+ 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 {
+ 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 {
+ 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" });
+ 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);
+ // 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, measurementShot);
+ 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 {
+ 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 {
+ 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 {
+ await page.evaluate((overlayId: string) => {
+ document.getElementById(overlayId)?.remove();
+ }, ANNOTATION_OVERLAY_ID);
+}
+
+async function prepareContrast(page: Page, time: number): Promise {
+ // 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 = {};
+ 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 {
+ 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,
+): Pick | 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,
+ 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): 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 | 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 = {};
+ 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",
+ "caption_zone_collision",
+ "frame_out_of_frame",
+ "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 | null {
+ if (!isRecord(value)) return null;
+ const record: Record = {};
+ 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,
+ key: "containerSelector" | "text" | "fixHint",
+): void {
+ const value = stringValue(source, key);
+ if (value !== null) issue[key] = value;
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+function stringValue(value: Record, key: string): string | null {
+ const entry = Reflect.get(value, key);
+ return typeof entry === "string" ? entry : null;
+}
+
+function numberValue(value: Record, key: string): number | null {
+ const entry = Reflect.get(value, key);
+ return typeof entry === "number" && Number.isFinite(entry) ? entry : null;
+}
+
+function booleanValue(value: Record, 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;
+ }
+}
diff --git a/packages/cli/src/utils/checkPipeline.ts b/packages/cli/src/utils/checkPipeline.ts
new file mode 100644
index 0000000000..8148654b24
--- /dev/null
+++ b/packages/cli/src/utils/checkPipeline.ts
@@ -0,0 +1,976 @@
+import { mkdirSync, writeFileSync } from "node:fs";
+import { join, relative } from "node:path";
+import { trackCheckReport, trackCommandFailure } from "../telemetry/events.js";
+import { getRunId } from "../telemetry/runId.js";
+import type { ProjectDir } from "./project.js";
+import { lintProject, shouldBlockRender, type ProjectLintResult } from "./lintProject.js";
+import {
+ buildLayoutSampleTimes,
+ buildTransitionSampleTimes,
+ collapseStaticLayoutIssues,
+ dedupeLayoutIssues,
+ limitLayoutIssues,
+ mergeSampleTimes,
+ type LayoutIssue,
+ type LayoutRect,
+} from "./layoutAudit.js";
+import {
+ collectSamplingTargets,
+ evaluateMotion,
+ type Canvas,
+ type MotionFrame,
+} from "./motionAudit.js";
+import { findMotionSpec, readMotionSpec } from "./motionSpec.js";
+import { normalizeErrorMessage } from "./errorMessage.js";
+import {
+ parseColorRGBA,
+ requiredContrastRatio,
+ suggestCompliantForegroundColor,
+ type Rgb,
+} from "../commands/contrast-bg.js";
+import { rectToBbox } from "./checkTypes.js";
+import type {
+ AnchoredLayoutIssue,
+ CheckAnnotationBox,
+ CheckAuditDriver,
+ CheckBbox,
+ CheckBrowserResult,
+ CheckContrastFinding,
+ CheckDependencies,
+ CheckFinding,
+ CheckFindingCropRequest,
+ CheckGeometryCandidate,
+ CheckOptions,
+ CheckReport,
+ CheckScreenshot,
+ CheckSection,
+ CheckSeverity,
+ ContrastAuditEntry,
+ GeometryCandidateRequest,
+ MotionSpecResolution,
+} from "./checkTypes.js";
+
+export type {
+ AnchoredLayoutIssue,
+ CheckAnchor,
+ CheckAuditDriver,
+ CheckBrowserResult,
+ CheckDependencies,
+ CheckFinding,
+ CheckFindingCropRequest,
+ CheckOptions,
+ CheckReport,
+ CheckSection,
+ ContrastAuditEntry,
+ MotionSpecResolution,
+} from "./checkTypes.js";
+
+const MOTION_FPS = 20;
+const MOTION_MAX_SAMPLES = 300;
+const ZERO_BBOX: CheckBbox = { x: 0, y: 0, width: 0, height: 0 };
+// Ignore normal in/out slide travel; only substantive frame breaches are actionable.
+const FRAME_BREACH_FLOOR_PX = 120;
+const FRAME_BREACH_FLOOR_FRACTION = 0.06;
+
+export const DEFAULT_CHECK_OPTIONS: CheckOptions = {
+ samples: 9,
+ atTransitions: false,
+ maxIssues: 80,
+ collapseStatic: true,
+ tolerance: 2,
+ timeout: 3000,
+ contrast: true,
+ strict: false,
+ snapshots: false,
+};
+
+/** Pick at most five evenly-strided points from the already-merged layout grid. */
+export function selectContrastTimes(grid: number[]): number[] {
+ if (grid.length <= 5) return [...grid];
+ return Array.from({ length: 5 }, (_, index) => {
+ const selected = Math.floor((index * (grid.length - 1)) / 4);
+ return grid[selected] ?? grid[0] ?? 0;
+ });
+}
+
+function buildMotionSampleTimes(duration: number): number[] {
+ if (!Number.isFinite(duration) || duration <= 0) return [];
+ const count = Math.min(MOTION_MAX_SAMPLES, Math.max(2, Math.ceil(duration * MOTION_FPS) + 1));
+ const step = duration / (count - 1);
+ return Array.from({ length: count }, (_, index) => Math.round(index * step * 1000) / 1000);
+}
+
+interface SampleGrid {
+ duration: number;
+ layoutSamples: number[];
+ captionSamples: number[];
+ frameSamples: number[];
+ transitionSamples: number[];
+ transitionSamplesDropped: number;
+ contrastSamples: number[];
+}
+
+function gateSampleTimes(
+ duration: number,
+ seeks: number[] | undefined,
+ fallback: number,
+): number[] {
+ if (!Number.isFinite(duration) || duration <= 0) return [];
+ const fractions = seeks && seeks.length > 0 ? seeks : [fallback];
+ return mergeSampleTimes(fractions.map((fraction) => fraction * duration));
+}
+
+async function buildSampleGrid(
+ driver: CheckAuditDriver,
+ options: CheckOptions,
+): Promise {
+ const duration = await driver.getDuration();
+ const baseSamples = buildLayoutSampleTimes({
+ duration,
+ samples: options.samples,
+ at: options.at,
+ });
+ const transitions = options.atTransitions
+ ? buildTransitionSampleTimes({
+ duration,
+ boundaries: await driver.getTransitionBoundaries(),
+ cap: options.maxTransitionSamples,
+ })
+ : { times: [], dropped: 0 };
+ const captionSamples = options.captionZone
+ ? gateSampleTimes(duration, options.captionZone.seek, 1)
+ : [];
+ const frameSamples = options.frameCheck
+ ? gateSampleTimes(duration, options.frameCheck.seek, 0.5)
+ : [];
+ const auditSamples = mergeSampleTimes(baseSamples, transitions.times);
+ const layoutSamples = mergeSampleTimes(auditSamples, captionSamples, frameSamples);
+ if (layoutSamples.length === 0) {
+ throw new Error("Could not determine composition duration — no layout samples run");
+ }
+ return {
+ duration,
+ layoutSamples,
+ captionSamples,
+ frameSamples,
+ transitionSamples: transitions.times,
+ transitionSamplesDropped: transitions.dropped,
+ contrastSamples: options.contrast ? selectContrastTimes(auditSamples) : [],
+ };
+}
+
+interface MotionPlan {
+ times: number[];
+ selectors: string[];
+ livenessScopes: string[];
+ preflightIssues: AnchoredLayoutIssue[];
+}
+
+async function planMotionSampling(
+ driver: CheckAuditDriver,
+ motion: MotionSpecResolution,
+ duration: number,
+): Promise {
+ if (motion.kind !== "valid") {
+ return { times: [], selectors: [], livenessScopes: [], preflightIssues: [] };
+ }
+ const targets = collectSamplingTargets(motion.spec.assertions);
+ const preflightIssues = await driver.findAmbiguousSelectors(targets.selectors);
+ const times =
+ preflightIssues.length === 0 ? buildMotionSampleTimes(motion.spec.duration ?? duration) : [];
+ return { times, ...targets, preflightIssues };
+}
+
+interface GridSamples {
+ layoutIssues: AnchoredLayoutIssue[];
+ motionFrames: MotionFrame[];
+ contrastEntries: ContrastAuditEntry[];
+ screenshots: CheckScreenshot[];
+ contrastMs: number;
+ /** One geometry+opacity fingerprint per layout sample (#U10 frozen-sweep guard). */
+ geometrySignatures: string[];
+}
+
+interface GeometrySeen {
+ caption: Set;
+ frame: Set;
+}
+
+function geometryRequest(
+ time: number,
+ grid: SampleGrid,
+ options: CheckOptions,
+): GeometryCandidateRequest | null {
+ const text = grid.captionSamples.includes(time);
+ const media = grid.frameSamples.includes(time);
+ if (!text && !media) return null;
+ const configuredTolerance = options.frameCheck?.tol;
+ const tolerance = typeof configuredTolerance === "number" ? configuredTolerance : 2;
+ return { text, media, tolerance };
+}
+
+function candidateIsSized(candidate: CheckGeometryCandidate, canvas: Canvas): boolean {
+ if (candidate.elementRect.width < 4 || candidate.elementRect.height < 4) return false;
+ return !(
+ candidate.elementRect.width >= 0.95 * canvas.width &&
+ candidate.elementRect.height >= 0.95 * canvas.height
+ );
+}
+
+function geometryIssueAnchor(candidate: CheckGeometryCandidate, time: number) {
+ return {
+ selector: candidate.selector,
+ dataAttributes: candidate.dataAttributes,
+ sourceFile: candidate.sourceFile,
+ bbox: candidate.bbox,
+ time,
+ rect: candidate.rect,
+ };
+}
+
+function captionFinding(
+ candidate: CheckGeometryCandidate,
+ options: CheckOptions,
+ canvas: Canvas,
+ time: number,
+): { key: string; issue: AnchoredLayoutIssue } | null {
+ const zone = options.captionZone;
+ if (!zone || candidate.kind !== "text" || !candidateIsSized(candidate, canvas)) return null;
+ const cx = candidate.rect.left + candidate.rect.width / 2;
+ const cy = candidate.rect.top + candidate.rect.height / 2;
+ const inside =
+ cx >= zone.x0 * canvas.width &&
+ cx <= zone.x1 * canvas.width &&
+ cy >= zone.y0 * canvas.height &&
+ cy <= zone.y1 * canvas.height;
+ if (!inside) return null;
+ const text = candidate.text.slice(0, 48);
+ const pctFromBottom = Math.round(((canvas.height - cy) / canvas.height) * 100);
+ return {
+ key: `${candidate.tag}|${text}`,
+ issue: {
+ ...geometryIssueAnchor(candidate, time),
+ code: "caption_zone_collision",
+ severity: zone.severity === "error" ? "error" : "warning",
+ text,
+ message: `<${candidate.tag}> "${text}" is centred in the reserved caption band (~${pctFromBottom}% up from the bottom).`,
+ fixHint: "Keep main content outside the configured caption band.",
+ },
+ };
+}
+
+function maxOverflow(candidate: CheckGeometryCandidate): number {
+ if (!candidate.overflow) return 0;
+ return Math.max(
+ candidate.overflow.left ?? 0,
+ candidate.overflow.top ?? 0,
+ candidate.overflow.right ?? 0,
+ candidate.overflow.bottom ?? 0,
+ );
+}
+
+function overflowMessage(candidate: CheckGeometryCandidate): string {
+ const overflow = candidate.overflow ?? {};
+ const edges: string[] = [];
+ if (overflow.left) edges.push(`${overflow.left}px past the left`);
+ if (overflow.top) edges.push(`${overflow.top}px past the top`);
+ if (overflow.right) edges.push(`${overflow.right}px past the right`);
+ if (overflow.bottom) edges.push(`${overflow.bottom}px past the bottom`);
+ return `<${candidate.tag}> "${candidate.text.slice(0, 48)}" spills outside the frame (${edges.join(", ")}).`;
+}
+
+function frameFinding(
+ candidate: CheckGeometryCandidate,
+ options: CheckOptions,
+ canvas: Canvas,
+ time: number,
+): { key: string; issue: AnchoredLayoutIssue } | null {
+ if (!options.frameCheck || candidate.kind !== "media" || !candidateIsSized(candidate, canvas)) {
+ return null;
+ }
+ const floor = Math.max(
+ FRAME_BREACH_FLOOR_PX,
+ FRAME_BREACH_FLOOR_FRACTION * Math.min(canvas.width, canvas.height),
+ );
+ if (maxOverflow(candidate) < floor) return null;
+ const text = candidate.text.slice(0, 48);
+ return {
+ key: `${candidate.tag}|${text}|${Math.round(candidate.rect.left)},${Math.round(candidate.rect.top)}`,
+ issue: {
+ ...geometryIssueAnchor(candidate, time),
+ code: "frame_out_of_frame",
+ severity: options.frameCheck.severity === "error" ? "error" : "warning",
+ text,
+ overflow: candidate.overflow,
+ message: overflowMessage(candidate),
+ fixHint: "Keep media within the composition frame's safe area.",
+ },
+ };
+}
+
+function appendGeometryFinding(
+ result: { key: string; issue: AnchoredLayoutIssue } | null,
+ seen: Set,
+ issues: AnchoredLayoutIssue[],
+): void {
+ if (!result || seen.has(result.key)) return;
+ seen.add(result.key);
+ issues.push(result.issue);
+}
+
+async function collectGeometryAt(
+ driver: CheckAuditDriver,
+ options: CheckOptions,
+ grid: SampleGrid,
+ canvas: Canvas,
+ time: number,
+ seen: GeometrySeen,
+): Promise {
+ const request = geometryRequest(time, grid, options);
+ if (!request) return [];
+ const candidates = await driver.collectGeometryCandidates(time, request);
+ const issues: AnchoredLayoutIssue[] = [];
+ for (const candidate of candidates) {
+ if (request.text) {
+ appendGeometryFinding(captionFinding(candidate, options, canvas, time), seen.caption, issues);
+ }
+ if (request.media) {
+ appendGeometryFinding(frameFinding(candidate, options, canvas, time), seen.frame, issues);
+ }
+ }
+ return issues;
+}
+
+async function collectGridSamples(
+ driver: CheckAuditDriver,
+ options: CheckOptions,
+ grid: SampleGrid,
+ motion: MotionPlan,
+): Promise {
+ const layoutSet = new Set(grid.layoutSamples);
+ const motionSet = new Set(motion.times);
+ const contrastSet = new Set(grid.contrastSamples);
+ const geometryEnabled = grid.captionSamples.length > 0 || grid.frameSamples.length > 0;
+ const canvas = geometryEnabled ? await driver.getCanvas() : null;
+ const geometrySeen: GeometrySeen = { caption: new Set(), frame: new Set() };
+ const collected: GridSamples = {
+ layoutIssues: [],
+ motionFrames: [],
+ contrastEntries: [],
+ screenshots: [],
+ contrastMs: 0,
+ geometrySignatures: [],
+ };
+ for (const time of mergeSampleTimes(grid.layoutSamples, motion.times)) {
+ await driver.seek(time);
+ // Findings collected for THIS sample time, so the overview overlay (below)
+ // only ever annotates a frame with defects that are actually valid at
+ // that render time — never a stale bbox from an earlier/later sample.
+ const issuesAtTime: AnchoredLayoutIssue[] = [];
+ if (layoutSet.has(time)) {
+ const layoutIssues = await driver.collectLayout(time, options.tolerance);
+ collected.layoutIssues.push(...layoutIssues);
+ issuesAtTime.push(...layoutIssues);
+ collected.geometrySignatures.push(await driver.collectLayoutGeometry());
+ }
+ if (canvas) {
+ const geometryIssues = await collectGeometryAt(
+ driver,
+ options,
+ grid,
+ canvas,
+ time,
+ geometrySeen,
+ );
+ collected.layoutIssues.push(...geometryIssues);
+ issuesAtTime.push(...geometryIssues);
+ }
+ if (motionSet.has(time)) {
+ collected.motionFrames.push(
+ await driver.collectMotionFrame(time, motion.selectors, motion.livenessScopes),
+ );
+ }
+ if (contrastSet.has(time)) {
+ const contrastStart = Date.now();
+ // Annotation is a --snapshots-only nicety — skip building it (and the
+ // driver's extra overlay screenshot) when nothing will use it; the call
+ // shape without --snapshots stays exactly what it was before this existed.
+ const capture = options.snapshots
+ ? await driver.collectContrast(time, annotationBoxesFrom(issuesAtTime))
+ : await driver.collectContrast(time);
+ collected.contrastMs += Date.now() - contrastStart;
+ collected.contrastEntries.push(...capture.entries);
+ collected.screenshots.push({ time, pngBase64: capture.pngBase64 });
+ }
+ }
+ return collected;
+}
+
+// Frozen-sweep guard (#U10): compositions this short can legitimately hold a
+// single static frame the whole time (a title card) — never flag those.
+const SWEEP_STATIC_MIN_DURATION_SEC = 3;
+const ZERO_LAYOUT_RECT: LayoutRect = {
+ left: 0,
+ top: 0,
+ right: 0,
+ bottom: 0,
+ width: 0,
+ height: 0,
+};
+
+/**
+ * Frozen-sweep guard (#U10): if every layout-grid sample produced the exact
+ * same geometry+opacity fingerprint (see layout-audit.browser.js), the seek
+ * never actually advanced the composition's timeline — every other green
+ * verdict from this run is meaningless, not just a missed defect. Skips
+ * short (<3s) compositions, single-sample runs (nothing to compare), and
+ * runs where a `motion_frozen` finding already reported the same underlying
+ * symptom (no double-reporting the one thing that's wrong).
+ */
+function detectSweepStatic(
+ duration: number,
+ geometrySignatures: string[],
+ motionIssues: AnchoredLayoutIssue[],
+): AnchoredLayoutIssue[] {
+ if (duration < SWEEP_STATIC_MIN_DURATION_SEC) return [];
+ if (geometrySignatures.length < 2) return [];
+ if (motionIssues.some((issue) => issue.code === "motion_frozen")) return [];
+ const [first, ...rest] = geometrySignatures;
+ if (!first || rest.some((signature) => signature !== first)) return [];
+ return [
+ {
+ code: "sweep_static",
+ severity: "error",
+ time: 0,
+ selector: "[data-composition-id]",
+ dataAttributes: {},
+ sourceFile: "index.html",
+ bbox: ZERO_BBOX,
+ rect: ZERO_LAYOUT_RECT,
+ message:
+ "Timeline did not advance under seek; every green verdict on this run is unreliable.",
+ fixHint:
+ "Confirm the composition seeks a paused GSAP/CSS timeline under `data-*` timing attributes rather than only autoplaying.",
+ },
+ ];
+}
+
+/** Error-severity findings with real geometry become labeled overview boxes.
+ * Contrast failures are annotated separately by the driver itself, since
+ * they're only known once contrast measurement for this sample completes. */
+function annotationBoxesFrom(issues: AnchoredLayoutIssue[]): CheckAnnotationBox[] {
+ return issues
+ .filter((issue) => issue.severity === "error" && issue.bbox.width > 0 && issue.bbox.height > 0)
+ .map((issue, index) => ({ label: `${index + 1} ${issue.code}`, bbox: issue.bbox }));
+}
+
+export async function runAuditGrid(
+ driver: CheckAuditDriver,
+ options: CheckOptions,
+ motion: MotionSpecResolution,
+): Promise {
+ await driver.initialize(options.contrast);
+ const grid = await buildSampleGrid(driver, options);
+ const plan = await planMotionSampling(driver, motion, grid.duration);
+ const seekLoopStart = Date.now();
+ const collected = await collectGridSamples(driver, options, grid, plan);
+ const seekLoopMs = Date.now() - seekLoopStart;
+
+ let motionIssues = plan.preflightIssues;
+ if (motion.kind === "valid" && motionIssues.length === 0 && collected.motionFrames.length > 0) {
+ const evaluated = evaluateMotion(
+ collected.motionFrames,
+ motion.spec.assertions,
+ await driver.getCanvas(),
+ );
+ motionIssues = await driver.anchorMotionIssues(evaluated);
+ }
+ const sweepFindings = detectSweepStatic(
+ grid.duration,
+ collected.geometrySignatures,
+ motionIssues,
+ );
+ const contrast = buildContrastResults(collected.contrastEntries);
+ return {
+ duration: grid.duration,
+ layoutSamples: grid.layoutSamples,
+ transitionSamples: grid.transitionSamples,
+ transitionSamplesDropped: grid.transitionSamplesDropped,
+ runtimeFindings: [],
+ layoutIssues: [...collected.layoutIssues, ...sweepFindings],
+ motionIssues,
+ motionSampleCount: collected.motionFrames.length,
+ contrastSamples: grid.contrastSamples,
+ contrastFindings: contrast.findings,
+ contrastChecked: collected.contrastEntries.length,
+ contrastPassed: contrast.passed,
+ screenshots: collected.screenshots,
+ timings: { launchSettleMs: 0, seekLoopMs, contrastMs: collected.contrastMs },
+ };
+}
+
+export async function runCheckPipeline(
+ project: ProjectDir,
+ options: CheckOptions,
+ dependencies: CheckDependencies = DEFAULT_DEPENDENCIES,
+): Promise {
+ let lintResult: ProjectLintResult;
+ try {
+ lintResult = await dependencies.lintProject(project.dir);
+ } catch (error) {
+ // The linter itself crashed (unreadable file, internal error) — distinct
+ // from lint findings; a runtime-failure code would send the agent hunting
+ // for a script problem that doesn't exist.
+ return failureReport(options, runtimeFailure(error, "check_lint_failure"));
+ }
+
+ const lint = buildLintSection(lintResult);
+ if (shouldBlockRender(true, false, lintResult.totalErrors, lintResult.totalWarnings)) {
+ return buildReport(options, lint, emptyBrowserResult(), { kind: "none" }, [], []);
+ }
+
+ const motion = dependencies.resolveMotionSpec(project.dir);
+ if (motion.kind === "invalid") {
+ const finding = findingAtRoot(
+ "motion_spec_invalid",
+ "error",
+ motion.message,
+ relative(project.dir, motion.path) || "index.motion.json",
+ );
+ return buildReport(options, lint, emptyBrowserResult(), motion, [finding], []);
+ }
+
+ let browser: CheckBrowserResult;
+ try {
+ browser = await dependencies.runBrowserCheck(project, options, motion);
+ } catch (error) {
+ browser = emptyBrowserResult();
+ browser.runtimeFindings.push(runtimeFailure(error));
+ }
+
+ const snapshotFiles = options.snapshots
+ ? await writeContrastSnapshots(dependencies, project.dir, browser)
+ : [];
+ const report = buildReport(options, lint, browser, motion, [], snapshotFiles);
+ return options.snapshots
+ ? await withFindingCrops(dependencies, project, options, report)
+ : report;
+}
+
+/** Persists the contrast pass's already-captured overview PNGs (or the
+ * annotated versions — see `collectContrast`'s overlay). A write failure
+ * becomes a runtime finding rather than aborting the whole report. */
+async function writeContrastSnapshots(
+ dependencies: CheckDependencies,
+ projectDir: string,
+ browser: CheckBrowserResult,
+): Promise {
+ const files: string[] = [];
+ for (let index = 0; index < browser.screenshots.length; index += 1) {
+ const shot = browser.screenshots[index];
+ if (!shot) continue;
+ try {
+ files.push(await dependencies.writeSnapshot(projectDir, index, shot.time, shot.pngBase64));
+ } catch (error) {
+ browser.runtimeFindings.push(runtimeFailure(error, "snapshot_write_failed"));
+ }
+ }
+ return files;
+}
+
+/** Finding crops are bonus evidence, not gating — no eligible finding, or a
+ * capture failure (e.g. a second Chrome launch failing), returns the report
+ * unchanged rather than sinking an otherwise-good run. */
+async function withFindingCrops(
+ dependencies: CheckDependencies,
+ project: ProjectDir,
+ options: CheckOptions,
+ report: CheckReport,
+): Promise {
+ const cropRequests = selectFindingCropRequests(report);
+ if (cropRequests.length === 0) return report;
+ try {
+ const findingFiles = await dependencies.captureFindingCrops(project, options, cropRequests);
+ return { ...report, snapshots: { ...report.snapshots, findingFiles } };
+ } catch (error) {
+ // Still non-gating, but observable: rollouts need the crop-failure rate
+ // (a second Chrome launch failing/timing out) without failing the run.
+ console.error(" finding crops skipped: " + normalizeErrorMessage(error));
+ trackCommandFailure("check-finding-crops", error);
+ return report;
+ }
+}
+
+const MAX_FINDING_CROPS = 12;
+
+/** Which error findings get a `finding-NN-.png` crop for `check --snapshots`:
+ * error severity, a real (non-zero) bbox, capped at 12. Pure and order-preserving
+ * so it's directly unit-testable without a browser. */
+export function selectFindingCropRequests(report: CheckReport): CheckFindingCropRequest[] {
+ const candidates: CheckFinding[] = [
+ ...report.layout.findings,
+ ...report.motion.findings,
+ ...report.contrast.findings,
+ ...report.runtime.findings,
+ ];
+ const requests: CheckFindingCropRequest[] = [];
+ for (const finding of candidates) {
+ if (requests.length >= MAX_FINDING_CROPS) break;
+ if (finding.severity !== "error" || !hasRealBbox(finding.bbox)) continue;
+ requests.push({
+ filename: findingCropFilename(requests.length, finding.code),
+ time: finding.time,
+ bbox: finding.bbox,
+ });
+ }
+ return requests;
+}
+
+function hasRealBbox(bbox: CheckBbox): boolean {
+ return bbox.width > 0 && bbox.height > 0;
+}
+
+export function findingCropFilename(index: number, code: string): string {
+ const safeCode = code.replace(/[^a-zA-Z0-9_-]/g, "_");
+ return `finding-${String(index).padStart(2, "0")}-${safeCode}.png`;
+}
+
+export function checkExitCode(report: CheckReport): 0 | 1 {
+ return report.ok ? 0 : 1;
+}
+
+// Same persistence rule the layout findings follow: a failure observed at a
+// single contrast sample is usually text caught mid-entrance/exit (its real
+// background not painted yet — white-on-white at exactly 1.0 is the classic
+// shape), so it demotes to warning. A failure HELD at 2+ samples for the same
+// element is a real, gating defect. Single-sample sweeps can't distinguish,
+// so they keep full severity.
+function contrastFailureHeld(
+ entries: ContrastAuditEntry[],
+): (entry: ContrastAuditEntry) => boolean {
+ const sampledTimes = new Set(entries.map((entry) => entry.time)).size;
+ const failureSamples = new Map>();
+ for (const entry of entries) {
+ if (entry.wcagAA) continue;
+ const key = `${entry.selector}|${entry.text}`;
+ const times = failureSamples.get(key) ?? new Set();
+ times.add(entry.time);
+ failureSamples.set(key, times);
+ }
+ return (entry) =>
+ sampledTimes < 2 || (failureSamples.get(`${entry.selector}|${entry.text}`)?.size ?? 0) >= 2;
+}
+
+function buildContrastResults(entries: ContrastAuditEntry[]): {
+ findings: CheckContrastFinding[];
+ passed: number;
+} {
+ const findings: CheckContrastFinding[] = [];
+ let passed = 0;
+ const isHeld = contrastFailureHeld(entries);
+ for (const entry of entries) {
+ if (entry.wcagAA) {
+ passed += 1;
+ continue;
+ }
+ const held = isHeld(entry);
+ const requiredRatio = requiredContrastRatio(entry.large);
+ findings.push({
+ code: "contrast_aa_failure",
+ severity: held ? "error" : "warning",
+ message: `Contrast is ${entry.ratio}:1; WCAG AA requires ${requiredRatio}:1.`,
+ text: entry.text,
+ fg: entry.fg,
+ bg: entry.bg,
+ ratio: entry.ratio,
+ requiredRatio,
+ suggestedColor: suggestedColor(entry.fg, entry.bg, requiredRatio),
+ large: entry.large,
+ selector: entry.selector,
+ dataAttributes: entry.dataAttributes,
+ sourceFile: entry.sourceFile,
+ bbox: entry.bbox,
+ time: entry.time,
+ });
+ }
+ return { findings, passed };
+}
+
+function suggestedColor(fg: string, bg: string, requiredRatio: number): string {
+ const foreground = parseColorRGBA(fg);
+ const background = parseColorRGBA(bg);
+ if (!foreground || !background) return fg;
+ const fgRgb: Rgb = [foreground[0], foreground[1], foreground[2]];
+ const bgRgb: Rgb = [background[0], background[1], background[2]];
+ const suggested = suggestCompliantForegroundColor(fgRgb, bgRgb, requiredRatio);
+ return `rgb(${suggested[0]},${suggested[1]},${suggested[2]})`;
+}
+
+function buildLintSection(result: ProjectLintResult): CheckReport["lint"] {
+ const findings = result.results.flatMap(({ file, result: fileResult }) =>
+ fileResult.findings.map((finding) => ({
+ code: finding.code,
+ severity: finding.severity,
+ message: finding.message,
+ selector:
+ finding.selector ?? (finding.elementId ? `#${finding.elementId}` : "[data-composition-id]"),
+ dataAttributes: {},
+ sourceFile: finding.file ?? file,
+ bbox: ZERO_BBOX,
+ time: 0,
+ fixHint: finding.fixHint,
+ })),
+ );
+ return { ...section(findings), filesScanned: result.results.length };
+}
+
+function buildReport(
+ options: CheckOptions,
+ lint: CheckReport["lint"],
+ browser: CheckBrowserResult,
+ motion: MotionSpecResolution,
+ extraMotionFindings: CheckFinding[],
+ snapshotFiles: string[],
+): CheckReport {
+ const layout = shapeLayoutSection(browser.layoutIssues, browser, options);
+ const shapedMotion = shapeLayoutFindings(browser.motionIssues, options);
+ const motionFindings: CheckFinding[] = [...shapedMotion.findings, ...extraMotionFindings];
+ const runtime = section(browser.runtimeFindings);
+ const motionSection = section(motionFindings);
+ const contrastSection = section(browser.contrastFindings);
+ const warningCount =
+ lint.warningCount +
+ runtime.warningCount +
+ layout.warningCount +
+ motionSection.warningCount +
+ contrastSection.warningCount;
+ const errorCount =
+ lint.errorCount +
+ runtime.errorCount +
+ layout.errorCount +
+ motionSection.errorCount +
+ contrastSection.errorCount;
+ const report: CheckReport = {
+ ok: errorCount === 0 && (!options.strict || warningCount === 0),
+ strict: options.strict,
+ lint,
+ runtime,
+ layout,
+ motion: {
+ ...motionSection,
+ enabled: motion.kind !== "none",
+ specPath: motion.kind === "none" ? undefined : motion.path,
+ samples: browser.motionSampleCount,
+ },
+ contrast: {
+ ...contrastSection,
+ enabled: options.contrast,
+ samples: browser.contrastSamples,
+ checked: browser.contrastChecked,
+ passed: browser.contrastPassed,
+ },
+ snapshots: {
+ enabled: options.snapshots,
+ files: snapshotFiles,
+ times: options.snapshots ? browser.screenshots.map((shot) => shot.time) : [],
+ findingFiles: [],
+ },
+ };
+ trackCheckReport({
+ contrastGate: options.contrast,
+ motionGate: motion.kind !== "none",
+ captionZoneGate: options.captionZone !== undefined,
+ frameCheckGate: options.frameCheck !== undefined,
+ snapshotsGate: options.snapshots,
+ lintErrors: lint.errorCount,
+ lintWarnings: lint.warningCount,
+ runtimeErrors: runtime.errorCount,
+ runtimeWarnings: runtime.warningCount,
+ layoutErrors: layout.errorCount,
+ layoutWarnings: layout.warningCount,
+ motionErrors: motionSection.errorCount,
+ motionWarnings: motionSection.warningCount,
+ contrastErrors: contrastSection.errorCount,
+ contrastWarnings: contrastSection.warningCount,
+ launchSettleMs: browser.timings.launchSettleMs,
+ seekLoopMs: browser.timings.seekLoopMs,
+ contrastMs: browser.timings.contrastMs,
+ gridPoints: browser.layoutSamples.length,
+ contrastPoints: browser.contrastChecked,
+ ok: report.ok,
+ exitCode: checkExitCode(report),
+ runId: getRunId(),
+ });
+ return report;
+}
+
+function shapeLayoutSection(
+ issues: AnchoredLayoutIssue[],
+ browser: CheckBrowserResult,
+ options: CheckOptions,
+): CheckReport["layout"] {
+ const shaped = shapeLayoutFindings(issues, options, browser.layoutSamples.length);
+ return {
+ ...section(shaped.findings),
+ duration: browser.duration,
+ samples: browser.layoutSamples,
+ transitionSamples: browser.transitionSamples,
+ transitionSamplesDropped: browser.transitionSamplesDropped,
+ tolerance: options.tolerance,
+ totalIssueCount: shaped.totalIssueCount,
+ truncated: shaped.truncated,
+ };
+}
+
+function shapeLayoutFindings(
+ issues: AnchoredLayoutIssue[],
+ options: CheckOptions,
+ totalSampleCount?: number,
+): { findings: AnchoredLayoutIssue[]; totalIssueCount: number; truncated: boolean } {
+ const deduped = dedupeLayoutIssues(issues);
+ const all = options.collapseStatic
+ ? collapseStaticLayoutIssues(deduped, totalSampleCount)
+ : deduped;
+ const limited = limitLayoutIssues(all, options.maxIssues);
+ return {
+ findings: limited.issues.map(ensureAnchoredLayoutIssue),
+ totalIssueCount: limited.totalIssueCount,
+ truncated: limited.truncated,
+ };
+}
+
+function ensureAnchoredLayoutIssue(issue: LayoutIssue): AnchoredLayoutIssue {
+ const sourceFile = Reflect.get(issue, "sourceFile");
+ const dataAttributes = Reflect.get(issue, "dataAttributes");
+ const bbox = Reflect.get(issue, "bbox");
+ if (typeof sourceFile === "string" && isStringRecord(dataAttributes) && isBbox(bbox)) {
+ return { ...issue, sourceFile, dataAttributes, bbox };
+ }
+ return {
+ ...issue,
+ sourceFile: "index.html",
+ dataAttributes: {},
+ bbox: rectToBbox(issue.rect),
+ };
+}
+
+function section(findings: T[]): CheckSection {
+ const errorCount = findings.filter((finding) => finding.severity === "error").length;
+ const warningCount = findings.filter((finding) => finding.severity === "warning").length;
+ const infoCount = findings.filter((finding) => finding.severity === "info").length;
+ return { ok: errorCount === 0, errorCount, warningCount, infoCount, findings };
+}
+
+function emptyBrowserResult(): CheckBrowserResult {
+ return {
+ duration: 0,
+ layoutSamples: [],
+ transitionSamples: [],
+ transitionSamplesDropped: 0,
+ runtimeFindings: [],
+ layoutIssues: [],
+ motionIssues: [],
+ motionSampleCount: 0,
+ contrastSamples: [],
+ contrastFindings: [],
+ contrastChecked: 0,
+ contrastPassed: 0,
+ screenshots: [],
+ timings: { launchSettleMs: 0, seekLoopMs: 0, contrastMs: 0 },
+ };
+}
+
+function runtimeFailure(error: unknown, code = "check_runtime_failure"): CheckFinding {
+ return findingAtRoot(code, "error", normalizeErrorMessage(error), "index.html");
+}
+
+function findingAtRoot(
+ code: string,
+ severity: CheckSeverity,
+ message: string,
+ sourceFile: string,
+): CheckFinding {
+ return {
+ code,
+ severity,
+ message,
+ selector: "[data-composition-id]",
+ dataAttributes: {},
+ sourceFile,
+ bbox: ZERO_BBOX,
+ time: 0,
+ };
+}
+
+function failureReport(options: CheckOptions, finding: CheckFinding): CheckReport {
+ const lint = { ...section([]), filesScanned: 0 };
+ const browser = emptyBrowserResult();
+ browser.runtimeFindings.push(finding);
+ return buildReport(options, lint, browser, { kind: "none" }, [], []);
+}
+
+function isBbox(value: unknown): value is CheckBbox {
+ if (typeof value !== "object" || value === null) return false;
+ return ["x", "y", "width", "height"].every((key) => typeof Reflect.get(value, key) === "number");
+}
+
+function isStringRecord(value: unknown): value is Record {
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
+ return Object.keys(value).every((key) => typeof Reflect.get(value, key) === "string");
+}
+
+function resolveMotionSpec(projectDir: string): MotionSpecResolution {
+ const path = findMotionSpec(projectDir);
+ if (!path) return { kind: "none" };
+ const result = readMotionSpec(path);
+ return result.ok
+ ? { kind: "valid", path, spec: result.spec }
+ : {
+ kind: "invalid",
+ path,
+ message: `Invalid motion spec ${path}: ${result.errors.join("; ")}`,
+ };
+}
+
+async function runBrowserCheck(
+ project: ProjectDir,
+ options: CheckOptions,
+ motion: MotionSpecResolution,
+): Promise {
+ const module = await import("./checkBrowser.js");
+ // runAuditGrid is handed over as a callback so checkBrowser never imports
+ // this module back (no import cycle).
+ return module.runBrowserCheck(project, options, motion, runAuditGrid);
+}
+
+async function writeSnapshot(
+ projectDir: string,
+ index: number,
+ time: number,
+ pngBase64: string,
+): Promise {
+ const snapshotDir = join(projectDir, "snapshots");
+ mkdirSync(snapshotDir, { recursive: true });
+ const filename = `frame-${String(index).padStart(2, "0")}-at-${time.toFixed(1)}s.png`;
+ const path = join(snapshotDir, filename);
+ writeFileSync(path, Buffer.from(pngBase64, "base64"));
+ return join("snapshots", filename);
+}
+
+async function captureFindingCrops(
+ project: ProjectDir,
+ options: CheckOptions,
+ requests: CheckFindingCropRequest[],
+): Promise {
+ const module = await import("./checkBrowser.js");
+ // Handed over the same way runBrowserCheck is (checkBrowser never imports this module back).
+ return module.captureFindingCrops(project, options, requests);
+}
+
+const DEFAULT_DEPENDENCIES: CheckDependencies = {
+ lintProject,
+ resolveMotionSpec,
+ runBrowserCheck,
+ writeSnapshot,
+ captureFindingCrops,
+};
diff --git a/packages/cli/src/utils/checkTypes.ts b/packages/cli/src/utils/checkTypes.ts
new file mode 100644
index 0000000000..02996dc9e5
--- /dev/null
+++ b/packages/cli/src/utils/checkTypes.ts
@@ -0,0 +1,246 @@
+import type { ProjectLintResult } from "./lintProject.js";
+import type { LayoutIssue, LayoutOverflow, LayoutRect } from "./layoutAudit.js";
+import type { Canvas, MotionFrame } from "./motionAudit.js";
+import type { MotionSpec } from "./motionSpec.js";
+import type { ProjectDir } from "./project.js";
+
+export interface CheckOptions {
+ samples: number;
+ at?: number[];
+ atTransitions: boolean;
+ maxTransitionSamples?: number;
+ maxIssues: number;
+ collapseStatic: boolean;
+ tolerance: number;
+ timeout: number;
+ contrast: boolean;
+ strict: boolean;
+ snapshots: boolean;
+ captionZone?: CaptionZoneOptions;
+ frameCheck?: FrameCheckOptions;
+}
+
+export interface CaptionZoneOptions {
+ x0: number;
+ y0: number;
+ x1: number;
+ y1: number;
+ severity?: "error" | "warning";
+ seek?: number[];
+}
+
+export interface FrameCheckOptions {
+ tol?: number;
+ severity?: "error" | "warning";
+ seek?: number[];
+}
+
+export type CheckSeverity = "error" | "warning" | "info";
+
+export interface CheckBbox {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+}
+
+export interface CheckAnchor {
+ selector: string;
+ dataAttributes: Record;
+ sourceFile: string;
+ bbox: CheckBbox;
+ time: number;
+}
+
+export interface CheckFinding extends CheckAnchor {
+ code: string;
+ severity: CheckSeverity;
+ message: string;
+ text?: string;
+ fixHint?: string;
+ url?: string;
+ line?: number;
+}
+
+export interface AnchoredLayoutIssue extends LayoutIssue, CheckAnchor {}
+
+export interface ContrastAuditEntry extends CheckAnchor {
+ text: string;
+ ratio: number;
+ wcagAA: boolean;
+ large: boolean;
+ fg: string;
+ bg: string;
+}
+
+export interface CheckContrastFinding extends CheckFinding {
+ fg: string;
+ bg: string;
+ ratio: number;
+ requiredRatio: number;
+ suggestedColor: string;
+ large: boolean;
+}
+
+export interface ContrastCapture {
+ entries: ContrastAuditEntry[];
+ pngBase64: string;
+}
+
+export interface GeometryCandidateRequest {
+ text: boolean;
+ media: boolean;
+ tolerance: number;
+}
+
+/** A labeled rectangle drawn on an overview frame's annotation overlay
+ * (`check --snapshots`) so one screenshot orients an agent across every
+ * error finding at that sample time. */
+export interface CheckAnnotationBox {
+ label: string;
+ bbox: CheckBbox;
+}
+
+/** A single crop to capture for `check --snapshots`'s per-finding evidence
+ * PNGs — filename and bbox already resolved by the pipeline. */
+export interface CheckFindingCropRequest {
+ filename: string;
+ time: number;
+ bbox: CheckBbox;
+}
+
+export interface CheckGeometryCandidate extends CheckAnchor {
+ kind: "text" | "media";
+ tag: string;
+ text: string;
+ rect: LayoutRect;
+ elementRect: LayoutRect;
+ overflow?: LayoutOverflow;
+}
+
+export type MotionSpecResolution =
+ | { kind: "none" }
+ | { kind: "valid"; path: string; spec: MotionSpec }
+ | { kind: "invalid"; path: string; message: string };
+
+export interface CheckAuditDriver {
+ initialize(contrast: boolean): Promise;
+ getDuration(): Promise;
+ getTransitionBoundaries(): Promise;
+ getCanvas(): Promise