Merge pull request #2138 from heygen-com/feat/check-command

feat(cli): hyperframes check — the single-session verification gate
This commit is contained in:
Miguel Ángel
2026-07-10 18:47:23 -04:00
committed by GitHub
77 changed files with 7077 additions and 509 deletions
@@ -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<CompositionSeekPage["evaluate"]>[0],
_value?: number,
_fallbackToBridgeAndTimelines?: boolean,
): Promise<unknown> => undefined,
);
const waitForFunction = vi.fn(
async (_pageFunction: () => boolean, _options: { timeout: number }): Promise<unknown> =>
undefined,
);
const page: CompositionSeekPage = { evaluate, waitForFunction };
return { page, evaluate, waitForFunction };
}
function runBrowserSeek(evaluate: ReturnType<typeof fakeSeekPage>["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);
});
});
@@ -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<unknown>;
}
export interface CompositionSeekPage extends CompositionEvaluationPage {
waitForFunction?(pageFunction: () => boolean, options: { timeout: number }): Promise<unknown>;
}
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<void>;
}
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<unknown>;
}): Promise<void> {
await page.evaluateOnNewDocument("self.__name = self.__name || ((fn) => fn);");
}
export async function openSettledCompositionPage(
html: string,
url: string,
options: OpenSettledCompositionPageOptions,
): Promise<SettledCompositionPage> {
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, "evaluate">,
page: CompositionSeekPage,
timeSeconds: number,
options: SeekCompositionTimelineOptions = {},
): Promise<void> {
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);
if (options.waitForPreferredSeekTargetMs !== undefined) {
await waitForPreferredSeekTarget(page, options.waitForPreferredSeekTargetMs);
}
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); });
})`);
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<void>((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<CompositionSeekPage, "waitForFunction">,
timeoutMs = PREFERRED_SEEK_TARGET_WAIT_MS,
): Promise<void> {
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<void> {
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<void>((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<CropRegion | null>;
}
/**
* 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<CropRegion | null> {
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<void>;
screenshot(options: { clip: CropRegion; type: "png" }): Promise<Uint8Array>;
}
/**
* 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<Buffer> {
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(
+12 -2
View File
@@ -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?.();
});
File diff suppressed because it is too large Load Diff
+409
View File
@@ -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<CheckReport>;
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<string, unknown>): 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<string, string> {
const fields = new Map<string, string>();
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<string, string> {
const fields = new Map<string, string>();
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<string, string>): {
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<string, string>, 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();
@@ -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;
+56 -1
View File
@@ -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,
]);
});
});
+53
View File
@@ -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];
@@ -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("<path>", () => 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<CommandMeta>` (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<T extends ArgsDef = ArgsDef>(command: CommandDef<T>): 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<T extends ArgsDef = ArgsDef>(
command: CommandDef<T>,
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<T extends ArgsDef = ArgsDef>(
command: CommandDef<T>,
rawArgs: string[] = ["--json"],
): Promise<unknown[] | undefined> {
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<T extends ArgsDef = ArgsDef>(
command: CommandDef<T>,
rawArgs: string[] = ["--json"],
) {
const jsonCall = await runAndFindJsonLogCall(command, rawArgs);
expect(jsonCall).toBeDefined();
const parsed = JSON.parse(String(jsonCall?.[0]));
return { jsonCall, parsed };
}
+15 -20
View File
@@ -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<string, string>;
};
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<string, string>;
};
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<string, string>;
};
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 });
}
+6 -3
View File
@@ -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<string, string> {
return {
dev: hyperframesScript("preview"),
check:
`${hyperframesScript("lint")} && ${hyperframesScript("validate")} && ` +
`${hyperframesScript("inspect")}`,
check: hyperframesScript("check"),
render: hyperframesScript("render"),
publish: hyperframesScript("publish"),
};
+33
View File
@@ -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("");
});
});
+212 -29
View File
@@ -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("|");
};
})();
@@ -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 = `
<div id="root" data-composition-id="main" data-width="640" data-height="360">
<div id="first"></div>
<div id="second"></div>
<div id="third"></div>
<div id="late">Late visible copy</div>
</div>
`;
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 = `
<div id="root" data-composition-id="main" data-width="640" data-height="360">
<div id="copy">Visible copy</div>
</div>
`;
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 = `
<div id="root" data-composition-id="main" data-width="640" data-height="360">
<section data-composition-file="scenes/hero.html">
<div id="copy" data-layout-name="copy">Own copy <span id="nested">Nested</span></div>
<img id="image" src="data:image/png;base64,AA==" />
<svg id="vector"></svg>
</section>
<div class="caption-layer"><p id="caption">Authored captions</p></div>
</div>
`;
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 = `
<canvas id="boundary" data-composition-id="background" data-width="640" data-height="360"></canvas>
<div id="root" data-composition-id="main" data-width="640" data-height="360">
<p id="portal-copy">Portal copy</p>
</div>
<img id="portal-image" src="data:image/png;base64,AA==" />
`;
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 = `
<div id="root" data-composition-id="main" data-width="640" data-height="360">
<img class="tile" src="data:image/png;base64,AA==" />
<img class="tile" src="data:image/png;base64,AA==" />
</div>
`;
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 = `
<div id="root" data-composition-id="main" data-width="640" data-height="360">
<p id="clipped-copy">Visible clipped copy</p>
</div>
`;
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 = `
<div id="root" data-composition-id="main" data-width="640" data-height="360">
<div id="faint-parent"><p id="hidden-copy">Hidden copy</p></div>
<div id="soft-parent"><p id="visible-copy">Visible copy</p></div>
<div id="stacked-parent"><p id="stacked-copy">Stacked opacity copy</p></div>
</div>
`;
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 = `
<div id="root" data-composition-id="main" data-width="640" data-height="360">
<div data-layout-ignore>
<div id="rail-label">SHAPE</div>
</div>
<div id="headline">Readable copy</div>
</div>
`;
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 = `
<div id="root" data-composition-id="main" data-width="640" data-height="360">
<div id="exited">You</div>
<div id="headline">Readable copy</div>
</div>
`;
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<typeof runAudit> {
const textRect = { left: 200, top: 500, width: 600, height: 80 };
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
<div id="headline">${options.text}</div>
<div id="overlay"></div>
</div>
`;
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<Record<string, string>>;
@@ -601,28 +974,31 @@ async function runContrastAudit(): Promise<Array<Record<string, unknown>>> {
return w.__contrastAuditFinish("stub", 0, candidates);
}
function runAudit(): Array<{
interface AuditIssue {
code: string;
selector: string;
containerSelector?: string;
overflow?: Record<string, number>;
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<string, number>;
message?: string;
}>;
__hyperframesLayoutAudit: (options: { time: number; tolerance: number }) => AuditIssue[];
}
).__hyperframesLayoutAudit;
return audit({ time: 1, tolerance: 2 });
}
function installGeometry(rects: Record<string, DOMRect>): void {
function installGeometry(
rects: Record<string, DOMRect>,
styleOverrides: Record<string, Partial<CSSStyleDeclaration>> = {},
): 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<string, DOMRect>): 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<string, DOMRect>): void {
});
}
interface GeometryCandidateResult {
kind: "text" | "media";
tag: string;
text: string;
selector: string;
sourceFile: string;
rect: Record<string, number>;
elementRect: Record<string, number>;
overflow?: Record<string, number>;
}
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,
+51
View File
@@ -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);
});
});
+72 -102
View File
@@ -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<number> {
// 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<void> {
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<void>((resolve) => setTimeout(resolve, ms)),
]);
}, timeoutMs)
.catch(() => {});
}
async function seekTo(page: import("puppeteer-core").Page, time: number): Promise<void> {
await page.evaluate((t: number) => {
const win = window as unknown as {
__hf?: { seek?: (time: number) => void };
__player?: { seek?: (time: number) => void };
__timelines?: Record<string, { pause?: () => 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<void>((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<LayoutAuditResult> {
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<MotionFrame[]> {
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,
),
+21 -1
View File
@@ -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);
});
});
+56 -1
View File
@@ -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<string[]> {
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) {
+59 -2
View File
@@ -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);
});
});
+40 -77
View File
@@ -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<void> {
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<string, unknown>).__timelines as
| Record<string, { seek: (t: number) => 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<unknown>;
}
export async function waitForPreferredSeekTarget(
page: WaitForFunctionPage,
timeoutMs = PREFERRED_SEEK_TARGET_WAIT_MS,
): Promise<void> {
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<void>((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<Co
const results: ContrastEntry[] = [];
for (let i = 0; i < CONTRAST_SAMPLES; i++) {
const t = +(((i + 0.5) / CONTRAST_SAMPLES) * duration).toFixed(3);
await seekTo(page, t);
await seekCompositionTimeline(page, t, {
fallbackToBridgeAndTimelines: true,
waitForPreferredSeekTargetMs: PREFERRED_SEEK_TARGET_WAIT_MS,
animationFrameSettle: "none",
settleMs: SEEK_SETTLE_MS,
});
try {
// __contrastAuditPrepare() hides each candidate text element's own
@@ -459,15 +412,17 @@ async function validateInBrowser(
const browser = await ensureBrowser();
const puppeteer = await import("puppeteer-core");
const { buildChromeArgs, analyzeClipMediaFit } = await import("@hyperframes/engine");
const browserGpuMode =
process.env.PRODUCER_BROWSER_GPU_MODE === "software" ? "software" : "hardware";
const chromeBrowser = await puppeteer.default.launch({
headless: true,
executablePath: browser.executablePath,
args: buildChromeArgs({ ...viewport, captureMode: "screenshot" }, { browserGpuMode }),
args: buildChromeArgs(
{ ...viewport, captureMode: "screenshot" },
{ browserGpuMode: resolveCliChromeGpuMode() },
),
});
const page = await chromeBrowser.newPage();
await installPageFunctionGuard(page);
await page.setViewport(viewport);
page.on("console", (msg) => {
@@ -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;
+145
View File
@@ -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();
+65 -2
View File
@@ -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),
});
}
+73
View File
@@ -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();
});
});
+12
View File
@@ -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;
}
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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
+302
View File
@@ -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 () => "<html></html>"),
}));
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<typeof import("../capture/captureCompositionFrame.js")>()),
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<typeof import("../commands/validate.js")>()),
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<typeof fakePage>): 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 = `
<div data-composition-id="main" data-duration="10" data-width="640" data-height="360">${inner}</div>
`;
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(`
<section data-composition-file="scenes/hero.html">
<img id="hero-image" data-layout-name="hero" src="data:image/png;base64,AA==" />
</section>
`);
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(`
<div id="headline">Readable copy</div>
`);
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<Record<string, unknown>> = [];
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<unknown>) & { 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<Record<string, unknown>>));
}
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<string, unknown>;
// 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);
}),
});
}
File diff suppressed because it is too large Load Diff
+976
View File
@@ -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<SampleGrid> {
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<MotionPlan> {
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<string>;
frame: Set<string>;
}
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<string>,
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<AnchoredLayoutIssue[]> {
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<GridSamples> {
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<CheckBrowserResult> {
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<CheckReport> {
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<string[]> {
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<CheckReport> {
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-<code>.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<string, Set<number>>();
for (const entry of entries) {
if (entry.wcagAA) continue;
const key = `${entry.selector}|${entry.text}`;
const times = failureSamples.get(key) ?? new Set<number>();
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<T extends CheckFinding>(findings: T[]): CheckSection<T> {
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<string, string> {
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<CheckBrowserResult> {
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<string> {
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<string[]> {
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,
};
+246
View File
@@ -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<string, string>;
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<void>;
getDuration(): Promise<number>;
getTransitionBoundaries(): Promise<number[]>;
getCanvas(): Promise<Canvas>;
findAmbiguousSelectors(selectors: string[]): Promise<AnchoredLayoutIssue[]>;
seek(time: number): Promise<void>;
collectLayout(time: number, tolerance: number): Promise<AnchoredLayoutIssue[]>;
/** Frozen-sweep guard (#U10): an opaque per-sample geometry+opacity
* fingerprint of the current seeked state, for detecting a timeline that
* never advances under seek. See layout-audit.browser.js. */
collectLayoutGeometry(): Promise<string>;
collectGeometryCandidates(
time: number,
request: GeometryCandidateRequest,
): Promise<CheckGeometryCandidate[]>;
collectMotionFrame(
time: number,
selectors: string[],
livenessScopes: string[],
): Promise<MotionFrame>;
anchorMotionIssues(issues: LayoutIssue[]): Promise<AnchoredLayoutIssue[]>;
collectContrast(time: number, annotations?: CheckAnnotationBox[]): Promise<ContrastCapture>;
}
export interface CheckScreenshot {
time: number;
pngBase64: string;
}
export interface CheckTimings {
launchSettleMs: number;
seekLoopMs: number;
contrastMs: number;
}
export interface CheckBrowserResult {
duration: number;
layoutSamples: number[];
transitionSamples: number[];
transitionSamplesDropped: number;
runtimeFindings: CheckFinding[];
layoutIssues: AnchoredLayoutIssue[];
motionIssues: AnchoredLayoutIssue[];
motionSampleCount: number;
contrastSamples: number[];
contrastFindings: CheckContrastFinding[];
contrastChecked: number;
contrastPassed: number;
screenshots: CheckScreenshot[];
timings: CheckTimings;
}
/** The seek-grid audit loop, injected into checkBrowser so it never imports checkPipeline back. */
export type RunAuditGrid = (
driver: CheckAuditDriver,
options: CheckOptions,
motion: MotionSpecResolution,
) => Promise<CheckBrowserResult>;
export interface CheckSection<T extends CheckFinding = CheckFinding> {
ok: boolean;
errorCount: number;
warningCount: number;
infoCount: number;
findings: T[];
}
export interface CheckReport {
ok: boolean;
strict: boolean;
lint: CheckSection & { filesScanned: number };
runtime: CheckSection;
layout: CheckSection<AnchoredLayoutIssue> & {
duration: number;
samples: number[];
transitionSamples: number[];
transitionSamplesDropped: number;
tolerance: number;
totalIssueCount: number;
truncated: boolean;
};
motion: CheckSection & { enabled: boolean; specPath?: string; samples: number };
contrast: CheckSection<CheckContrastFinding> & {
enabled: boolean;
samples: number[];
checked: number;
passed: number;
};
snapshots: { enabled: boolean; files: string[]; times: number[]; findingFiles: string[] };
}
export interface CheckDependencies {
lintProject(projectDir: string): Promise<ProjectLintResult>;
resolveMotionSpec(projectDir: string): MotionSpecResolution;
runBrowserCheck(
project: ProjectDir,
options: CheckOptions,
motion: MotionSpecResolution,
): Promise<CheckBrowserResult>;
writeSnapshot(
projectDir: string,
index: number,
time: number,
pngBase64: string,
): Promise<string>;
captureFindingCrops(
project: ProjectDir,
options: CheckOptions,
requests: CheckFindingCropRequest[],
): Promise<string[]>;
}
export function rectToBbox(rect: {
left: number;
top: number;
width: number;
height: number;
}): CheckBbox {
return { x: rect.left, y: rect.top, width: rect.width, height: rect.height };
}
@@ -199,6 +199,82 @@ describe("layoutAudit helpers", () => {
});
});
// #U10: held-duration severity tiering on top of the existing collapse step.
// Sample counts below (9) mirror the CLI's default grid so the "1 sample =
// entrance/exit transient, 2+ adjacent samples = held" framing in the
// approach doc lines up with the numbers used here.
describe("persistence-tiered severity (#U10)", () => {
it("demotes a content_overlap seen at only one sample among several to info", () => {
const collapsed = collapseStaticLayoutIssues(
[{ ...issue("content_overlap", "warning"), time: 3 }],
9,
);
expect(collapsed).toHaveLength(1);
expect(collapsed[0]).toMatchObject({ severity: "info", occurrences: 1 });
});
it("promotes content_overlap held across >= 2 adjacent samples to error", () => {
const collapsed = collapseStaticLayoutIssues(
[
{ ...issue("content_overlap", "warning"), time: 3 },
{ ...issue("content_overlap", "warning"), time: 3.6 },
],
9,
);
expect(collapsed).toHaveLength(1);
expect(collapsed[0]).toMatchObject({ severity: "error", occurrences: 2 });
});
it("does not demote a finding held at every sample — persistence, not a single hit", () => {
const collapsed = collapseStaticLayoutIssues(
[
{ ...issue("text_box_overflow", "error"), time: 1 },
{ ...issue("text_box_overflow", "error"), time: 3 },
{ ...issue("text_box_overflow", "error"), time: 5 },
],
9,
);
expect(collapsed).toHaveLength(1);
expect(collapsed[0]).toMatchObject({ severity: "error", occurrences: 3 });
});
it("only re-promotes content_overlap — other held codes keep their original severity", () => {
const collapsed = collapseStaticLayoutIssues(
[
{ ...issue("container_overflow", "warning"), time: 3 },
{ ...issue("container_overflow", "warning"), time: 3.6 },
],
9,
);
expect(collapsed[0]).toMatchObject({ severity: "warning" });
});
it("skips tiering entirely on a single-sample run — nothing to compare a transient against", () => {
const collapsed = collapseStaticLayoutIssues(
[{ ...issue("content_overlap", "warning"), time: 3 }],
1,
);
expect(collapsed[0]).toMatchObject({ severity: "warning" });
});
it("infers the sample count from distinct issue times when none is given", () => {
// Two distinct times among the raw issues imply a multi-sample run even
// without an explicit count, so the single-occurrence group still demotes.
const collapsed = collapseStaticLayoutIssues([
{ ...issue("content_overlap", "warning"), time: 3 },
{ ...issue("text_box_overflow", "error"), time: 5 },
]);
const overlap = collapsed.find((found) => found.code === "content_overlap");
expect(overlap).toMatchObject({ severity: "info" });
});
});
function issue(code: LayoutIssue["code"], severity: LayoutIssue["severity"]): LayoutIssue {
return {
code,
+105 -8
View File
@@ -16,6 +16,11 @@ export type LayoutIssueCode =
| "container_overflow"
| "content_overlap"
| "text_occluded"
| "caption_zone_collision"
| "frame_out_of_frame"
// Frozen-sweep guard (#U10) — a whole-run meta-finding, not a per-sample
// geometry observation; never persistence-tiered (see `applyPersistenceTier`).
| "sweep_static"
// Motion-verification findings (#1437) — evaluated against the seeked timeline.
| "motion_appears_late"
| "motion_out_of_order"
@@ -40,6 +45,9 @@ export interface LayoutIssue {
rect: LayoutRect;
containerRect?: LayoutRect;
overflow?: LayoutOverflow;
/** `text_occluded` only: approximate fraction (0-1) of the occlusion probe
* grid that hit an opaque occluder see layout-audit.browser.js. */
coveredFraction?: number;
fixHint?: string;
}
@@ -152,6 +160,7 @@ export function dedupeLayoutIssues(issues: LayoutIssue[]): LayoutIssue[] {
issue.containerSelector ?? "",
issue.text ?? "",
issue.overflow ? formatOverflow(issue.overflow) : "",
framePositionKey(issue),
].join("|");
if (seen.has(key)) continue;
seen.add(key);
@@ -161,7 +170,44 @@ export function dedupeLayoutIssues(issues: LayoutIssue[]): LayoutIssue[] {
return result;
}
export function collapseStaticLayoutIssues(issues: LayoutIssue[]): LayoutIssue[] {
// Persistence-tier thresholds (#U10, adapted from Adam Rosler's visual-linter
// design). The approach doc frames these as held-duration floors — ignore
// under ~250ms, re-promote content_overlap at >= ~500ms — measured against
// the SAME firstSeen/lastSeen span this collapse step already tracks. At the
// default 9-sample grid over a multi-second composition, a single collapsed
// occurrence is held 0ms (one entrance/exit transient sample) and two
// collapsed occurrences are already >= one sample-to-sample gap, which is
// well past 500ms — so "held under 250ms" reduces to `occurrences <= 1` and
// "held >= 500ms" reduces to `occurrences >= 2`. Tiering below is written in
// those sample-count terms (the mapping the approach doc asks to document),
// with the literal ms span (CONTENT_OVERLAP_HELD_ERROR_MS) kept as a fallback
// for callers whose samples really are spaced close enough together for the
// ms floor to matter on its own (dense `--at`/`--at-transitions` runs). The
// ~250ms ignore floor needs no separate constant — see the occurrences <= 1
// branch below.
const CONTENT_OVERLAP_HELD_ERROR_MS = 500;
const HELD_ACROSS_SAMPLES_MIN_OCCURRENCES = 2;
// Tiering only applies to layout-audit.browser.js's own per-sample seek-grid
// findings — the ones this collapse step's firstSeen/lastSeen span was built
// to describe. `caption_zone_collision`/`frame_out_of_frame` (a different
// script, U3) and the `motion_*`/`sweep_static` codes (evaluated once over
// the whole run, not per grid sample) already carry their own singular
// dedupe/severity semantics; re-interpreting their occurrence count as a
// held-duration signal would misread it.
const PERSISTENCE_TIERED_CODES: ReadonlySet<LayoutIssueCode> = new Set([
"text_box_overflow",
"clipped_text",
"canvas_overflow",
"container_overflow",
"content_overlap",
"text_occluded",
]);
export function collapseStaticLayoutIssues(
issues: LayoutIssue[],
totalSampleCount?: number,
): LayoutIssue[] {
const groups = new Map<
string,
{
@@ -190,13 +236,57 @@ export function collapseStaticLayoutIssues(issues: LayoutIssue[]): LayoutIssue[]
existing.occurrences += 1;
}
return [...groups.values()].map(({ issue, firstSeen, lastSeen, occurrences }) => ({
...issue,
time: firstSeen,
firstSeen,
lastSeen,
occurrences,
}));
// A run that only ever sampled one point in time can't distinguish a
// transient from a persistent finding — skip tiering entirely rather than
// guess (see `applyPersistenceTier`).
const sampleCount = totalSampleCount ?? new Set(issues.map((issue) => issue.time)).size;
const multiSampleRun = sampleCount > 1;
return [...groups.values()].map(({ issue, firstSeen, lastSeen, occurrences }) =>
applyPersistenceTier(
{ ...issue, time: firstSeen, firstSeen, lastSeen, occurrences },
multiSampleRun,
),
);
}
/**
* Held-duration severity tiering (#U10). A finding observed at only one
* sample among several (held 0ms) is an entrance/exit transient, not a held
* defect demote to info so it stays in the data (verbose/--json output)
* without gating the run. `content_overlap` specifically re-promotes from
* warning to error once it's held long enough to be a real, sustained
* collision rather than a crossfade/transition blip (resolves the TODO in
* layout-audit.browser.js's `overlapIssue`). A finding held at every sample
* (a genuinely static defect) is well past both thresholds and is left
* untouched either way persistence, not the code, decides the tier.
*/
function applyPersistenceTier(issue: LayoutIssue, multiSampleRun: boolean): LayoutIssue {
if (!multiSampleRun) return issue;
if (!PERSISTENCE_TIERED_CODES.has(issue.code)) return issue;
const occurrences = issue.occurrences ?? 1;
// A single collapsed occurrence is held 0ms by construction (firstSeen ===
// lastSeen) — always under the ignore floor, so occurrences <= 1 is a
// complete (not approximate) test for "held under 250ms".
if (occurrences <= 1) {
return { ...issue, severity: "info" };
}
if (issue.code === "content_overlap" && isContentOverlapHeldLongEnough(issue, occurrences)) {
return { ...issue, severity: "error" };
}
return issue;
}
// Split out of applyPersistenceTier so the two independent "held long enough"
// signals (sample count vs. wall-clock span) read as one boolean question
// instead of adding a third compound branch to the tiering ladder above.
function isContentOverlapHeldLongEnough(issue: LayoutIssue, occurrences: number): boolean {
if (occurrences >= HELD_ACROSS_SAMPLES_MIN_OCCURRENCES) return true;
const firstSeen = issue.firstSeen ?? issue.time;
const lastSeen = issue.lastSeen ?? issue.time;
const heldMs = (lastSeen - firstSeen) * 1000;
return heldMs >= CONTENT_OVERLAP_HELD_ERROR_MS;
}
export function limitLayoutIssues(
@@ -230,9 +320,16 @@ function staticIssueKey(issue: LayoutIssue): string {
issue.containerSelector ?? "",
issue.text ?? "",
issue.overflow ? formatOverflow(issue.overflow) : "",
framePositionKey(issue),
].join("|");
}
function framePositionKey(issue: LayoutIssue): string {
return issue.code === "frame_out_of_frame"
? `${Math.round(issue.rect.left)},${Math.round(issue.rect.top)}`
: "";
}
function uniqueSortedTimes(times: number[]): number[] {
const rounded = times.map(roundTime);
return [...new Set(rounded)].sort((a, b) => a - b);
+63 -1
View File
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { isSafeVersion } from "./updateCheck.js";
import { isSafeVersion, printDeprecationNotice, withMeta } from "./updateCheck.js";
describe("isSafeVersion", () => {
it("accepts strict semver, incl. prerelease/build metadata", () => {
@@ -150,6 +150,68 @@ async function checkWith(registryVersion: unknown): Promise<{
}
}
/**
* U5: validate/inspect/layout are deprecated in favor of `check`. withMeta's
* optional `{ deprecated: true }` is the single place that adds `_meta.deprecated`
* to a --json envelope; every other command (check, lint, ...) calls withMeta
* with no second argument and must never see the key at all not even `false`.
*/
describe("withMeta — deprecated flag", () => {
it("omits _meta.deprecated entirely when no options are passed (check/lint et al.)", () => {
const wrapped = withMeta({ ok: true });
expect("deprecated" in wrapped._meta).toBe(false);
});
it("omits _meta.deprecated when options.deprecated is false", () => {
const wrapped = withMeta({ ok: true }, { deprecated: false });
expect("deprecated" in wrapped._meta).toBe(false);
});
it("sets _meta.deprecated === true when requested (validate/inspect/layout)", () => {
const wrapped = withMeta({ ok: true }, { deprecated: true });
expect(wrapped._meta.deprecated).toBe(true);
});
it("preserves the rest of the _meta envelope alongside the deprecated flag", () => {
const wrapped = withMeta({ ok: true }, { deprecated: true });
expect(wrapped._meta.version).toEqual(expect.any(String));
expect(typeof wrapped._meta.updateAvailable).toBe("boolean");
});
});
/**
* The stderr-only deprecation notice: printed once per invocation, never on
* stdout, so --json output stays pure JSON while humans still see the notice.
*/
describe("printDeprecationNotice", () => {
it("writes exactly one line to stderr, never stdout", () => {
const stderrWrites: string[] = [];
const stdoutWrites: string[] = [];
const origErrWrite = process.stderr.write.bind(process.stderr);
const origOutWrite = process.stdout.write.bind(process.stdout);
process.stderr.write = ((chunk: unknown) => {
stderrWrites.push(String(chunk));
return true;
}) as typeof process.stderr.write;
process.stdout.write = ((chunk: unknown) => {
stdoutWrites.push(String(chunk));
return true;
}) as typeof process.stdout.write;
try {
printDeprecationNotice("validate");
} finally {
process.stderr.write = origErrWrite;
process.stdout.write = origOutWrite;
}
expect(stdoutWrites).toEqual([]);
expect(stderrWrites).toHaveLength(1);
expect(stderrWrites[0]).toContain("hyperframes validate");
expect(stderrWrites[0]).toContain("hyperframes check");
});
});
describe("checkForUpdate — registry boundary guard", () => {
afterEach(() => {
vi.doUnmock("../telemetry/config.js");
+25 -2
View File
@@ -40,6 +40,8 @@ export interface UpdateMeta {
version: string;
latestVersion?: string;
updateAvailable: boolean;
/** Present (and true) only for commands superseded by `check`; absent otherwise. */
deprecated?: boolean;
}
/**
@@ -130,9 +132,30 @@ export function getUpdateMeta(): UpdateMeta {
/**
* Wrap a JSON payload with the _meta version envelope.
* Use this in all --json command outputs for consistent agent-friendly metadata.
*
* Pass `{ deprecated: true }` from a command superseded by `check` (validate,
* inspect, layout) to add `_meta.deprecated: true`; every other call site is
* unaffected the key is only ever added, never set to `false`.
*/
export function withMeta<T extends object>(data: T): T & { _meta: UpdateMeta } {
return { ...data, _meta: getUpdateMeta() };
export function withMeta<T extends object>(
data: T,
options?: { deprecated?: boolean },
): T & { _meta: UpdateMeta } {
const meta = getUpdateMeta();
if (options?.deprecated) meta.deprecated = true;
return { ...data, _meta: meta };
}
/**
* One-line deprecation notice for a command superseded by `check`. Always
* writes to stderr (never stdout), so a --json invocation's stdout stays
* pure, parseable JSON. Call once per invocation, before the command's own
* output.
*/
export function printDeprecationNotice(command: string): void {
process.stderr.write(
`'hyperframes ${command}' is deprecated and will be removed in a future release. Use 'hyperframes check' instead.\n`,
);
}
/**