From feb256df8adf02a7aa26ebd791a9d939485c5dce Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 9 Jul 2026 21:26:35 -0400 Subject: [PATCH 01/16] refactor(cli): unify seek/settle and Chrome launch across browser commands seekCompositionTimeline becomes the single seek implementation with per-caller settle options (rAF mode, font wait, settle sleep), replacing the divergent local seekTo copies in validate and layout. All three launch paths now build args via the engine's buildChromeArgs; screenshot paths keep the engine's software-GPU default for deterministic output. inspect gains one transient content_overlap warning on product-promo (t=12.22s): the gsap.ticker.tick flush samples timeline state the old layout seek missed. --- .../capture/captureCompositionFrame.test.ts | 174 +++++++++++++++- .../src/capture/captureCompositionFrame.ts | 194 +++++++++++++++--- packages/cli/src/commands/layout.ts | 77 ++----- packages/cli/src/commands/validate.test.ts | 10 + packages/cli/src/commands/validate.ts | 85 ++------ 5 files changed, 383 insertions(+), 157 deletions(-) diff --git a/packages/cli/src/capture/captureCompositionFrame.test.ts b/packages/cli/src/capture/captureCompositionFrame.test.ts index cf942565c9..559a54f91a 100644 --- a/packages/cli/src/capture/captureCompositionFrame.test.ts +++ b/packages/cli/src/capture/captureCompositionFrame.test.ts @@ -1,13 +1,181 @@ -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 { + resolveCliChromeGpuMode, + runFfmpegOnce, + seekCompositionTimeline, + type CompositionSeekPage, +} from "./captureCompositionFrame.js"; function tempDir(): string { return mkdtempSync(join(tmpdir(), "hf-capture-frame-test-")); } +function fakeSeekPage() { + const evaluate = vi.fn( + async ( + _pageFunction: Parameters[0], + _value?: number, + _fallbackToBridgeAndTimelines?: boolean, + ): Promise => undefined, + ); + const waitForFunction = vi.fn( + async (_pageFunction: () => boolean, _options: { timeout: number }): Promise => + undefined, + ); + const page: CompositionSeekPage = { evaluate, waitForFunction }; + return { page, evaluate, waitForFunction }; +} + +function runBrowserSeek(evaluate: ReturnType["evaluate"]): void { + const seekInBrowser = evaluate.mock.calls[0]?.[0]; + if (typeof seekInBrowser !== "function") throw new Error("Expected a browser seek function"); + Reflect.apply(seekInBrowser, undefined, evaluate.mock.calls[0]?.slice(1) ?? []); +} + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +describe("seekCompositionTimeline", () => { + it("keeps the existing raced double-frame settle as the default", async () => { + const { page, evaluate, waitForFunction } = fakeSeekPage(); + + await seekCompositionTimeline(page, 1.25); + + expect(waitForFunction).not.toHaveBeenCalled(); + expect(evaluate).toHaveBeenCalledTimes(2); + expect(evaluate).toHaveBeenNthCalledWith(1, expect.any(Function), 1.25, false); + expect(evaluate.mock.calls[1]?.[0]).toContain("window.setTimeout(finish, 100)"); + }); + + it("prefers renderSeek so the runtime synchronizes clip visibility", async () => { + const { page, evaluate } = fakeSeekPage(); + const renderSeek = vi.fn(); + const bridgeSeek = vi.fn(); + const playerSeek = vi.fn(); + const timelineSeek = vi.fn(); + vi.stubGlobal("window", { + __player: { renderSeek, seek: playerSeek }, + __hf: { seek: bridgeSeek }, + __timelines: { main: { seek: timelineSeek } }, + }); + + await seekCompositionTimeline(page, 2.25); + runBrowserSeek(evaluate); + + expect(renderSeek).toHaveBeenCalledWith(2.25); + expect(bridgeSeek).not.toHaveBeenCalled(); + expect(playerSeek).not.toHaveBeenCalled(); + expect(timelineSeek).not.toHaveBeenCalled(); + }); + + function fakeBridgeOnlySeekPage() { + const { page, evaluate } = fakeSeekPage(); + const bridgeSeek = vi.fn(); + const tickerTick = vi.fn(); + vi.stubGlobal("window", { __hf: { seek: bridgeSeek }, gsap: { ticker: { tick: tickerTick } } }); + return { page, evaluate, bridgeSeek, tickerTick }; + } + + it("keeps bridge and raw fallbacks disabled for default capture callers", async () => { + const { page, evaluate, bridgeSeek, tickerTick } = fakeBridgeOnlySeekPage(); + + await seekCompositionTimeline(page, 2.5); + runBrowserSeek(evaluate); + + expect(bridgeSeek).not.toHaveBeenCalled(); + expect(tickerTick).not.toHaveBeenCalled(); + }); + + it("opts into the bridge before player and raw timeline fallbacks", async () => { + const { page, evaluate, bridgeSeek, tickerTick } = fakeBridgeOnlySeekPage(); + + await seekCompositionTimeline(page, 2.5, { fallbackToBridgeAndTimelines: true }); + runBrowserSeek(evaluate); + + expect(bridgeSeek).toHaveBeenCalledWith(2.5); + expect(tickerTick).toHaveBeenCalledOnce(); + }); + + it("opts into pausing and seeking raw timelines when no preferred target exists", async () => { + const { page, evaluate } = fakeSeekPage(); + const pause = vi.fn(); + const seek = vi.fn(); + vi.stubGlobal("window", { __timelines: { main: { pause, seek } } }); + + await seekCompositionTimeline(page, 1.75, { fallbackToBridgeAndTimelines: true }); + runBrowserSeek(evaluate); + + expect(pause).toHaveBeenCalledOnce(); + expect(seek).toHaveBeenCalledWith(1.75); + }); + + it("supports validate settling without adding an animation-frame or font wait", async () => { + vi.useFakeTimers(); + const { page, evaluate, waitForFunction } = fakeSeekPage(); + + const pending = seekCompositionTimeline(page, 3, { + fallbackToBridgeAndTimelines: true, + waitForPreferredSeekTargetMs: 500, + animationFrameSettle: "none", + settleMs: 150, + }); + await vi.advanceTimersByTimeAsync(150); + await pending; + + expect(waitForFunction).toHaveBeenCalledWith(expect.any(Function), { timeout: 500 }); + expect(evaluate).toHaveBeenCalledTimes(1); + expect(evaluate).toHaveBeenCalledWith(expect.any(Function), 3, true); + }); + + it("supports layout's ordered double-frame, bounded font, and sleep settles", async () => { + vi.useFakeTimers(); + const { page, evaluate } = fakeSeekPage(); + + const pending = seekCompositionTimeline(page, 4, { + fallbackToBridgeAndTimelines: true, + animationFrameSettle: "double", + waitForFontsMs: 500, + settleMs: 120, + }); + await vi.advanceTimersByTimeAsync(120); + await pending; + + expect(evaluate).toHaveBeenCalledTimes(3); + expect(evaluate).toHaveBeenNthCalledWith(1, expect.any(Function), 4, true); + expect(evaluate).toHaveBeenNthCalledWith(2, expect.any(Function)); + expect(evaluate).toHaveBeenNthCalledWith(3, expect.any(Function), 500); + }); +}); + +describe("resolveCliChromeGpuMode", () => { + it("preserves validate's software-only opt-in mapping", () => { + expect(resolveCliChromeGpuMode("software")).toBe("software"); + expect(resolveCliChromeGpuMode("hardware")).toBe("hardware"); + expect(resolveCliChromeGpuMode("auto")).toBe("hardware"); + expect(resolveCliChromeGpuMode("")).toBe("hardware"); + }); +}); + +describe("screenshot Chrome arguments", () => { + it("leaves shared capture and layout on the engine's software default", () => { + const defaultScreenshotArgs = + /args:\s*buildChromeArgs\(\s*\{[^}]*captureMode:\s*"screenshot"[^}]*\}\s*\),/; + const captureSource = readFileSync( + new URL("./captureCompositionFrame.ts", import.meta.url), + "utf8", + ); + const layoutSource = readFileSync(new URL("../commands/layout.ts", import.meta.url), "utf8"); + + expect(captureSource).toMatch(defaultScreenshotArgs); + expect(layoutSource).toMatch(defaultScreenshotArgs); + }); +}); + describe("runFfmpegOnce", () => { it("returns the process exit code and collected stderr", async () => { const dir = tempDir(); diff --git a/packages/cli/src/capture/captureCompositionFrame.ts b/packages/cli/src/capture/captureCompositionFrame.ts index fbc15331a5..6fe0d23cc9 100644 --- a/packages/cli/src/capture/captureCompositionFrame.ts +++ b/packages/cli/src/capture/captureCompositionFrame.ts @@ -3,17 +3,35 @@ 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; + +export interface SeekCompositionTimelineOptions { + fallbackToBridgeAndTimelines?: boolean; + waitForPreferredSeekTargetMs?: number; + animationFrameSettle?: "race" | "double" | "none"; + waitForFontsMs?: number; + settleMs?: number; +} + +type CompositionPageFunction = + | string + | (() => unknown) + | ((value: number) => unknown) + | ((value: number, fallbackToBridgeAndTimelines: boolean) => unknown); + +export interface CompositionEvaluationPage { + evaluate( + pageFunction: CompositionPageFunction, + value?: number, + fallbackToBridgeAndTimelines?: boolean, + ): Promise; +} + +export interface CompositionSeekPage extends CompositionEvaluationPage { + waitForFunction?(pageFunction: () => boolean, options: { timeout: number }): Promise; +} export interface SettledCompositionPage { browser: Browser; @@ -34,6 +52,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")); } @@ -97,20 +121,22 @@ export async function openSettledCompositionPage( url: string, options: OpenSettledCompositionPageOptions, ): Promise { + const viewport = resolveCompositionViewportFromHtml(html); const { ensureBrowser } = await import("../browser/manager.js"); const browser = await ensureBrowser(); const puppeteer = await import("puppeteer-core"); + const { buildChromeArgs } = await import("@hyperframes/engine"); let chromeBrowser: Browser | undefined; try { chromeBrowser = await puppeteer.default.launch({ headless: true, executablePath: browser.executablePath, - args: CHROME_LAUNCH_ARGS, + args: buildChromeArgs({ ...viewport, captureMode: "screenshot" }), }); const page = await chromeBrowser.newPage(); - await page.setViewport(resolveCompositionViewportFromHtml(html)); + await page.setViewport(viewport); await page.goto(url, { waitUntil: "domcontentloaded", timeout: 10000 }); const renderReadyTimedOut = !(await waitForCompositionSettle(page, options)); return { browser: chromeBrowser, page, renderReadyTimedOut }; @@ -121,29 +147,133 @@ export async function openSettledCompositionPage( } export async function seekCompositionTimeline( - page: Pick, + page: CompositionSeekPage, timeSeconds: number, + options: SeekCompositionTimelineOptions = {}, ): Promise { - await page.evaluate((t: number) => { - const player = (window as any).__player; - if (!player) return; - const safe = Math.max(0, Number(t) || 0); - if (typeof player.renderSeek === "function") { - player.renderSeek(safe); - } else if (typeof player.seek === "function") { - player.seek(safe); - } - if ((window as any).gsap?.ticker?.tick) { - (window as any).gsap.ticker.tick(); - } - }, timeSeconds); - - await page.evaluate(`new Promise(function(r) { - var settled = false; - function finish() { if (settled) return; settled = true; r(); } - window.setTimeout(finish, 100); - requestAnimationFrame(function() { requestAnimationFrame(finish); }); - })`); + if (options.waitForPreferredSeekTargetMs !== undefined) { + await waitForPreferredSeekTarget(page, options.waitForPreferredSeekTargetMs); + } + + await page.evaluate( + // Serialized into the page; the seek-target cascade must stay one function. + // fallow-ignore-next-line complexity + (t: number, fallbackToBridgeAndTimelines: boolean) => { + const getProperty = (target: unknown, key: string): unknown => { + if ((typeof target !== "object" || target === null) && typeof target !== "function") { + return undefined; + } + return Reflect.get(target, key); + }; + const call = (fn: unknown, receiver: unknown, args: unknown[]): boolean => { + if (typeof fn !== "function") return false; + Reflect.apply(fn, receiver, args); + return true; + }; + + const player = Reflect.get(window, "__player"); + if (!player && !fallbackToBridgeAndTimelines) return; + + const safe = Math.max(0, Number(t) || 0); + const renderSeek = getProperty(player, "renderSeek"); + const playerSeek = getProperty(player, "seek"); + const hf = Reflect.get(window, "__hf"); + const bridgeSeek = getProperty(hf, "seek"); + + // Prefer renderSeek because it also runs the runtime's data-start/data-duration + // visibility sync; raw timeline seeks leave off-window clips visible to audits. + if (call(renderSeek, player, [safe])) { + // Preferred runtime target handled the seek. + } else if (fallbackToBridgeAndTimelines && call(bridgeSeek, hf, [safe])) { + // Producer bridge handled the seek. + } else if (call(playerSeek, player, [safe])) { + // Legacy player target handled the seek. + } else if (fallbackToBridgeAndTimelines) { + const timelines = Reflect.get(window, "__timelines"); + if (typeof timelines === "object" && timelines !== null) { + for (const key of Object.keys(timelines)) { + const timeline = Reflect.get(timelines, key); + call(getProperty(timeline, "pause"), timeline, []); + call(getProperty(timeline, "seek"), timeline, [safe]); + } + } + } + + const gsap = Reflect.get(window, "gsap"); + const ticker = getProperty(gsap, "ticker"); + call(getProperty(ticker, "tick"), ticker, []); + }, + timeSeconds, + options.fallbackToBridgeAndTimelines === true, + ); + + const animationFrameSettle = options.animationFrameSettle ?? "race"; + if (animationFrameSettle === "race") { + await page.evaluate(`new Promise(function(r) { + var settled = false; + function finish() { if (settled) return; settled = true; r(); } + window.setTimeout(finish, 100); + requestAnimationFrame(function() { requestAnimationFrame(finish); }); + })`); + } else if (animationFrameSettle === "double") { + await page.evaluate( + () => + new Promise((resolveFrame) => + requestAnimationFrame(() => requestAnimationFrame(() => resolveFrame())), + ), + ); + } + + if (options.waitForFontsMs !== undefined) { + await waitForCompositionFonts(page, options.waitForFontsMs); + } + if (options.settleMs !== undefined) { + const settleMs = Math.max(0, options.settleMs); + await new Promise((resolveSettle) => setTimeout(resolveSettle, settleMs)); + } +} + +export async function waitForPreferredSeekTarget( + page: Pick, + timeoutMs = PREFERRED_SEEK_TARGET_WAIT_MS, +): Promise { + if (!page.waitForFunction) return; + try { + await page.waitForFunction( + () => { + const player = Reflect.get(window, "__player"); + const hf = Reflect.get(window, "__hf"); + const renderSeek = + typeof player === "object" && player !== null + ? Reflect.get(player, "renderSeek") + : undefined; + const bridgeSeek = + typeof hf === "object" && hf !== null ? Reflect.get(hf, "seek") : undefined; + return typeof renderSeek === "function" || typeof bridgeSeek === "function"; + }, + { timeout: timeoutMs }, + ); + } catch { + // Legacy/static pages may only expose raw timelines; keep that fallback available. + } +} + +export async function waitForCompositionFonts( + page: CompositionEvaluationPage, + timeoutMs: number, +): Promise { + await page + .evaluate((ms: number) => { + const fonts = Reflect.get(document, "fonts"); + if (typeof fonts !== "object" || fonts === null) return Promise.resolve(); + const ready = Reflect.get(fonts, "ready"); + if (!ready) return Promise.resolve(); + return Promise.race([ + Promise.resolve(ready).then(() => undefined), + new Promise((resolve) => setTimeout(resolve, ms)), + ]); + }, timeoutMs) + .catch(() => {}); } export async function runFfmpegOnce( diff --git a/packages/cli/src/commands/layout.ts b/packages/cli/src/commands/layout.ts index 34a70eb5f4..5739ad498b 100644 --- a/packages/cli/src/commands/layout.ts +++ b/packages/cli/src/commands/layout.ts @@ -26,10 +26,21 @@ import { type MotionFrame, } from "../utils/motionAudit.js"; import { findMotionSpec, readMotionSpec, type MotionSpec } from "../utils/motionSpec.js"; +import { + 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 = { + fallbackToBridgeAndTimelines: true, + animationFrameSettle: "double", + waitForFontsMs: 500, + settleMs: SEEK_SETTLE_MS, +}; // 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 +79,8 @@ function buildMotionSampleTimes(duration: number): number[] { } async function getCompositionDuration(page: import("puppeteer-core").Page): Promise { + // Serialized into the page; the duration-source cascade cannot be split. + // fallow-ignore-next-line complexity return page.evaluate(() => { const win = window as unknown as { __hf?: { duration?: number }; @@ -96,52 +109,6 @@ async function getCompositionDuration(page: import("puppeteer-core").Page): Prom }); } -async function waitForFonts(page: import("puppeteer-core").Page, timeoutMs: number): Promise { - await page - .evaluate((ms: number) => { - const fonts = (document as Document & { fonts?: FontFaceSet }).fonts; - if (!fonts?.ready) return Promise.resolve(); - return Promise.race([ - fonts.ready.then(() => undefined), - new Promise((resolve) => setTimeout(resolve, ms)), - ]); - }, timeoutMs) - .catch(() => {}); -} - -async function seekTo(page: import("puppeteer-core").Page, time: number): Promise { - await page.evaluate((t: number) => { - const win = window as unknown as { - __hf?: { seek?: (time: number) => void }; - __player?: { seek?: (time: number) => void }; - __timelines?: Record void; seek?: (time: number) => void }>; - }; - if (typeof win.__hf?.seek === "function") { - win.__hf.seek(t); - return; - } - if (typeof win.__player?.seek === "function") { - win.__player.seek(t); - return; - } - const timelines = win.__timelines; - if (timelines) { - for (const timeline of Object.values(timelines)) { - if (typeof timeline.pause === "function") timeline.pause(); - if (typeof timeline.seek === "function") timeline.seek(t); - } - } - }, time); - await page.evaluate( - () => - new Promise((resolveFrame) => - requestAnimationFrame(() => requestAnimationFrame(() => resolveFrame())), - ), - ); - await waitForFonts(page, 500); - await new Promise((resolveSettle) => setTimeout(resolveSettle, SEEK_SETTLE_MS)); -} - /** * Collect every tween start/end boundary from the registered timelines, * expressed in the registered timeline's own time (what seekTo consumes). @@ -234,6 +201,7 @@ async function runLayoutAudit( ): Promise { const { ensureBrowser } = await import("../browser/manager.js"); const puppeteer = await import("puppeteer-core"); + const { buildChromeArgs } = await import("@hyperframes/engine"); const html = await bundleProjectHtml(projectDir); const server = await serveStaticProjectHtml( projectDir, @@ -247,14 +215,7 @@ 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(); @@ -266,7 +227,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); @@ -330,7 +291,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 +334,7 @@ async function collectMotionFrames( ): Promise { const frames: MotionFrame[] = []; for (const time of times) { - await seekTo(page, time); + await seekCompositionTimeline(page, time, LAYOUT_SEEK_OPTIONS); const sample = await page.evaluate( (options: { selectors: string[]; livenessScopes: string[] }) => { const win = window as unknown as { @@ -512,6 +473,8 @@ 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 }) { const project = resolveProject(args.dir); const samples = Math.max(1, parseInt(args.samples as string, 10) || 9); diff --git a/packages/cli/src/commands/validate.test.ts b/packages/cli/src/commands/validate.test.ts index 7353806c41..bd3941286e 100644 --- a/packages/cli/src/commands/validate.test.ts +++ b/packages/cli/src/commands/validate.test.ts @@ -131,6 +131,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", () => { diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index 5f10ae3a9a..a5b40a6886 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -9,6 +9,12 @@ 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 { + resolveCliChromeGpuMode, + seekCompositionTimeline, +} from "../capture/captureCompositionFrame.js"; + +export { waitForPreferredSeekTarget } from "../capture/captureCompositionFrame.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -85,67 +91,6 @@ async function getCompositionDuration(page: import("puppeteer-core").Page): Prom }); } -async function seekTo(page: import("puppeteer-core").Page, time: number): Promise { - await waitForPreferredSeekTarget(page); - await page.evaluate((t: number) => { - // window.__player.renderSeek is exposed directly by the composition - // runtime (packages/core/src/runtime/init.ts) on every page load, and - // — unlike raw timeline.seek() — it also runs the runtime's own - // [data-start]/[data-duration] visibility sync, hiding clips outside - // their timeline window. window.__hf.seek only exists when the - // producer's render-pipeline bridge script has been injected, which - // validate's static preview server never does, so it was always - // falling through to the raw __timelines seek below and skipping that - // sync — leaving off-window elements looking fully visible to any - // check (e.g. the contrast audit) that reads computed style afterward. - const player = (window as unknown as { __player?: { renderSeek?: (t: number) => void } }) - .__player; - if (player && typeof player.renderSeek === "function") { - player.renderSeek(t); - return; - } - if (window.__hf && typeof window.__hf.seek === "function") { - window.__hf.seek(t); - return; - } - const timelines = (window as unknown as Record).__timelines as - | Record void }> - | undefined; - if (timelines) { - for (const tl of Object.values(timelines)) { - if (typeof tl.seek === "function") tl.seek(t); - } - } - }, time); - await new Promise((r) => setTimeout(r, SEEK_SETTLE_MS)); -} - -interface WaitForFunctionPage { - waitForFunction: (pageFunction: () => boolean, options: { timeout: number }) => Promise; -} - -export async function waitForPreferredSeekTarget( - page: WaitForFunctionPage, - timeoutMs = PREFERRED_SEEK_TARGET_WAIT_MS, -): Promise { - try { - await page.waitForFunction( - () => { - const w = window as unknown as { - __hf?: { seek?: unknown }; - __player?: { renderSeek?: unknown }; - }; - return typeof w.__player?.renderSeek === "function" || typeof w.__hf?.seek === "function"; - }, - { timeout: timeoutMs }, - ); - } catch { - // Older/static pages may only expose raw window.__timelines. Keep the - // legacy fallback path rather than turning a missing player API into a - // validate failure. - } -} - /** * Race a media element's `loadedmetadata`/`error` event against a deadline, * whichever comes first. Already-ready elements resolve immediately. @@ -165,6 +110,8 @@ export function raceMediaReady( ): Promise { if (Number.isFinite(el.duration) && el.duration > 0) return Promise.resolve(); return new Promise((resolve) => { + // Clones its in-page twin below; evaluate() bodies can't import Node helpers. + // fallow-ignore-next-line code-duplication const onReady = () => { el.removeEventListener("loadedmetadata", onReady); el.removeEventListener("error", onReady); @@ -209,6 +156,8 @@ async function auditClipDurations( nodes.map((el) => { if (Number.isFinite(el.duration) && el.duration > 0) return Promise.resolve(); return new Promise((resolve) => { + // fallow-ignore-next-line code-duplication + // Serialized twin of the Node-side metadata wait above. // fallow-ignore-next-line code-duplication const cleanup = () => { el.removeEventListener("loadedmetadata", onReady); @@ -300,7 +249,12 @@ async function runContrastAudit(page: import("puppeteer-core").Page): Promise Date: Thu, 9 Jul 2026 22:55:44 -0400 Subject: [PATCH 02/16] =?UTF-8?q?feat(cli):=20add=20check=20=E2=80=94=20si?= =?UTF-8?q?ngle-session=20verification=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One command, one Chrome boot: in-process lint gate (browser skipped on lint errors), passive runtime capture wired before navigation, layout + motion + contrast audits over one seek grid, optional --snapshots persisting the contrast-pass screenshots. Aggregated --json envelope {ok, lint, runtime, layout, motion, contrast, snapshots}; findings carry selector/data-*/source-file/bbox/time anchors, contrast findings include fg/bg, measured vs required ratio, and a compliant color suggestion. Contrast AA failures gate the exit code (they were warning-only in validate); --strict gates warnings. Contrast candidates round-trip verbatim between __contrastAuditPrepare and __contrastAuditFinish: the page script owns their shape (bbox w/h), and normalizing them Node-side made every sample rect NaN — the audit reported zero checked elements as green. Regression-pinned in check.test.ts; E2E on a low-contrast fixture now exits 1 with 8 findings. Measured on kinetic-type: check 5.6s vs 23.0s for sequential validate + inspect + snapshot. --- .../capture/captureCompositionFrame.test.ts | 7 +- .../src/capture/captureCompositionFrame.ts | 12 +- packages/cli/src/cli.ts | 1 + packages/cli/src/commands/check.test.ts | 428 ++++++++++ packages/cli/src/commands/check.ts | 262 ++++++ packages/cli/src/commands/contrast-bg.test.ts | 57 +- packages/cli/src/commands/contrast-bg.ts | 53 ++ packages/cli/src/commands/layout.ts | 4 +- packages/cli/src/utils/checkBrowser.ts | 793 ++++++++++++++++++ packages/cli/src/utils/checkPipeline.ts | 554 ++++++++++++ packages/cli/src/utils/checkTypes.ts | 169 ++++ 11 files changed, 2335 insertions(+), 5 deletions(-) create mode 100644 packages/cli/src/commands/check.test.ts create mode 100644 packages/cli/src/commands/check.ts create mode 100644 packages/cli/src/utils/checkBrowser.ts create mode 100644 packages/cli/src/utils/checkPipeline.ts create mode 100644 packages/cli/src/utils/checkTypes.ts diff --git a/packages/cli/src/capture/captureCompositionFrame.test.ts b/packages/cli/src/capture/captureCompositionFrame.test.ts index 559a54f91a..53e1ef4c23 100644 --- a/packages/cli/src/capture/captureCompositionFrame.test.ts +++ b/packages/cli/src/capture/captureCompositionFrame.test.ts @@ -171,7 +171,12 @@ describe("screenshot Chrome arguments", () => { ); const layoutSource = readFileSync(new URL("../commands/layout.ts", import.meta.url), "utf8"); - expect(captureSource).toMatch(defaultScreenshotArgs); + // 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); }); }); diff --git a/packages/cli/src/capture/captureCompositionFrame.ts b/packages/cli/src/capture/captureCompositionFrame.ts index 6fe0d23cc9..92ac4b93bf 100644 --- a/packages/cli/src/capture/captureCompositionFrame.ts +++ b/packages/cli/src/capture/captureCompositionFrame.ts @@ -44,6 +44,12 @@ export interface SettledCompositionPage { export interface OpenSettledCompositionPageOptions { renderReadyTimeoutMs: number; renderReadyWarningSuffix: string; + // Screenshot paths take the engine's software-GPU default; validate/check + // thread the PRODUCER_BROWSER_GPU_MODE opt-in through here. + browserGpuMode?: "software" | "hardware"; + // Runs after the page exists but before page.goto, so console/pageerror/ + // request listeners can attach without missing load-time events. + beforeNavigate?: (page: Page) => void | Promise; } export interface FfmpegRunResult { @@ -132,11 +138,15 @@ export async function openSettledCompositionPage( chromeBrowser = await puppeteer.default.launch({ headless: true, executablePath: browser.executablePath, - args: buildChromeArgs({ ...viewport, captureMode: "screenshot" }), + args: buildChromeArgs( + { ...viewport, captureMode: "screenshot" }, + { browserGpuMode: options.browserGpuMode }, + ), }); const page = await chromeBrowser.newPage(); 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 }; diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index b98ad6d445..2f3cc5d8ff 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -119,6 +119,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), diff --git a/packages/cli/src/commands/check.test.ts b/packages/cli/src/commands/check.test.ts new file mode 100644 index 0000000000..18dc433a14 --- /dev/null +++ b/packages/cli/src/commands/check.test.ts @@ -0,0 +1,428 @@ +import { runCommand } from "citty"; +import { readFileSync } from "node:fs"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { contrastRatio, parseColorRGBA } from "./contrast-bg.js"; +import { createCheckCommand } from "./check.js"; +import { + DEFAULT_CHECK_OPTIONS, + checkExitCode, + runAuditGrid, + runCheckPipeline, + selectContrastTimes, + type AnchoredLayoutIssue, + type CheckAnchor, + type CheckAuditDriver, + type CheckBrowserResult, + type CheckDependencies, + type CheckFinding, + type CheckOptions, + type CheckReport, + type ContrastAuditEntry, + type MotionSpecResolution, +} from "../utils/checkPipeline.js"; +import type { ProjectLintResult } from "../utils/lintProject.js"; +import type { LayoutIssue } from "../utils/layoutAudit.js"; +import type { ProjectDir } from "../utils/project.js"; + +const PROJECT: ProjectDir = { + dir: "/project", + name: "project", + indexPath: "/project/index.html", +}; +const PNG_BASE64 = Buffer.from("png-bytes").toString("base64"); + +function cleanLint(): ProjectLintResult { + return { + results: [ + { + file: "index.html", + result: { + ok: true, + errorCount: 0, + warningCount: 0, + infoCount: 0, + findings: [], + }, + }, + ], + totalErrors: 0, + totalWarnings: 0, + totalInfos: 0, + }; +} + +function lintWith( + severity: "error" | "warning" | "info", + code: string, + message: string, +): ProjectLintResult { + return { + results: [ + { + file: "index.html", + result: { + ok: severity !== "error", + errorCount: severity === "error" ? 1 : 0, + warningCount: severity === "warning" ? 1 : 0, + infoCount: severity === "info" ? 1 : 0, + findings: [{ severity, code, message }], + }, + }, + ], + totalErrors: severity === "error" ? 1 : 0, + totalWarnings: severity === "warning" ? 1 : 0, + totalInfos: severity === "info" ? 1 : 0, + }; +} + +function anchor(selector: string, time: number): CheckAnchor { + return { + selector, + dataAttributes: { "data-layout-name": "hero" }, + sourceFile: "compositions/scene.html", + bbox: { x: 10, y: 20, width: 300, height: 80 }, + time, + }; +} + +function layoutIssue(severity: "error" | "warning" | "info" = "error"): AnchoredLayoutIssue { + return { + ...anchor("#hero", 0.5), + code: severity === "warning" ? "content_overlap" : "clipped_text", + severity, + text: "Hero", + message: severity === "warning" ? "Text may overlap." : "Text is clipped.", + rect: { left: 10, top: 20, right: 310, bottom: 100, width: 300, height: 80 }, + }; +} + +function contrastEntry(overrides: Partial = {}): ContrastAuditEntry { + return { + ...anchor("#hero", 0.5), + text: "Body text", + ratio: 2.5, + wcagAA: false, + large: false, + fg: "rgb(110,110,110)", + bg: "rgb(30,30,30)", + ...overrides, + }; +} + +function fakeDriver(overrides: Partial = {}): CheckAuditDriver { + return { + initialize: vi.fn(async (_contrast: boolean) => undefined), + getDuration: vi.fn(async () => 9), + getTransitionBoundaries: vi.fn(async () => []), + getCanvas: vi.fn(async () => ({ width: 1920, height: 1080 })), + findAmbiguousSelectors: vi.fn(async (_selectors: string[]) => []), + seek: vi.fn(async (_time: number) => undefined), + collectLayout: vi.fn(async (_time: number, _tolerance: number) => []), + collectMotionFrame: vi.fn(async (time: number) => ({ time, data: {}, liveness: {} })), + anchorMotionIssues: vi.fn(async (issues: LayoutIssue[]) => + issues.map((issue) => ({ + ...issue, + ...anchor(issue.selector, issue.time), + })), + ), + collectContrast: vi.fn(async (_time: number) => ({ entries: [], pngBase64: PNG_BASE64 })), + ...overrides, + }; +} + +function noMotion(): MotionSpecResolution { + return { kind: "none" }; +} + +function dependencies( + driver: CheckAuditDriver, + options: { + lint?: ProjectLintResult; + motion?: MotionSpecResolution; + runtime?: CheckFinding[]; + writeSnapshot?: CheckDependencies["writeSnapshot"]; + } = {}, +): { deps: CheckDependencies; runBrowserCheck: ReturnType } { + const runBrowserCheck = vi.fn( + async ( + _project: ProjectDir, + checkOptions: CheckOptions, + motion: MotionSpecResolution, + ): Promise => { + const result = await runAuditGrid(driver, checkOptions, motion); + return { ...result, runtimeFindings: options.runtime ?? [] }; + }, + ); + const deps: CheckDependencies = { + lintProject: vi.fn(async () => options.lint ?? cleanLint()), + resolveMotionSpec: vi.fn(() => options.motion ?? noMotion()), + runBrowserCheck, + writeSnapshot: + options.writeSnapshot ?? + vi.fn((_projectDir: string, index: number, time: number, _pngBase64: string) => + Promise.resolve( + `snapshots/frame-${String(index).padStart(2, "0")}-at-${time.toFixed(1)}s.png`, + ), + ), + }; + return { deps, runBrowserCheck }; +} + +async function runScenario( + driver: CheckAuditDriver, + optionOverrides: Partial = {}, + dependencyOverrides: Parameters[1] = {}, +): Promise<{ report: CheckReport; deps: CheckDependencies; browser: ReturnType }> { + const { deps, runBrowserCheck } = dependencies(driver, dependencyOverrides); + const report = await runCheckPipeline( + PROJECT, + { ...DEFAULT_CHECK_OPTIONS, ...optionOverrides }, + deps, + ); + return { report, deps, browser: runBrowserCheck }; +} + +function runtimeError(): CheckFinding { + return { + code: "console_error", + severity: "error", + message: "boom", + ...anchor("[data-composition-id]", 0), + }; +} + +describe("contrast sample selection", () => { + it("chooses five evenly distributed grid points including both ends", () => { + expect(selectContrastTimes([0.5, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5, 8.5])).toEqual([ + 0.5, 2.5, 4.5, 6.5, 8.5, + ]); + expect(selectContrastTimes([1, 2, 3])).toEqual([1, 2, 3]); + }); +}); + +describe("check pipeline", () => { + const originalExitCode = process.exitCode; + + afterEach(() => { + process.exitCode = originalExitCode; + vi.restoreAllMocks(); + }); + + it("emits one clean JSON envelope with every section and exit 0", async () => { + const { report } = await runScenario(fakeDriver()); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + const command = createCheckCommand({ + resolveProject: () => PROJECT, + runPipeline: vi.fn(async () => report), + withMeta: (value) => ({ ...value, _meta: { version: "test" } }), + }); + + await runCommand(command, { rawArgs: ["--json"] }); + + expect(report.ok).toBe(true); + expect(checkExitCode(report)).toBe(0); + expect(process.exitCode).toBe(0); + expect(log).toHaveBeenCalledTimes(1); + const output = log.mock.calls[0]?.[0]; + expect(typeof output).toBe("string"); + if (typeof output !== "string") throw new Error("expected JSON output"); + const envelope = JSON.parse(output); + expect(envelope).toMatchObject({ + ok: true, + lint: { ok: true }, + runtime: { ok: true }, + layout: { ok: true }, + motion: { ok: true }, + contrast: { ok: true }, + snapshots: { enabled: false }, + _meta: { version: "test" }, + }); + }); + + it("short-circuits on lint errors without launching a browser", async () => { + const lint = lintWith( + "error", + "root_missing_composition_id", + "Root element needs data-composition-id.", + ); + const { report, browser } = await runScenario(fakeDriver(), {}, { lint }); + + expect(report.ok).toBe(false); + expect(checkExitCode(report)).toBe(1); + expect(report.lint.findings).toHaveLength(1); + expect(browser).not.toHaveBeenCalled(); + }); + + it("gates AA contrast failures and --no-contrast skips the pass", async () => { + const failingContrast = vi.fn(async (time: number) => ({ + entries: time === 0.5 ? [contrastEntry()] : [], + pngBase64: PNG_BASE64, + })); + const { report } = await runScenario(fakeDriver({ collectContrast: failingContrast })); + expect(report.ok).toBe(false); + expect(checkExitCode(report)).toBe(1); + expect(report.contrast.errorCount).toBe(1); + + const skippedContrast = vi.fn(async () => ({ + entries: [contrastEntry()], + pngBase64: PNG_BASE64, + })); + const skipped = await runScenario(fakeDriver({ collectContrast: skippedContrast }), { + contrast: false, + }); + expect(skipped.report.ok).toBe(true); + expect(checkExitCode(skipped.report)).toBe(0); + expect(skipped.report.contrast.enabled).toBe(false); + expect(skippedContrast).not.toHaveBeenCalled(); + }); + + it("includes measured colors, thresholds, and a passing palette-direction suggestion", async () => { + const { report } = await runScenario( + fakeDriver({ + collectContrast: vi.fn(async () => ({ + entries: [contrastEntry()], + pngBase64: PNG_BASE64, + })), + }), + ); + const finding = report.contrast.findings[0]; + expect(finding).toMatchObject({ + fg: "rgb(110,110,110)", + bg: "rgb(30,30,30)", + ratio: 2.5, + requiredRatio: 4.5, + }); + if (!finding) throw new Error("expected contrast finding"); + const suggested = parseColorRGBA(finding.suggestedColor); + const background = parseColorRGBA(finding.bg); + expect(suggested).not.toBeNull(); + expect(background).not.toBeNull(); + if (!suggested || !background) throw new Error("expected parseable colors"); + expect( + contrastRatio( + [suggested[0], suggested[1], suggested[2]], + [background[0], background[1], background[2]], + ), + ).toBeGreaterThanOrEqual(finding.requiredRatio); + expect(suggested[0]).toBeGreaterThan(110); + }); + + it("preserves a resolving selector, source file, identity, bbox, and sample time", async () => { + const { report } = await runScenario( + fakeDriver({ collectLayout: vi.fn(async () => [layoutIssue()]) }), + ); + expect(report.layout.findings[0]).toMatchObject({ + selector: "#hero", + dataAttributes: { "data-layout-name": "hero" }, + sourceFile: "compositions/scene.html", + bbox: { x: 10, y: 20, width: 300, height: 80 }, + time: 0.5, + }); + }); + + it("reports layout and runtime errors from one browser session", async () => { + const { report, browser } = await runScenario( + fakeDriver({ collectLayout: vi.fn(async () => [layoutIssue()]) }), + {}, + { runtime: [runtimeError()] }, + ); + expect(report.runtime.errorCount).toBe(1); + expect(report.layout.errorCount).toBe(1); + expect(browser).toHaveBeenCalledTimes(1); + }); + + it("reports a failing appearsBy sidecar as motion_appears_late", async () => { + const motion: MotionSpecResolution = { + kind: "valid", + path: "/project/index.motion.json", + spec: { assertions: [{ kind: "appearsBy", selector: "#hero", bySec: 0.2 }] }, + }; + const driver = fakeDriver({ + getDuration: vi.fn(async () => 1), + collectMotionFrame: vi.fn(async (time: number) => ({ + time, + data: { + "#hero": { + rect: { left: 10, top: 20, right: 310, bottom: 100, width: 300, height: 80 }, + opacity: time >= 0.5 ? 1 : 0, + visible: time >= 0.5, + }, + }, + liveness: {}, + })), + }); + const { report } = await runScenario(driver, {}, { motion }); + + expect(report.motion.findings).toEqual([ + expect.objectContaining({ + code: "motion_appears_late", + severity: "error", + selector: "#hero", + }), + ]); + expect(report.ok).toBe(false); + }); + + it("writes cached contrast PNGs only with --snapshots at the contrast timestamps", async () => { + const writer = vi.fn( + async (_projectDir: string, index: number, time: number, _pngBase64: string) => + `snapshots/frame-${String(index).padStart(2, "0")}-at-${time.toFixed(1)}s.png`, + ); + const captured = fakeDriver({ + collectContrast: vi.fn(async () => ({ entries: [], pngBase64: PNG_BASE64 })), + }); + const { report } = await runScenario(captured, { snapshots: true }, { writeSnapshot: writer }); + + expect(report.snapshots.times).toEqual([0.5, 2.5, 4.5, 6.5, 8.5]); + expect(report.snapshots.files).toEqual([ + "snapshots/frame-00-at-0.5s.png", + "snapshots/frame-01-at-2.5s.png", + "snapshots/frame-02-at-4.5s.png", + "snapshots/frame-03-at-6.5s.png", + "snapshots/frame-04-at-8.5s.png", + ]); + expect(writer).toHaveBeenCalledTimes(5); + + const absentWriter = vi.fn(async () => "unused.png"); + await runScenario(fakeDriver(), { snapshots: false }, { writeSnapshot: absentWriter }); + expect(absentWriter).not.toHaveBeenCalled(); + }); + + it("--strict flips a warnings-only result from exit 0 to exit 1", async () => { + const warningDriver = () => + fakeDriver({ collectLayout: vi.fn(async () => [layoutIssue("warning")]) }); + const normal = await runScenario(warningDriver(), { strict: false }); + const strict = await runScenario(warningDriver(), { strict: true }); + + expect(checkExitCode(normal.report)).toBe(0); + expect(checkExitCode(strict.report)).toBe(1); + }); + + it("fails clearly without samples when no timeline duration is available, without hanging", async () => { + const driver = fakeDriver({ getDuration: vi.fn(async () => 0) }); + await expect(runAuditGrid(driver, DEFAULT_CHECK_OPTIONS, noMotion())).rejects.toThrow( + "Could not determine composition duration — no layout samples run", + ); + + const { report, browser } = await runScenario(driver); + expect(browser).toHaveBeenCalledTimes(1); + expect(report.runtime.findings[0]?.message).toContain( + "Could not determine composition duration — no layout samples run", + ); + expect(checkExitCode(report)).toBe(1); + }); +}); + +describe("contrast candidate round-trip", () => { + it("passes the browser script's raw candidates back to finish, never the normalized copies", () => { + const source = readFileSync(new URL("../utils/checkBrowser.ts", import.meta.url), "utf8"); + + // __contrastAuditFinish samples pixels via the page script's own bbox + // shape ({x, y, w, h}); sending the Node-normalized candidate + // ({width, height}) makes every sample rect NaN and the audit silently + // reports zero checked elements. The raw object must round-trip verbatim. + expect(source).toMatch(/prepared\.map\(\(entry\) => entry\.raw\)/); + expect(source).toMatch(/raw: unknown;/); + expect(source).not.toMatch(/prepared\.map\(\(entry\) => entry\.candidate\)/); + }); +}); diff --git a/packages/cli/src/commands/check.ts b/packages/cli/src/commands/check.ts new file mode 100644 index 0000000000..78e183377d --- /dev/null +++ b/packages/cli/src/commands/check.ts @@ -0,0 +1,262 @@ +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"; + +export const examples: Example[] = [ + ["Run the full verification gate", "hyperframes check"], + ["Output one agent-readable envelope", "hyperframes check --json"], + ["Persist the five audited contrast frames", "hyperframes check --snapshots"], + ["Also fail on warnings", "hyperframes check --strict"], +]; + +export interface CheckCommandDependencies { + resolveProject(dir: string | undefined): ProjectDir; + runPipeline(project: ProjectDir, options: CheckOptions): Promise; + withMeta(value: object): object; +} + +const DEFAULT_COMMAND_DEPENDENCIES: CheckCommandDependencies = { + resolveProject, + runPipeline: runCheckPipeline, + withMeta, +}; + +export function createCheckCommand( + dependencies: CheckCommandDependencies = DEFAULT_COMMAND_DEPENDENCIES, +) { + return defineCommand({ + meta: { + name: "check", + description: + "Run lint, runtime, layout, motion, and WCAG contrast verification in one browser session", + }, + args: { + dir: { type: "positional", description: "Project directory", required: false }, + json: { type: "boolean", description: "Output agent-readable JSON", default: false }, + samples: { + type: "string", + description: "Number of midpoint samples across the duration (default: 9)", + default: "9", + }, + at: { + type: "string", + description: "Comma-separated timestamps in seconds (e.g., --at 1.5,4,7.25)", + }, + "at-transitions": { + type: "boolean", + description: + "Also sample at every tween start/end boundary (plus segment midpoints) to catch transient overlaps at transition seams", + default: false, + }, + "max-transition-samples": { + type: "string", + description: + "Optional cap on transition-derived samples; when it truncates, the omitted count is reported (default: unlimited)", + }, + "max-issues": { + type: "string", + description: "Maximum issues to print or return after static collapse (default: 80)", + default: "80", + }, + "collapse-static": { + type: "boolean", + description: "Collapse repeated static issues across samples (default: true)", + default: true, + }, + tolerance: { + type: "string", + description: "Allowed pixel overflow before reporting an issue (default: 2)", + default: "2", + }, + timeout: { + type: "string", + description: "Ms to wait for scripts and media to settle initially (default: 3000)", + default: "3000", + }, + contrast: { + type: "boolean", + description: "Run the WCAG AA contrast pass (enabled by default)", + default: true, + }, + strict: { + type: "boolean", + description: "Exit non-zero on warnings too", + default: false, + }, + snapshots: { + type: "boolean", + description: "Save the five contrast-pass PNGs under snapshots/", + default: false, + }, + }, + async run({ args }) { + const project = dependencies.resolveProject(args.dir); + const options = parseCheckOptions(args); + const asJson = args.json === true; + if (!asJson) { + console.log(`${c.accent("◆")} Checking ${c.accent(project.name)}`); + } + + try { + const report = await dependencies.runPipeline(project, options); + if (asJson) { + console.log(JSON.stringify(dependencies.withMeta(report), null, 2)); + } else { + printHumanReport(report); + } + process.exitCode = checkExitCode(report); + } catch (error) { + const message = normalizeErrorMessage(error); + if (asJson) { + console.log( + JSON.stringify(dependencies.withMeta({ ok: false, error: message }), null, 2), + ); + } else { + console.error(`${c.error("✗")} Check failed: ${message}`); + } + process.exitCode = 1; + } + }, + }); +} + +function parseCheckOptions(args: Record): CheckOptions { + const maxTransitionSamplesRaw = parseInt(String(args["max-transition-samples"] ?? ""), 10); + return { + samples: positiveInteger(args.samples, DEFAULT_CHECK_OPTIONS.samples), + at: parseAt(args.at), + atTransitions: args["at-transitions"] === true, + maxTransitionSamples: + Number.isFinite(maxTransitionSamplesRaw) && maxTransitionSamplesRaw > 0 + ? maxTransitionSamplesRaw + : 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, + }; +} + +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)}`); + } +} + +function printFinding(finding: CheckFinding): void { + const where = `${finding.sourceFile} ${finding.selector} t=${finding.time}s`; + console.log(` ${findingIcon(finding)} ${finding.code}: ${finding.message}`); + console.log(` ${c.dim(where)}`); + if (finding.fixHint) console.log(` ${c.dim(`Fix: ${finding.fixHint}`)}`); +} + +function findingIcon(finding: CheckFinding): string { + if (finding.severity === "error") return c.error("✗"); + if (finding.severity === "warning") return c.warn("⚠"); + return c.dim("ℹ"); +} + +function printCounts(section: CheckSection): void { + console.log( + ` ${c.dim(`${section.errorCount} error(s), ${section.warningCount} warning(s), ${section.infoCount} info(s)`)}`, + ); +} + +export default createCheckCommand(); diff --git a/packages/cli/src/commands/contrast-bg.test.ts b/packages/cli/src/commands/contrast-bg.test.ts index 8a58b30793..fd1dddb85f 100644 --- a/packages/cli/src/commands/contrast-bg.test.ts +++ b/packages/cli/src/commands/contrast-bg.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it } from "vitest"; -import { parseColorRGBA, pickOpaqueBackground } from "./contrast-bg.js"; +import { + contrastRatio, + parseColorRGBA, + pickOpaqueBackground, + relativeLuminance, + requiredContrastRatio, + suggestCompliantForegroundColor, +} from "./contrast-bg.js"; const opaque = (bg: string) => ({ backgroundColor: bg, backgroundImage: "none" }); @@ -53,3 +60,51 @@ describe("pickOpaqueBackground", () => { expect(pickOpaqueBackground([opaque("rgba(0, 0, 0, 0)")])).toBeNull(); }); }); + +describe("relativeLuminance", () => { + it("uses the WCAG sRGB transfer function", () => { + expect(relativeLuminance([0, 0, 0])).toBe(0); + expect(relativeLuminance([255, 255, 255])).toBe(1); + expect(relativeLuminance([255, 0, 0])).toBeCloseTo(0.2126, 4); + }); +}); + +describe("contrastRatio", () => { + it("is symmetric and reaches 21:1 for black and white", () => { + expect(contrastRatio([0, 0, 0], [255, 255, 255])).toBe(21); + expect(contrastRatio([255, 255, 255], [0, 0, 0])).toBe(21); + }); +}); + +describe("requiredContrastRatio", () => { + it("requires 3:1 for large text and 4.5:1 otherwise", () => { + expect(requiredContrastRatio(true)).toBe(3); + expect(requiredContrastRatio(false)).toBe(4.5); + }); +}); + +describe("suggestCompliantForegroundColor", () => { + it("brightens a failing foreground on a dark background until it passes", () => { + const background: [number, number, number] = [20, 20, 20]; + const foreground: [number, number, number] = [80, 80, 80]; + const suggested = suggestCompliantForegroundColor(foreground, background, 4.5); + + expect(suggested[0]).toBeGreaterThan(foreground[0]); + expect(contrastRatio(suggested, background)).toBeGreaterThanOrEqual(4.5); + }); + + it("darkens a failing foreground on a light background until it passes", () => { + const background: [number, number, number] = [245, 245, 245]; + const foreground: [number, number, number] = [180, 180, 180]; + const suggested = suggestCompliantForegroundColor(foreground, background, 4.5); + + expect(suggested[0]).toBeLessThan(foreground[0]); + expect(contrastRatio(suggested, background)).toBeGreaterThanOrEqual(4.5); + }); + + it("preserves a foreground that already passes", () => { + expect(suggestCompliantForegroundColor([255, 255, 255], [0, 0, 0], 4.5)).toEqual([ + 255, 255, 255, + ]); + }); +}); diff --git a/packages/cli/src/commands/contrast-bg.ts b/packages/cli/src/commands/contrast-bg.ts index fa221a2d9a..778a9e64ae 100644 --- a/packages/cli/src/commands/contrast-bg.ts +++ b/packages/cli/src/commands/contrast-bg.ts @@ -20,6 +20,59 @@ export type Rgb = [number, number, number]; export type Rgba = [number, number, number, number]; +/** WCAG relative luminance for an sRGB color. Mirrors contrast-audit.browser.js. */ +export function relativeLuminance(color: Rgb): number { + const channel = (value: number) => { + const srgb = value / 255; + return srgb <= 0.03928 ? srgb / 12.92 : ((srgb + 0.055) / 1.055) ** 2.4; + }; + + return 0.2126 * channel(color[0]) + 0.7152 * channel(color[1]) + 0.0722 * channel(color[2]); +} + +/** WCAG contrast ratio between two opaque sRGB colors. */ +export function contrastRatio(first: Rgb, second: Rgb): number { + const firstLuminance = relativeLuminance(first); + const secondLuminance = relativeLuminance(second); + const lighter = Math.max(firstLuminance, secondLuminance); + const darker = Math.min(firstLuminance, secondLuminance); + return (lighter + 0.05) / (darker + 0.05); +} + +/** WCAG AA minimum contrast for body or large text. */ +export function requiredContrastRatio(large: boolean): number { + return large ? 3 : 4.5; +} + +/** + * Find the nearest passing foreground on the line toward the higher-contrast + * pole: white for a dark background, black for a light background. + */ +export function suggestCompliantForegroundColor( + foreground: Rgb, + background: Rgb, + requiredRatio: number, +): Rgb { + if (contrastRatio(foreground, background) >= requiredRatio) return [...foreground]; + + const black: Rgb = [0, 0, 0]; + const white: Rgb = [255, 255, 255]; + const target = + contrastRatio(white, background) >= contrastRatio(black, background) ? white : black; + + for (let step = 1; step <= 255; step += 1) { + const amount = step / 255; + const candidate: Rgb = [ + Math.round(foreground[0] + (target[0] - foreground[0]) * amount), + Math.round(foreground[1] + (target[1] - foreground[1]) * amount), + Math.round(foreground[2] + (target[2] - foreground[2]) * amount), + ]; + if (contrastRatio(candidate, background) >= requiredRatio) return candidate; + } + + return [...target]; +} + /** Parse a CSS `rgb()`/`rgba()` string. Returns null if it is not rgb(a). */ export function parseColorRGBA(color: string | null | undefined): Rgba | null { const body = /rgba?\(([^)]+)\)/.exec(color ?? "")?.[1]; diff --git a/packages/cli/src/commands/layout.ts b/packages/cli/src/commands/layout.ts index 5739ad498b..55d2c49d24 100644 --- a/packages/cli/src/commands/layout.ts +++ b/packages/cli/src/commands/layout.ts @@ -269,7 +269,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"); @@ -408,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(",") diff --git a/packages/cli/src/utils/checkBrowser.ts b/packages/cli/src/utils/checkBrowser.ts new file mode 100644 index 0000000000..098d97a225 --- /dev/null +++ b/packages/cli/src/utils/checkBrowser.ts @@ -0,0 +1,793 @@ +import type { Page } from "puppeteer-core"; +import { + openSettledCompositionPage, + resolveCliChromeGpuMode, + seekCompositionTimeline, + waitForPreferredSeekTarget, +} from "../capture/captureCompositionFrame.js"; +import { shouldIgnoreRequestFailure } from "../commands/validate.js"; +import { loadBrowserScript } from "../commands/layout.js"; +import { normalizeErrorMessage } from "./errorMessage.js"; +import { ambiguousIssue, type MotionFrame } from "./motionAudit.js"; +import type { LayoutIssue, LayoutIssueCode, LayoutRect } from "./layoutAudit.js"; +import { serveStaticProjectHtml } from "./staticProjectServer.js"; +import type { + AnchoredLayoutIssue, + CheckAnchor, + CheckAuditDriver, + CheckBbox, + CheckBrowserResult, + CheckFinding, + CheckOptions, + CheckSeverity, + ContrastAuditEntry, + ContrastCapture, + MotionSpecResolution, + RunAuditGrid, +} from "./checkTypes.js"; +import type { ProjectDir } from "./project.js"; + +const SEEK_OPTIONS = { + fallbackToBridgeAndTimelines: true, + animationFrameSettle: "double" as const, + waitForFontsMs: 500, + settleMs: 120, +}; + +interface RuntimeDraft { + code: string; + severity: CheckSeverity; + message: string; + time: number; + url?: string; + line?: number; +} + +interface AnchorRequest { + selector: string; + time: number; + bbox: CheckBbox; +} + +interface ContrastCandidate { + selector: string; + text: string; + fg: [number, number, number, number]; + large: boolean; + bbox: CheckBbox; +} + +interface PreparedContrast { + // The untouched candidate object from __contrastAuditPrepare. It round-trips + // back into __contrastAuditFinish verbatim — the browser script owns its + // shape (e.g. bbox uses w/h, not width/height), so Node must not normalize + // what it sends back. `candidate` is the parsed copy for Node-side reporting. + raw: unknown; + candidate: ContrastCandidate; + anchor: CheckAnchor; +} + +interface FinishedContrast { + selector: string; + text: string; + ratio: number; + wcagAA: boolean; + large: boolean; + fg: string; + bg: string; +} + +export async function runBrowserCheck( + project: ProjectDir, + options: CheckOptions, + motion: MotionSpecResolution, + runGrid: RunAuditGrid, +): Promise { + const { bundleToSingleHtml } = await import("@hyperframes/core/compiler"); + const html = await bundleToSingleHtml(project.dir); + const server = await serveStaticProjectHtml(project.dir, html, "Failed to bind check server"); + const drafts: RuntimeDraft[] = []; + let currentTime = 0; + let chromeBrowser: import("puppeteer-core").Browser | undefined; + + try { + const session = await openSettledCompositionPage(html, server.url, { + renderReadyTimeoutMs: options.timeout, + renderReadyWarningSuffix: "checking the current page state", + browserGpuMode: resolveCliChromeGpuMode(), + beforeNavigate: (page) => wireRuntimeListeners(page, drafts, () => currentTime), + }); + chromeBrowser = session.browser; + const page = session.page; + await waitForPreferredSeekTarget(page, 500); + + const rootAnchor = await resolveRootAnchor(page); + const driver = createPageDriver(page, (time) => { + currentTime = time; + }); + const result = await runGrid(driver, options, motion); + return { + ...result, + runtimeFindings: drafts.map((draft) => runtimeFinding(draft, rootAnchor)), + }; + } finally { + await chromeBrowser?.close().catch(() => undefined); + await server.close(); + } +} + +function wireRuntimeListeners(page: Page, drafts: RuntimeDraft[], currentTime: () => number): void { + page.on("console", (message) => { + const type = message.type(); + const text = message.text(); + if (type === "error" && !text.startsWith("Failed to load resource")) { + const location = message.location(); + drafts.push({ + code: "console_error", + severity: "error", + message: text, + time: currentTime(), + url: location.url, + line: location.lineNumber, + }); + } else if (type === "warn") { + const location = message.location(); + drafts.push({ + code: "console_warning", + severity: "warning", + message: text, + time: currentTime(), + url: location.url, + line: location.lineNumber, + }); + } + }); + page.on("pageerror", (error) => { + const message = normalizeErrorMessage(error); + if (message.includes("Unexpected token '<'") || message.includes("Unexpected token '<'")) { + return; + } + drafts.push({ code: "page_error", severity: "error", message, time: currentTime() }); + }); + wireNetworkListeners(page, drafts, currentTime); +} + +function wireNetworkListeners(page: Page, drafts: RuntimeDraft[], currentTime: () => number): void { + page.on("requestfailed", (request) => { + const url = request.url(); + if (url.includes("favicon") || url.startsWith("data:")) return; + const failure = request.failure()?.errorText; + if (shouldIgnoreRequestFailure(url, failure, request.resourceType())) return; + drafts.push({ + code: "request_failed", + severity: "error", + message: `Failed to load ${urlPath(url)}: ${failure ?? "net::ERR_FAILED"}`, + time: currentTime(), + url, + }); + }); + page.on("response", (response) => { + if (response.status() < 400) return; + const url = response.url(); + if (url.includes("favicon")) return; + drafts.push({ + code: "http_error", + severity: "error", + message: `${response.status()} loading ${urlPath(url)}`, + time: currentTime(), + url, + }); + }); +} + +function createPageDriver(page: Page, setTime: (time: number) => void): CheckAuditDriver { + return { + initialize: (contrast) => injectAuditScripts(page, contrast), + getDuration: () => getCompositionDuration(page), + getTransitionBoundaries: () => collectTweenBoundaries(page), + getCanvas: () => + page.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight })), + findAmbiguousSelectors: (selectors) => findAmbiguousSelectors(page, selectors), + seek: async (time) => { + setTime(time); + await seekCompositionTimeline(page, time, SEEK_OPTIONS); + }, + collectLayout: (time, tolerance) => collectLayout(page, time, tolerance), + collectMotionFrame: (time, selectors, scopes) => + collectMotionFrame(page, time, selectors, scopes), + anchorMotionIssues: (issues) => anchorLayoutIssues(page, issues), + collectContrast: (time) => collectContrast(page, time), + }; +} + +async function injectAuditScripts(page: Page, contrast: boolean): Promise { + await page.addScriptTag({ content: loadBrowserScript("layout-audit.browser.js") }); + await page.addScriptTag({ content: loadBrowserScript("motion-sample.browser.js") }); + if (contrast) { + await page.addScriptTag({ content: loadBrowserScript("contrast-audit.browser.js") }); + } +} + +async function getCompositionDuration(page: Page): Promise { + // Duration resolution is serialized into the page and must remain self-contained. + // fallow-ignore-next-line complexity + return page.evaluate(() => { + const value = (target: unknown, key: string): unknown => + typeof target === "object" && target !== null ? Reflect.get(target, key) : undefined; + const positive = (candidate: unknown): number | null => + typeof candidate === "number" && candidate > 0 ? candidate : null; + const callDuration = (target: unknown): number | null => { + const duration = value(target, "duration"); + if (typeof duration === "function") { + const result = Reflect.apply(duration, target, []); + return positive(result); + } + return positive(duration); + }; + const hfDuration = positive(value(Reflect.get(window, "__hf"), "duration")); + if (hfDuration) return hfDuration; + const playerDuration = callDuration(Reflect.get(window, "__player")); + if (playerDuration) return playerDuration; + const root = document.querySelector("[data-composition-id][data-duration]"); + const authored = root ? parseFloat(root.getAttribute("data-duration") ?? "0") : 0; + if (authored > 0) return authored; + const timelines = Reflect.get(window, "__timelines"); + if (typeof timelines !== "object" || timelines === null) return 0; + for (const key of Object.keys(timelines)) { + const duration = callDuration(Reflect.get(timelines, key)); + if (duration) return duration; + } + return 0; + }); +} + +async function collectTweenBoundaries(page: Page): Promise { + // GSAP getter binding and parent-time conversion form one serialized algorithm. + // fallow-ignore-next-line complexity + return page.evaluate(() => { + const property = (target: unknown, key: string): unknown => + (typeof target === "object" && target !== null) || typeof target === "function" + ? Reflect.get(target, key) + : undefined; + const numberCall = (target: unknown, key: string, fallback: number): number => { + const method = property(target, key); + if (typeof method !== "function") return fallback; + const result = Reflect.apply(method, target, []); + return typeof result === "number" ? result : fallback; + }; + const rootTime = (root: unknown, animation: unknown, local: number): number => { + let time = local; + let node = animation; + while (node && node !== root) { + time = numberCall(node, "startTime", 0) + time / (numberCall(node, "timeScale", 1) || 1); + node = property(node, "parent"); + } + return time; + }; + const timelines = Reflect.get(window, "__timelines"); + if (typeof timelines !== "object" || timelines === null) return []; + const boundaries: number[] = []; + for (const key of Object.keys(timelines)) { + const timeline = Reflect.get(timelines, key); + const getChildren = property(timeline, "getChildren"); + if (typeof getChildren !== "function") continue; + try { + const children = Reflect.apply(getChildren, timeline, [true, true, false]); + if (!Array.isArray(children)) continue; + for (const child of children) { + const duration = numberCall(child, "duration", Number.NaN); + if (!Number.isFinite(duration)) continue; + boundaries.push(rootTime(timeline, child, 0), rootTime(timeline, child, duration)); + } + } catch { + continue; + } + } + return boundaries.filter(Number.isFinite); + }); +} + +async function collectLayout( + page: Page, + time: number, + tolerance: number, +): Promise { + const raw = await page.evaluate( + (options: { time: number; tolerance: number }) => { + const audit = Reflect.get(window, "__hyperframesLayoutAudit"); + if (typeof audit !== "function") return []; + const result = Reflect.apply(audit, window, [options]); + return Array.isArray(result) ? result : []; + }, + { time, tolerance }, + ); + return anchorLayoutIssues(page, raw.flatMap(parseLayoutIssue)); +} + +async function findAmbiguousSelectors( + page: Page, + selectors: string[], +): Promise { + const ambiguous = await page.evaluate( + (values: string[]) => + values.filter((selector) => { + try { + return document.querySelectorAll(selector).length > 1; + } catch { + return false; + } + }), + selectors, + ); + return anchorLayoutIssues(page, ambiguous.map(ambiguousIssue)); +} + +async function collectMotionFrame( + page: Page, + time: number, + selectors: string[], + livenessScopes: string[], +): Promise { + const raw = await page.evaluate( + (options: { selectors: string[]; livenessScopes: string[] }) => { + const sample = Reflect.get(window, "__hyperframesMotionSample"); + if (typeof sample !== "function") return null; + return Reflect.apply(sample, window, [options]); + }, + { selectors, livenessScopes }, + ); + return parseMotionFrame(raw, time, selectors, livenessScopes); +} + +async function anchorLayoutIssues( + page: Page, + issues: LayoutIssue[], +): Promise { + const requests = issues.map((issue) => ({ + selector: issue.selector, + time: issue.time, + bbox: rectToBbox(issue.rect), + })); + const anchors = await resolveAnchors(page, requests); + return issues.map((issue, index) => ({ + ...issue, + ...(anchors[index] ?? fallbackAnchor(requests[index])), + })); +} + +async function resolveAnchors(page: Page, requests: AnchorRequest[]): Promise { + if (requests.length === 0) return []; + return page.evaluate((values: AnchorRequest[]) => { + const root = document.querySelector("[data-composition-id]"); + return values.map((request) => { + let element: Element | null = null; + try { + element = document.querySelector(request.selector); + } catch { + element = null; + } + element ??= root; + // Clones the anchor-extraction block in prepareContrast's evaluate() below; + // both run inside separate serialized browser closures and can't share a + // Node-side helper. + // fallow-ignore-next-line code-duplication + const dataAttributes: Record = {}; + for (const attribute of Array.from(element?.attributes ?? [])) { + if (attribute.name.startsWith("data-")) dataAttributes[attribute.name] = attribute.value; + } + const source = element + ?.closest("[data-composition-file]") + ?.getAttribute("data-composition-file"); + return { + selector: element ? request.selector : "[data-composition-id]", + dataAttributes, + sourceFile: source || "index.html", + bbox: request.bbox, + time: request.time, + }; + }); + }, requests); +} + +async function resolveRootAnchor(page: Page): Promise { + const anchors = await resolveAnchors(page, [ + { selector: "[data-composition-id]", time: 0, bbox: await compositionBbox(page) }, + ]); + return anchors[0] ?? fallbackAnchor(undefined); +} + +async function compositionBbox(page: Page): Promise { + return page.evaluate(() => { + const element = document.querySelector("[data-composition-id]"); + const rect = element?.getBoundingClientRect(); + return rect + ? { x: rect.x, y: rect.y, width: rect.width, height: rect.height } + : { x: 0, y: 0, width: 0, height: 0 }; + }); +} + +async function collectContrast(page: Page, time: number): Promise { + let prepared: PreparedContrast[] = []; + try { + prepared = parsePreparedContrast(await prepareContrast(page, time)); + const screenshot = await page.screenshot({ encoding: "base64", type: "png" }); + if (typeof screenshot !== "string") throw new Error("Contrast screenshot was not base64"); + const raw = await finishContrast( + page, + screenshot, + time, + prepared.map((entry) => entry.raw), + ); + const finished = raw.flatMap(parseFinishedContrast); + return { entries: joinContrastEntries(finished, prepared), pngBase64: screenshot }; + } finally { + await page + .evaluate(() => { + const restore = Reflect.get(window, "__contrastAuditRestoreIfPending"); + if (typeof restore === "function") Reflect.apply(restore, window, []); + }) + .catch(() => undefined); + } +} + +async function prepareContrast(page: Page, time: number): Promise { + // Candidate-to-element provenance must be captured while the prepare restore list is live. + // fallow-ignore-next-line complexity + return page.evaluate((sampleTime: number) => { + const prepare = Reflect.get(window, "__contrastAuditPrepare"); + const candidates = typeof prepare === "function" ? Reflect.apply(prepare, window, []) : []; + if (!Array.isArray(candidates)) return []; + const restores = Reflect.get(window, "__contrastAuditRestores"); + const restoreList = Array.isArray(restores) ? restores : []; + const escape = (value: string) => + typeof CSS !== "undefined" && typeof CSS.escape === "function" + ? CSS.escape(value) + : value.replace(/[^a-zA-Z0-9_-]/g, "\\$&"); + const selectorFor = (element: Element | null, fallback: string): string => { + if (!element) return fallback; + if (element.id) return `#${escape(element.id)}`; + const parts: string[] = []; + for ( + let current: Element | null = element; + current && current !== document.body; + current = current.parentElement + ) { + const tag = current.tagName.toLowerCase(); + const siblings = current.parentElement + ? Array.from(current.parentElement.children).filter( + (item) => item.tagName === current?.tagName, + ) + : []; + parts.push( + siblings.length > 1 ? `${tag}:nth-of-type(${siblings.indexOf(current) + 1})` : tag, + ); + } + return parts.reverse().join(" > ") || fallback; + }; + // Part of the serialized evaluate body above; cannot delegate to Node helpers. + // fallow-ignore-next-line complexity + return candidates.map((candidate, index) => { + const restore = restoreList[index]; + const candidateObject = typeof candidate === "object" && candidate !== null ? candidate : {}; + const elementValue = + typeof restore === "object" && restore !== null ? Reflect.get(restore, "el") : null; + const element = elementValue instanceof Element ? elementValue : null; + const fallback = Reflect.get(candidateObject, "selector"); + const selector = selectorFor( + element, + typeof fallback === "string" ? fallback : "[data-composition-id]", + ); + const dataAttributes: Record = {}; + for (const attribute of Array.from(element?.attributes ?? [])) { + if (attribute.name.startsWith("data-")) dataAttributes[attribute.name] = attribute.value; + } + const source = element + ?.closest("[data-composition-file]") + ?.getAttribute("data-composition-file"); + return { + candidate: { ...candidateObject, selector }, + anchor: { + selector, + dataAttributes, + sourceFile: source || "index.html", + bbox: Reflect.get(candidateObject, "bbox"), + time: sampleTime, + }, + }; + }); + }, time); +} + +async function finishContrast( + page: Page, + screenshot: string, + time: number, + candidates: unknown[], +): Promise { + return page.evaluate( + async (payload: { screenshot: string; time: number; candidates: unknown[] }) => { + const finish = Reflect.get(window, "__contrastAuditFinish"); + if (typeof finish !== "function") return []; + const result = await Reflect.apply(finish, window, [ + payload.screenshot, + payload.time, + payload.candidates, + ]); + return Array.isArray(result) ? result : []; + }, + { screenshot, time, candidates }, + ); +} + +function parsePreparedContrast(raw: unknown[]): PreparedContrast[] { + return raw.flatMap((value) => { + if (!isRecord(value)) return []; + const raw = Reflect.get(value, "candidate"); + const candidate = parseContrastCandidate(raw); + const anchor = parseAnchor(Reflect.get(value, "anchor")); + return candidate && anchor ? [{ raw, candidate, anchor }] : []; + }); +} + +function parseContrastCandidate(value: unknown): ContrastCandidate | null { + if (!isRecord(value)) return null; + const selector = stringValue(value, "selector"); + const text = stringValue(value, "text"); + const fg = rgbaValue(Reflect.get(value, "fg")); + const large = booleanValue(value, "large"); + const bbox = parseBbox(Reflect.get(value, "bbox")); + return selector && text !== null && fg && large !== null && bbox + ? { selector, text, fg, large, bbox } + : null; +} + +function parseFinishedContrast(value: unknown): FinishedContrast[] { + if (!isRecord(value)) return []; + const selector = stringValue(value, "selector"); + const text = stringValue(value, "text"); + const ratio = numberValue(value, "ratio"); + const wcagAA = booleanValue(value, "wcagAA"); + const large = booleanValue(value, "large"); + const fg = stringValue(value, "fg"); + const bg = stringValue(value, "bg"); + return selector && + text !== null && + ratio !== null && + wcagAA !== null && + large !== null && + fg && + bg + ? [{ selector, text, ratio, wcagAA, large, fg, bg }] + : []; +} + +function joinContrastEntries( + finished: FinishedContrast[], + prepared: PreparedContrast[], +): ContrastAuditEntry[] { + const remaining = [...prepared]; + return finished.flatMap((entry) => { + const index = remaining.findIndex( + (candidate) => + candidate.candidate.selector === entry.selector && candidate.candidate.text === entry.text, + ); + const match = index >= 0 ? remaining.splice(index, 1)[0] : undefined; + return match ? [{ ...entry, ...match.anchor }] : []; + }); +} + +function parseLayoutIssue(value: unknown): LayoutIssue[] { + if (!isRecord(value)) return []; + const code = layoutCodeValue(Reflect.get(value, "code")); + const severity = severityValue(Reflect.get(value, "severity")); + const time = numberValue(value, "time"); + const selector = stringValue(value, "selector"); + const message = stringValue(value, "message"); + const rect = parseRect(Reflect.get(value, "rect")); + if (!code || !severity || time === null || !selector || !message || !rect) return []; + const issue: LayoutIssue = { code, severity, time, selector, message, rect }; + assignOptionalLayoutFields(issue, value); + return [issue]; +} + +function assignOptionalLayoutFields(issue: LayoutIssue, value: Record): void { + assignOptionalString(issue, value, "containerSelector"); + assignOptionalString(issue, value, "text"); + assignOptionalString(issue, value, "fixHint"); + const containerRect = parseRect(Reflect.get(value, "containerRect")); + if (containerRect) issue.containerRect = containerRect; + const overflow = parseOverflow(Reflect.get(value, "overflow")); + if (overflow) issue.overflow = overflow; +} + +function recordField(value: unknown, key: string): Record | null { + if (!isRecord(value)) return null; + const field = Reflect.get(value, key); + return isRecord(field) ? field : null; +} + +function parseMotionFrame( + value: unknown, + time: number, + selectors: string[], + scopes: string[], +): MotionFrame { + const rawData = recordField(value, "data"); + const rawLiveness = recordField(value, "liveness"); + const data: MotionFrame["data"] = {}; + for (const selector of selectors) { + data[selector] = rawData ? parseFrameSample(Reflect.get(rawData, selector)) : null; + } + const liveness: Record = {}; + for (const scope of scopes) { + const signature = rawLiveness ? Reflect.get(rawLiveness, scope) : ""; + liveness[scope] = typeof signature === "string" ? signature : ""; + } + return { time, data, liveness }; +} + +function parseFrameSample(value: unknown): MotionFrame["data"][string] { + if (!isRecord(value)) return null; + const rect = parseRect(Reflect.get(value, "rect")); + const opacity = numberValue(value, "opacity"); + const visible = booleanValue(value, "visible"); + return rect && opacity !== null && visible !== null ? { rect, opacity, visible } : null; +} + +function parseAnchor(value: unknown): CheckAnchor | null { + if (!isRecord(value)) return null; + const selector = stringValue(value, "selector"); + const sourceFile = stringValue(value, "sourceFile"); + const time = numberValue(value, "time"); + const bbox = parseBbox(Reflect.get(value, "bbox")); + const dataAttributes = stringRecord(Reflect.get(value, "dataAttributes")); + return selector && sourceFile && time !== null && bbox && dataAttributes + ? { selector, sourceFile, time, bbox, dataAttributes } + : null; +} + +function runtimeFinding(draft: RuntimeDraft, root: CheckAnchor): CheckFinding { + return { + code: draft.code, + severity: draft.severity, + message: draft.message, + selector: root.selector, + dataAttributes: root.dataAttributes, + sourceFile: root.sourceFile, + bbox: root.bbox, + time: draft.time, + url: draft.url, + line: draft.line, + }; +} + +function fallbackAnchor(request: AnchorRequest | undefined): CheckAnchor { + return { + selector: request?.selector ?? "[data-composition-id]", + dataAttributes: {}, + sourceFile: "index.html", + bbox: request?.bbox ?? { x: 0, y: 0, width: 0, height: 0 }, + time: request?.time ?? 0, + }; +} + +function rectToBbox(rect: LayoutRect): CheckBbox { + return { x: rect.left, y: rect.top, width: rect.width, height: rect.height }; +} + +function parseBbox(value: unknown): CheckBbox | null { + if (!isRecord(value)) return null; + const x = numberValue(value, "x"); + const y = numberValue(value, "y"); + const width = numberValue(value, "width") ?? numberValue(value, "w"); + const height = numberValue(value, "height") ?? numberValue(value, "h"); + return x !== null && y !== null && width !== null && height !== null + ? { x, y, width, height } + : null; +} + +function parseRect(value: unknown): LayoutRect | null { + if (!isRecord(value)) return null; + const left = numberValue(value, "left"); + const top = numberValue(value, "top"); + const right = numberValue(value, "right"); + const bottom = numberValue(value, "bottom"); + const width = numberValue(value, "width"); + const height = numberValue(value, "height"); + return left !== null && + top !== null && + right !== null && + bottom !== null && + width !== null && + height !== null + ? { left, top, right, bottom, width, height } + : null; +} + +function parseOverflow(value: unknown): LayoutIssue["overflow"] | null { + if (!isRecord(value)) return null; + const overflow: LayoutIssue["overflow"] = {}; + for (const side of ["left", "right", "top", "bottom"] as const) { + const amount = numberValue(value, side); + if (amount !== null) overflow[side] = amount; + } + return Object.keys(overflow).length > 0 ? overflow : null; +} + +const LAYOUT_ISSUE_CODES: readonly LayoutIssueCode[] = [ + "text_box_overflow", + "clipped_text", + "canvas_overflow", + "container_overflow", + "content_overlap", + "text_occluded", + "motion_appears_late", + "motion_out_of_order", + "motion_off_frame", + "motion_frozen", + "motion_selector_missing", + "motion_selector_ambiguous", +]; + +function layoutCodeValue(value: unknown): LayoutIssueCode | null { + return LAYOUT_ISSUE_CODES.find((code) => code === value) ?? null; +} + +function severityValue(value: unknown): CheckSeverity | null { + return value === "error" || value === "warning" || value === "info" ? value : null; +} + +function rgbaValue(value: unknown): [number, number, number, number] | null { + if (!Array.isArray(value) || value.length < 4) return null; + const [red, green, blue, alpha] = value; + return [red, green, blue, alpha].every((channel) => typeof channel === "number") + ? [red, green, blue, alpha] + : null; +} + +function stringRecord(value: unknown): Record | null { + if (!isRecord(value)) return null; + const record: Record = {}; + for (const key of Object.keys(value)) { + const entry = Reflect.get(value, key); + if (typeof entry !== "string") return null; + record[key] = entry; + } + return record; +} + +function assignOptionalString( + issue: LayoutIssue, + source: Record, + key: "containerSelector" | "text" | "fixHint", +): void { + const value = stringValue(source, key); + if (value !== null) issue[key] = value; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function stringValue(value: Record, key: string): string | null { + const entry = Reflect.get(value, key); + return typeof entry === "string" ? entry : null; +} + +function numberValue(value: Record, key: string): number | null { + const entry = Reflect.get(value, key); + return typeof entry === "number" && Number.isFinite(entry) ? entry : null; +} + +function booleanValue(value: Record, key: string): boolean | null { + const entry = Reflect.get(value, key); + return typeof entry === "boolean" ? entry : null; +} + +function urlPath(url: string): string { + try { + return decodeURIComponent(new URL(url).pathname).replace(/^\//, ""); + } catch { + return url; + } +} diff --git a/packages/cli/src/utils/checkPipeline.ts b/packages/cli/src/utils/checkPipeline.ts new file mode 100644 index 0000000000..854cb72c4e --- /dev/null +++ b/packages/cli/src/utils/checkPipeline.ts @@ -0,0 +1,554 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { join, relative } from "node:path"; +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 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 type { + AnchoredLayoutIssue, + CheckAuditDriver, + CheckBbox, + CheckBrowserResult, + CheckContrastFinding, + CheckDependencies, + CheckFinding, + CheckOptions, + CheckReport, + CheckScreenshot, + CheckSection, + CheckSeverity, + ContrastAuditEntry, + MotionSpecResolution, +} from "./checkTypes.js"; + +export type { + AnchoredLayoutIssue, + CheckAnchor, + CheckAuditDriver, + CheckBrowserResult, + CheckDependencies, + CheckFinding, + 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 }; + +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[]; + transitionSamples: number[]; + transitionSamplesDropped: number; + contrastSamples: number[]; +} + +async function buildSampleGrid( + driver: CheckAuditDriver, + options: CheckOptions, +): Promise { + const duration = await driver.getDuration(); + const baseSamples = buildLayoutSampleTimes({ + duration, + samples: options.samples, + at: options.at, + }); + const transitions = options.atTransitions + ? buildTransitionSampleTimes({ + duration, + boundaries: await driver.getTransitionBoundaries(), + cap: options.maxTransitionSamples, + }) + : { times: [], dropped: 0 }; + const layoutSamples = mergeSampleTimes(baseSamples, transitions.times); + if (layoutSamples.length === 0) { + throw new Error("Could not determine composition duration — no layout samples run"); + } + return { + duration, + layoutSamples, + transitionSamples: transitions.times, + transitionSamplesDropped: transitions.dropped, + contrastSamples: options.contrast ? selectContrastTimes(layoutSamples) : [], + }; +} + +interface MotionPlan { + times: number[]; + selectors: string[]; + livenessScopes: string[]; + preflightIssues: AnchoredLayoutIssue[]; +} + +async function planMotionSampling( + driver: CheckAuditDriver, + motion: MotionSpecResolution, + duration: number, +): Promise { + if (motion.kind !== "valid") { + return { times: [], selectors: [], livenessScopes: [], preflightIssues: [] }; + } + const targets = collectSamplingTargets(motion.spec.assertions); + const preflightIssues = await driver.findAmbiguousSelectors(targets.selectors); + const times = + preflightIssues.length === 0 ? buildMotionSampleTimes(motion.spec.duration ?? duration) : []; + return { times, ...targets, preflightIssues }; +} + +interface GridSamples { + layoutIssues: AnchoredLayoutIssue[]; + motionFrames: MotionFrame[]; + contrastEntries: ContrastAuditEntry[]; + screenshots: CheckScreenshot[]; +} + +async function collectGridSamples( + driver: CheckAuditDriver, + options: CheckOptions, + grid: SampleGrid, + motion: MotionPlan, +): Promise { + const layoutSet = new Set(grid.layoutSamples); + const motionSet = new Set(motion.times); + const contrastSet = new Set(grid.contrastSamples); + const collected: GridSamples = { + layoutIssues: [], + motionFrames: [], + contrastEntries: [], + screenshots: [], + }; + for (const time of mergeSampleTimes(grid.layoutSamples, motion.times)) { + await driver.seek(time); + if (layoutSet.has(time)) { + collected.layoutIssues.push(...(await driver.collectLayout(time, options.tolerance))); + } + if (motionSet.has(time)) { + collected.motionFrames.push( + await driver.collectMotionFrame(time, motion.selectors, motion.livenessScopes), + ); + } + if (contrastSet.has(time)) { + const capture = await driver.collectContrast(time); + collected.contrastEntries.push(...capture.entries); + collected.screenshots.push({ time, pngBase64: capture.pngBase64 }); + } + } + return collected; +} + +export async function runAuditGrid( + driver: CheckAuditDriver, + options: CheckOptions, + motion: MotionSpecResolution, +): Promise { + await driver.initialize(options.contrast); + const grid = await buildSampleGrid(driver, options); + const plan = await planMotionSampling(driver, motion, grid.duration); + const collected = await collectGridSamples(driver, options, grid, plan); + + 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 contrast = buildContrastResults(collected.contrastEntries); + return { + duration: grid.duration, + layoutSamples: grid.layoutSamples, + transitionSamples: grid.transitionSamples, + transitionSamplesDropped: grid.transitionSamplesDropped, + runtimeFindings: [], + layoutIssues: collected.layoutIssues, + motionIssues, + motionSampleCount: collected.motionFrames.length, + contrastSamples: grid.contrastSamples, + contrastFindings: contrast.findings, + contrastChecked: collected.contrastEntries.length, + contrastPassed: contrast.passed, + screenshots: collected.screenshots, + }; +} + +export async function runCheckPipeline( + project: ProjectDir, + options: CheckOptions, + dependencies: CheckDependencies = DEFAULT_DEPENDENCIES, +): Promise { + let lintResult: ProjectLintResult; + try { + lintResult = await dependencies.lintProject(project.dir); + } catch (error) { + return failureReport(options, runtimeFailure(error)); + } + + 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: string[] = []; + if (options.snapshots) { + for (let index = 0; index < browser.screenshots.length; index += 1) { + const shot = browser.screenshots[index]; + if (!shot) continue; + try { + snapshotFiles.push( + await dependencies.writeSnapshot(project.dir, index, shot.time, shot.pngBase64), + ); + } catch (error) { + browser.runtimeFindings.push(runtimeFailure(error, "snapshot_write_failed")); + } + } + } + return buildReport(options, lint, browser, motion, [], snapshotFiles); +} + +export function checkExitCode(report: CheckReport): 0 | 1 { + return report.ok ? 0 : 1; +} + +function buildContrastResults(entries: ContrastAuditEntry[]): { + findings: CheckContrastFinding[]; + passed: number; +} { + const findings: CheckContrastFinding[] = []; + let passed = 0; + for (const entry of entries) { + if (entry.wcagAA) { + passed += 1; + continue; + } + const requiredRatio = requiredContrastRatio(entry.large); + findings.push({ + code: "contrast_aa_failure", + severity: "error", + 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; + return { + 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) : [], + }, + }; +} + +function shapeLayoutSection( + issues: AnchoredLayoutIssue[], + browser: CheckBrowserResult, + options: CheckOptions, +): CheckReport["layout"] { + const shaped = shapeLayoutFindings(issues, options); + 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, +): { findings: AnchoredLayoutIssue[]; totalIssueCount: number; truncated: boolean } { + const deduped = dedupeLayoutIssues(issues); + const all = options.collapseStatic ? collapseStaticLayoutIssues(deduped) : deduped; + const limited = limitLayoutIssues(all, options.maxIssues); + return { + findings: limited.issues.map(ensureAnchoredLayoutIssue), + totalIssueCount: limited.totalIssueCount, + truncated: limited.truncated, + }; +} + +function ensureAnchoredLayoutIssue(issue: LayoutIssue): AnchoredLayoutIssue { + const sourceFile = Reflect.get(issue, "sourceFile"); + const dataAttributes = Reflect.get(issue, "dataAttributes"); + const bbox = Reflect.get(issue, "bbox"); + if (typeof sourceFile === "string" && isStringRecord(dataAttributes) && isBbox(bbox)) { + return { ...issue, sourceFile, dataAttributes, bbox }; + } + return { + ...issue, + sourceFile: "index.html", + dataAttributes: {}, + bbox: rectToBbox(issue.rect), + }; +} + +function section(findings: T[]): CheckSection { + const errorCount = findings.filter((finding) => finding.severity === "error").length; + const warningCount = findings.filter((finding) => finding.severity === "warning").length; + const infoCount = findings.filter((finding) => finding.severity === "info").length; + return { ok: errorCount === 0, errorCount, warningCount, infoCount, findings }; +} + +function emptyBrowserResult(): CheckBrowserResult { + return { + duration: 0, + layoutSamples: [], + transitionSamples: [], + transitionSamplesDropped: 0, + runtimeFindings: [], + layoutIssues: [], + motionIssues: [], + motionSampleCount: 0, + contrastSamples: [], + contrastFindings: [], + contrastChecked: 0, + contrastPassed: 0, + screenshots: [], + }; +} + +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 rectToBbox(rect: LayoutRect): CheckBbox { + return { x: rect.left, y: rect.top, width: rect.width, height: rect.height }; +} + +function isBbox(value: unknown): value is CheckBbox { + if (typeof value !== "object" || value === null) return false; + return ["x", "y", "width", "height"].every((key) => typeof Reflect.get(value, key) === "number"); +} + +function isStringRecord(value: unknown): value is Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + return Object.keys(value).every((key) => typeof Reflect.get(value, key) === "string"); +} + +function resolveMotionSpec(projectDir: string): MotionSpecResolution { + const path = findMotionSpec(projectDir); + if (!path) return { kind: "none" }; + const result = readMotionSpec(path); + return result.ok + ? { kind: "valid", path, spec: result.spec } + : { + kind: "invalid", + path, + message: `Invalid motion spec ${path}: ${result.errors.join("; ")}`, + }; +} + +async function runBrowserCheck( + project: ProjectDir, + options: CheckOptions, + motion: MotionSpecResolution, +): Promise { + const module = await import("./checkBrowser.js"); + // runAuditGrid is handed over as a callback so checkBrowser never imports + // this module back (no import cycle). + return module.runBrowserCheck(project, options, motion, runAuditGrid); +} + +async function writeSnapshot( + projectDir: string, + index: number, + time: number, + pngBase64: string, +): Promise { + const snapshotDir = join(projectDir, "snapshots"); + mkdirSync(snapshotDir, { recursive: true }); + const filename = `frame-${String(index).padStart(2, "0")}-at-${time.toFixed(1)}s.png`; + const path = join(snapshotDir, filename); + writeFileSync(path, Buffer.from(pngBase64, "base64")); + return join("snapshots", filename); +} + +const DEFAULT_DEPENDENCIES: CheckDependencies = { + lintProject, + resolveMotionSpec, + runBrowserCheck, + writeSnapshot, +}; diff --git a/packages/cli/src/utils/checkTypes.ts b/packages/cli/src/utils/checkTypes.ts new file mode 100644 index 0000000000..e129793f7e --- /dev/null +++ b/packages/cli/src/utils/checkTypes.ts @@ -0,0 +1,169 @@ +import type { ProjectLintResult } from "./lintProject.js"; +import type { LayoutIssue } 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; +} + +export type CheckSeverity = "error" | "warning" | "info"; + +export interface CheckBbox { + x: number; + y: number; + width: number; + height: number; +} + +export interface CheckAnchor { + selector: string; + dataAttributes: Record; + sourceFile: string; + bbox: CheckBbox; + time: number; +} + +export interface CheckFinding extends CheckAnchor { + code: string; + severity: CheckSeverity; + message: string; + text?: string; + fixHint?: string; + url?: string; + line?: number; +} + +export interface AnchoredLayoutIssue extends LayoutIssue, CheckAnchor {} + +export interface ContrastAuditEntry extends CheckAnchor { + text: string; + ratio: number; + wcagAA: boolean; + large: boolean; + fg: string; + bg: string; +} + +export interface CheckContrastFinding extends CheckFinding { + fg: string; + bg: string; + ratio: number; + requiredRatio: number; + suggestedColor: string; + large: boolean; +} + +export interface ContrastCapture { + entries: ContrastAuditEntry[]; + pngBase64: string; +} + +export type MotionSpecResolution = + | { kind: "none" } + | { kind: "valid"; path: string; spec: MotionSpec } + | { kind: "invalid"; path: string; message: string }; + +export interface CheckAuditDriver { + initialize(contrast: boolean): Promise; + getDuration(): Promise; + getTransitionBoundaries(): Promise; + getCanvas(): Promise; + findAmbiguousSelectors(selectors: string[]): Promise; + seek(time: number): Promise; + collectLayout(time: number, tolerance: number): Promise; + collectMotionFrame( + time: number, + selectors: string[], + livenessScopes: string[], + ): Promise; + anchorMotionIssues(issues: LayoutIssue[]): Promise; + collectContrast(time: number): Promise; +} + +export interface CheckScreenshot { + time: number; + pngBase64: string; +} + +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[]; +} + +/** The seek-grid audit loop, injected into checkBrowser so it never imports checkPipeline back. */ +export type RunAuditGrid = ( + driver: CheckAuditDriver, + options: CheckOptions, + motion: MotionSpecResolution, +) => Promise; + +export interface CheckSection { + 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 & { + duration: number; + samples: number[]; + transitionSamples: number[]; + transitionSamplesDropped: number; + tolerance: number; + totalIssueCount: number; + truncated: boolean; + }; + motion: CheckSection & { enabled: boolean; specPath?: string; samples: number }; + contrast: CheckSection & { + enabled: boolean; + samples: number[]; + checked: number; + passed: number; + }; + snapshots: { enabled: boolean; files: string[]; times: number[] }; +} + +export interface CheckDependencies { + lintProject(projectDir: string): Promise; + resolveMotionSpec(projectDir: string): MotionSpecResolution; + runBrowserCheck( + project: ProjectDir, + options: CheckOptions, + motion: MotionSpecResolution, + ): Promise; + writeSnapshot( + projectDir: string, + index: number, + time: number, + pngBase64: string, + ): Promise; +} From 7ab6c2b7a2611a25a613fa41ee4d3b2efbf3be53 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 10 Jul 2026 01:05:01 -0400 Subject: [PATCH 03/16] feat(cli): caption-zone and frame-check gates on check Ports the EF bridge's captionZone and frameCheck semantics as opt-in flags so the bespoke bridge can be retired: --caption-zone takes fractional band geometry (x0;y0;x1;y1) with optional severity routing and seek points, defaults matching the bridge (caption seek [1], frame seek [0.5], 2px tolerance, 0.05 opacity floor, 4px minimum size, 0.95 full-frame exclusion, center-in-band comparison, tag|text dedup). --frame-check adds media bounds detection (img/svg/video/canvas) the always-on text canvas_overflow never covered, reusing overflowFor. Breach floor: max(120px, 6% of min canvas dimension). Band math derives from the composition's own canvas, portrait included. Both gates off by default; plain check output unchanged. --- packages/cli/src/commands/check.test.ts | 458 +++++++++++++++++- packages/cli/src/commands/check.ts | 112 ++++- .../cli/src/commands/layout-audit.browser.js | 157 +++++- .../src/commands/layout-audit.browser.test.ts | 252 +++++++++- packages/cli/src/utils/checkBrowser.test.ts | 110 +++++ packages/cli/src/utils/checkBrowser.ts | 71 +++ packages/cli/src/utils/checkPipeline.ts | 195 +++++++- packages/cli/src/utils/checkTypes.ts | 38 +- packages/cli/src/utils/layoutAudit.ts | 10 + 9 files changed, 1376 insertions(+), 27 deletions(-) create mode 100644 packages/cli/src/utils/checkBrowser.test.ts diff --git a/packages/cli/src/commands/check.test.ts b/packages/cli/src/commands/check.test.ts index 18dc433a14..8e1fb3cbea 100644 --- a/packages/cli/src/commands/check.test.ts +++ b/packages/cli/src/commands/check.test.ts @@ -20,8 +20,9 @@ import { type ContrastAuditEntry, type MotionSpecResolution, } from "../utils/checkPipeline.js"; +import { resolveCompositionViewportFromHtml } from "../utils/compositionViewport.js"; import type { ProjectLintResult } from "../utils/lintProject.js"; -import type { LayoutIssue } from "../utils/layoutAudit.js"; +import type { LayoutIssue, LayoutOverflow, LayoutRect } from "../utils/layoutAudit.js"; import type { ProjectDir } from "../utils/project.js"; const PROJECT: ProjectDir = { @@ -30,6 +31,12 @@ const PROJECT: ProjectDir = { indexPath: "/project/index.html", }; const PNG_BASE64 = Buffer.from("png-bytes").toString("base64"); +const ORIGINAL_EXIT_CODE = process.exitCode; + +afterEach(() => { + process.exitCode = ORIGINAL_EXIT_CODE; + vi.restoreAllMocks(); +}); function cleanLint(): ProjectLintResult { return { @@ -118,6 +125,7 @@ function fakeDriver(overrides: Partial = {}): CheckAuditDriver findAmbiguousSelectors: vi.fn(async (_selectors: string[]) => []), seek: vi.fn(async (_time: number) => undefined), collectLayout: vi.fn(async (_time: number, _tolerance: number) => []), + collectGeometryCandidates: vi.fn(async () => []), collectMotionFrame: vi.fn(async (time: number) => ({ time, data: {}, liveness: {} })), anchorMotionIssues: vi.fn(async (issues: LayoutIssue[]) => issues.map((issue) => ({ @@ -130,6 +138,76 @@ function fakeDriver(overrides: Partial = {}): CheckAuditDriver }; } +interface GeometryFixture { + kind: "text" | "media"; + tag: string; + text: string; + selector: string; + rect: LayoutRect; + elementRect?: LayoutRect; + time: number; + overflow?: LayoutOverflow; +} + +function geometryCandidate(fixture: GeometryFixture) { + return { + ...anchor(fixture.selector, fixture.time), + kind: fixture.kind, + tag: fixture.tag, + text: fixture.text, + rect: fixture.rect, + elementRect: fixture.elementRect ?? fixture.rect, + bbox: { + x: fixture.rect.left, + y: fixture.rect.top, + width: fixture.rect.width, + height: fixture.rect.height, + }, + overflow: fixture.overflow, + }; +} + +function fixtureRect(left: number, top: number, width: number, height: number): LayoutRect { + return { left, top, right: left + width, bottom: top + height, width, height }; +} + +function checkBrowserSource(): string { + return readFileSync(new URL("../utils/checkBrowser.ts", import.meta.url), "utf8"); +} + +async function gateCandidates( + time: number, + request: { text: boolean; media: boolean; tolerance: number }, +) { + const candidates = []; + if (request.text) { + candidates.push( + geometryCandidate({ + kind: "text", + tag: "h2", + text: "Repeated heading", + selector: time === 2 ? "#first-heading" : "#later-heading", + rect: fixtureRect(800, 880, 320, 60), + time, + }), + ); + } + if (request.media) { + candidates.push( + geometryCandidate({ + kind: "media", + tag: "video", + text: "video", + selector: "#midpoint-video", + rect: fixtureRect(-140, 100, 100, 100), + overflow: { left: 140 }, + time, + }), + ); + } + return candidates; +} + function noMotion(): MotionSpecResolution { return { kind: "none" }; } @@ -200,6 +278,361 @@ describe("contrast sample selection", () => { }); }); +it("parses the caption-zone grammar and enables the frame gate", async () => { + const { report } = await runScenario(fakeDriver()); + const runPipeline = vi.fn(async (_project: ProjectDir, _options: CheckOptions) => report); + vi.spyOn(console, "log").mockImplementation(() => undefined); + const command = createCheckCommand({ + resolveProject: () => PROJECT, + runPipeline, + withMeta: (value) => value, + }); + + await runCommand(command, { + rawArgs: [ + "--json", + "--caption-zone", + "x0=0;y0=.82;x1=1;y1=1;severity=error;seek=.25,1", + "--frame-check", + ], + }); + + expect(runPipeline).toHaveBeenCalledWith( + PROJECT, + expect.objectContaining({ + captionZone: { + x0: 0, + y0: 0.82, + x1: 1, + y1: 1, + severity: "error", + seek: [0.25, 1], + }, + frameCheck: {}, + }), + ); +}); + +it("rejects malformed caption-zone specs instead of silently disabling the gate", async () => { + const { report } = await runScenario(fakeDriver()); + const runPipeline = vi.fn(async () => report); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + const command = createCheckCommand({ + resolveProject: () => PROJECT, + runPipeline, + withMeta: (value) => ({ ...value, _meta: { version: "test" } }), + }); + + await runCommand(command, { + rawArgs: ["--json", "--caption-zone", "x0=0;y0=.8;x1=1;y1=1.2"], + }); + + expect(runPipeline).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + expect(log).toHaveBeenCalledTimes(1); + expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual({ + ok: false, + error: expect.stringContaining("Invalid --caption-zone"), + _meta: { version: "test" }, + }); +}); + +it("flags only text whose center is inside the caption band at the default end seek", async () => { + const collectGeometryCandidates = vi.fn(async (time: number) => [ + geometryCandidate({ + kind: "text", + tag: "div", + text: "Centered title", + selector: "#centered", + rect: fixtureRect(860, 870, 200, 60), + time, + }), + geometryCandidate({ + kind: "text", + tag: "div", + text: "Overlap only", + selector: "#overlap-only", + rect: fixtureRect(860, 830, 200, 60), + time, + }), + ]); + const { report } = await runScenario( + fakeDriver({ + getDuration: vi.fn(async () => 10), + collectGeometryCandidates, + }), + { + samples: 1, + contrast: false, + captionZone: { x0: 0, y0: 0.8, x1: 1, y1: 0.9 }, + }, + ); + + expect(collectGeometryCandidates).toHaveBeenCalledTimes(1); + expect(collectGeometryCandidates).toHaveBeenCalledWith(10, { + text: true, + media: false, + tolerance: 2, + }); + expect(report.layout.samples).toEqual([5, 10]); + expect(report.layout.findings).toEqual([ + expect.objectContaining({ + code: "caption_zone_collision", + severity: "warning", + selector: "#centered", + text: "Centered title", + time: 10, + }), + ]); + expect(report.ok).toBe(true); +}); + +it("filters caption candidates by the element box while centering the text rect", async () => { + const collectGeometryCandidates = vi.fn(async (time: number) => [ + geometryCandidate({ + kind: "text", + tag: "div", + text: "Full-frame wrapper copy", + selector: "#full-frame-wrapper", + rect: fixtureRect(860, 870, 200, 60), + elementRect: fixtureRect(0, 0, 1920, 1080), + time, + }), + geometryCandidate({ + kind: "text", + tag: "span", + text: "Tiny wrapper copy", + selector: "#tiny-wrapper", + rect: fixtureRect(860, 870, 200, 60), + elementRect: fixtureRect(860, 870, 3, 3), + time, + }), + ]); + const { report } = await runScenario(fakeDriver({ collectGeometryCandidates }), { + contrast: false, + captionZone: { x0: 0, y0: 0.8, x1: 1, y1: 0.9 }, + }); + + expect(report.layout.findings).toEqual([]); +}); + +it("checks media overflow at the default midpoint and applies warning severity", async () => { + const collectGeometryCandidates = vi.fn(async (time: number) => [ + geometryCandidate({ + kind: "media", + tag: "img", + text: "img", + selector: "#hero-image", + rect: fixtureRect(1840, 100, 220, 200), + overflow: { right: 140 }, + time, + }), + ]); + const { report } = await runScenario( + fakeDriver({ getDuration: vi.fn(async () => 10), collectGeometryCandidates }), + { samples: 1, contrast: false, frameCheck: {} }, + ); + + expect(collectGeometryCandidates).toHaveBeenCalledWith(5, { + text: false, + media: true, + tolerance: 2, + }); + expect(report.layout.findings[0]).toMatchObject({ + code: "frame_out_of_frame", + severity: "warning", + selector: "#hero-image", + overflow: { right: 140 }, + time: 5, + }); +}); + +it("converts progress seeks to time, gates each collector, and keeps the first caption hit", async () => { + const collectGeometryCandidates = vi.fn(gateCandidates); + const { report } = await runScenario( + fakeDriver({ + getDuration: vi.fn(async () => 8), + collectGeometryCandidates, + }), + { + samples: 1, + contrast: false, + captionZone: { + x0: 0, + y0: 0.8, + x1: 1, + y1: 0.9, + severity: "error", + seek: [0.25, 0.75], + }, + frameCheck: { severity: "error" }, + }, + ); + + expect(report.layout.samples).toEqual([2, 4, 6]); + expect(collectGeometryCandidates.mock.calls).toEqual([ + [2, { text: true, media: false, tolerance: 2 }], + [4, { text: false, media: true, tolerance: 2 }], + [6, { text: true, media: false, tolerance: 2 }], + ]); + expect(report.layout.findings).toEqual([ + expect.objectContaining({ + code: "caption_zone_collision", + severity: "error", + selector: "#first-heading", + time: 2, + }), + expect.objectContaining({ code: "frame_out_of_frame", severity: "error", time: 4 }), + ]); + expect(report.ok).toBe(false); +}); + +it("does not collect or emit opt-in geometry findings when both flags are off", async () => { + const collectGeometryCandidates = vi.fn(async () => [ + geometryCandidate({ + kind: "media", + tag: "video", + text: "video", + selector: "#video", + rect: fixtureRect(1900, 0, 200, 200), + overflow: { right: 180 }, + time: 4.5, + }), + ]); + const { report } = await runScenario(fakeDriver({ collectGeometryCandidates }), { + contrast: false, + }); + + expect(collectGeometryCandidates).not.toHaveBeenCalled(); + expect(JSON.stringify(report)).not.toContain("caption_zone_collision"); + expect(JSON.stringify(report)).not.toContain("frame_out_of_frame"); +}); + +it("computes caption bands from a portrait composition viewport", async () => { + const viewport = resolveCompositionViewportFromHtml( + '
', + ); + const collectGeometryCandidates = vi.fn(async (time: number) => [ + geometryCandidate({ + kind: "text", + tag: "p", + text: "Portrait caption collision", + selector: "#portrait-copy", + rect: fixtureRect(480, 1570, 120, 60), + time, + }), + ]); + const getCanvas = vi.fn(async () => viewport); + const { report } = await runScenario( + fakeDriver({ + getDuration: vi.fn(async () => 4), + getCanvas, + collectGeometryCandidates, + }), + { + samples: 1, + contrast: false, + captionZone: { x0: 0.4, y0: 0.8, x1: 0.6, y1: 0.9 }, + }, + ); + + expect(viewport).toEqual({ width: 1080, height: 1920 }); + expect(getCanvas).toHaveBeenCalled(); + expect(report.layout.findings).toEqual([ + expect.objectContaining({ code: "caption_zone_collision", selector: "#portrait-copy" }), + ]); +}); + +it("suppresses frame breaches below the per-canvas floor and reports those above it", async () => { + const collectGeometryCandidates = vi.fn(async (time: number) => [ + geometryCandidate({ + kind: "media", + tag: "canvas", + text: "canvas", + selector: "#under-floor", + rect: fixtureRect(3980, 100, 199, 100), + overflow: { right: 179 }, + time, + }), + geometryCandidate({ + kind: "media", + tag: "canvas", + text: "canvas", + selector: "#over-floor", + rect: fixtureRect(3980, 300, 201, 100), + overflow: { right: 181 }, + time, + }), + ]); + const { report } = await runScenario( + fakeDriver({ + getCanvas: vi.fn(async () => ({ width: 4000, height: 3000 })), + collectGeometryCandidates, + }), + { + contrast: false, + frameCheck: {}, + }, + ); + + expect(report.layout.findings).toEqual([ + expect.objectContaining({ + code: "frame_out_of_frame", + selector: "#over-floor", + overflow: { right: 181 }, + }), + ]); +}); + +it("keeps frame findings at distinct rounded positions across requested seeks", async () => { + const collectGeometryCandidates = vi.fn(async (time: number) => [ + geometryCandidate({ + kind: "media", + tag: "img", + text: "img", + selector: "#moving-image", + rect: fixtureRect(1920, time === 2 ? 100 : 300, 130, 100), + overflow: { right: 130 }, + time, + }), + ]); + const { report } = await runScenario( + fakeDriver({ + getDuration: vi.fn(async () => 8), + collectGeometryCandidates, + }), + { + samples: 1, + contrast: false, + frameCheck: { seek: [0.25, 0.75] }, + }, + ); + + expect(report.layout.findings).toEqual([ + expect.objectContaining({ code: "frame_out_of_frame", time: 2 }), + expect.objectContaining({ code: "frame_out_of_frame", time: 6 }), + ]); +}); + +it("keeps contrast and snapshot sampling on the pre-gate layout grid", async () => { + const collectContrast = vi.fn(async () => ({ entries: [], pngBase64: PNG_BASE64 })); + const { report } = await runScenario( + fakeDriver({ + getDuration: vi.fn(async () => 10), + collectContrast, + }), + { + samples: 1, + captionZone: { x0: 0, y0: 0.8, x1: 1, y1: 1 }, + }, + ); + + expect(report.layout.samples).toEqual([5, 10]); + expect(report.contrast.samples).toEqual([5]); + expect(collectContrast).toHaveBeenCalledTimes(1); + expect(collectContrast).toHaveBeenCalledWith(5); +}); + describe("check pipeline", () => { const originalExitCode = process.exitCode; @@ -403,6 +836,16 @@ describe("check pipeline", () => { await expect(runAuditGrid(driver, DEFAULT_CHECK_OPTIONS, noMotion())).rejects.toThrow( "Could not determine composition duration — no layout samples run", ); + await expect( + runAuditGrid( + driver, + { + ...DEFAULT_CHECK_OPTIONS, + captionZone: { x0: 0, y0: 0.8, x1: 1, y1: 1 }, + }, + noMotion(), + ), + ).rejects.toThrow("Could not determine composition duration — no layout samples run"); const { report, browser } = await runScenario(driver); expect(browser).toHaveBeenCalledTimes(1); @@ -415,7 +858,7 @@ describe("check pipeline", () => { describe("contrast candidate round-trip", () => { it("passes the browser script's raw candidates back to finish, never the normalized copies", () => { - const source = readFileSync(new URL("../utils/checkBrowser.ts", import.meta.url), "utf8"); + const source = checkBrowserSource(); // __contrastAuditFinish samples pixels via the page script's own bbox // shape ({x, y, w, h}); sending the Node-normalized candidate @@ -426,3 +869,14 @@ describe("contrast candidate round-trip", () => { expect(source).not.toMatch(/prepared\.map\(\(entry\) => entry\.candidate\)/); }); }); + +describe("geometry candidate plumbing", () => { + it("wires the opt-in browser primitive without round-tripping normalized candidates", () => { + const source = checkBrowserSource(); + + expect(source).toMatch(/collectGeometryCandidates: \(time, request\) =>/); + expect(source).toMatch(/__hyperframesGeometryCandidates/); + expect(source).toMatch(/raw\.flatMap\(\(value\) => parseGeometryCandidate\(value, time\)\)/); + expect(source).not.toMatch(/resolveAnchors\(page, raw/); + }); +}); diff --git a/packages/cli/src/commands/check.ts b/packages/cli/src/commands/check.ts index 78e183377d..36c48aa64b 100644 --- a/packages/cli/src/commands/check.ts +++ b/packages/cli/src/commands/check.ts @@ -15,6 +15,7 @@ import { type CheckReport, type CheckSection, } from "../utils/checkPipeline.js"; +import type { CaptionZoneOptions } from "../utils/checkTypes.js"; export const examples: Example[] = [ ["Run the full verification gate", "hyperframes check"], @@ -102,16 +103,27 @@ export function createCheckCommand( 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: "boolean", + description: + "Use as --frame-check (boolean/no value; tol=2px, severity=warning, seek=.5; breach floor=max(120px, 6% of shorter canvas edge))", + default: false, + }, }, async run({ args }) { - const project = dependencies.resolveProject(args.dir); - const options = parseCheckOptions(args); const asJson = args.json === true; - if (!asJson) { - console.log(`${c.accent("◆")} Checking ${c.accent(project.name)}`); - } 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)); @@ -151,9 +163,99 @@ function parseCheckOptions(args: Record): CheckOptions { contrast: args.contrast !== false, strict: args.strict === true, snapshots: args.snapshots === true, + captionZone: parseCaptionZone(args["caption-zone"]), + frameCheck: args["frame-check"] === true ? {} : undefined, + }; +} + +const CAPTION_ZONE_FIELDS = new Set(["x0", "y0", "x1", "y1", "severity", "seek"]); + +function parseCaptionZone(value: unknown): CaptionZoneOptions | undefined { + if (value === undefined || value === null) return undefined; + const fields = parseCaptionFields(captionZoneString(value)); + const { x0, y0, x1, y1 } = parseCaptionBounds(fields); + const severity = captionSeverity(fields.get("severity")); + const seek = captionSeeks(fields.get("seek")); + return { + x0, + y0, + x1, + y1, + ...(severity ? { severity } : {}), + ...(seek ? { seek } : {}), + }; +} + +function captionZoneString(value: unknown): string { + if (typeof value !== "string" || value.trim() === "") throw captionZoneError(); + return value; +} + +function parseCaptionFields(value: string): Map { + const fields = new Map(); + for (const part of value.split(";")) { + const { key, entry } = parseCaptionField(part); + if (!CAPTION_ZONE_FIELDS.has(key) || fields.has(key)) throw captionZoneError(); + fields.set(key, entry); + } + return fields; +} + +function parseCaptionField(part: string): { key: string; entry: string } { + const separator = part.indexOf("="); + if (separator <= 0) throw captionZoneError(); + return { + key: part.slice(0, separator).trim(), + entry: part.slice(separator + 1).trim(), }; } +function parseCaptionBounds(fields: Map): { + x0: number; + y0: number; + x1: number; + y1: number; +} { + const x0 = requiredCaptionFraction(fields, "x0"); + const y0 = requiredCaptionFraction(fields, "y0"); + const x1 = requiredCaptionFraction(fields, "x1"); + const y1 = requiredCaptionFraction(fields, "y1"); + if (x0 > x1 || y0 > y1) throw captionZoneError(); + return { x0, y0, x1, y1 }; +} + +function requiredCaptionFraction(fields: Map, key: string): number { + const value = captionFraction(fields.get(key)); + if (value === null) throw captionZoneError(); + return value; +} + +function captionFraction(value: string | undefined): number | null { + if (value === undefined || value === "") return null; + const parsed = Number(value); + return Number.isFinite(parsed) && parsed >= 0 && parsed <= 1 ? parsed : null; +} + +function captionSeverity(value: string | undefined): "error" | "warning" | undefined { + if (value === undefined) return undefined; + if (value === "error" || value === "warning") return value; + throw captionZoneError(); +} + +function captionSeeks(value: string | undefined): number[] | undefined { + if (value === undefined) return undefined; + if (value === "") return []; + const values = value.split(",").map(captionFraction); + if (values.some((entry) => entry === null)) throw captionZoneError(); + return values.flatMap((entry) => (entry === null ? [] : entry)); +} + +function captionZoneError(): Error { + return new Error( + 'Invalid --caption-zone; use "x0=0;y0=.82;x1=1;y1=1[;severity=warning|error][;seek=.5,1]" with fractions from 0 to 1.', + ); +} + function positiveInteger(value: unknown, fallback: number): number { const parsed = parseInt(String(value ?? ""), 10); return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index c67ed809e3..568ccfbae4 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -81,6 +81,22 @@ return `${selectorFor(parent)} > ${element.tagName.toLowerCase()}:nth-of-type(${index})`; } + function uniqueSelectorFor(element) { + const preferred = selectorFor(element); + try { + if (document.querySelectorAll(preferred).length === 1) return preferred; + } catch { + // Fall through to a structural selector. + } + const parent = element.parentElement; + if (!parent) return preferred; + const siblings = Array.from(parent.children).filter( + (child) => child.tagName === element.tagName, + ); + const index = siblings.indexOf(element) + 1; + return `${uniqueSelectorFor(parent)} > ${element.tagName.toLowerCase()}:nth-of-type(${index})`; + } + function hasIgnoreFlag(element) { return !!element.closest("[data-layout-ignore], [data-layout-check='ignore']"); } @@ -98,6 +114,14 @@ return opacity; } + function hasOpacityBelow(element, floor) { + for (let current = element; current; current = current.parentElement) { + const parsed = Number.parseFloat(getComputedStyle(current).opacity || "1"); + if (Number.isFinite(parsed) && parsed < floor) return true; + } + return false; + } + // A clip-path can shrink an element's painted region to nothing (e.g. a // typewriter span pre-reveal at `inset(0 100% 0 0)`, or `circle(0px)`) while // its layout box, opacity, visibility and display all still read as present. @@ -142,9 +166,20 @@ return !paintsAnyProbePoint(element, rect); } - function isVisibleElement(element) { + function isVisibleElement(element, opacityFloor, probeClipPath) { if (IGNORE_TAGS.has(element.tagName)) return false; if (hasIgnoreFlag(element)) return false; + if ( + opacityFloor != null && + typeof element.checkVisibility === "function" && + !element.checkVisibility({ + opacityProperty: true, + visibilityProperty: true, + contentVisibilityAuto: true, + }) + ) { + return false; + } const style = getComputedStyle(element); if ( style.display === "none" || @@ -153,32 +188,57 @@ ) { return false; } - if (opacityChain(element) < 0.2) return false; + if ( + opacityFloor == null ? opacityChain(element) < 0.2 : hasOpacityBelow(element, opacityFloor) + ) { + return false; + } const rect = element.getBoundingClientRect(); if (rect.width <= 0.5 || rect.height <= 0.5) return false; - return !isClippedAway(element); + return probeClipPath === false || !isClippedAway(element); } - function textContentFor(element) { - return (element.innerText || element.textContent || "").replace(/\s+/g, " ").trim(); + function directTextNodes(element) { + return Array.from(element.childNodes).filter((node) => node.nodeType === 3); } - function hasOwnTextCandidate(element) { - const text = textContentFor(element); + function textContentFor(element, ownTextOnly) { + const content = ownTextOnly + ? directTextNodes(element) + .map((node) => node.textContent || "") + .join("") + : element.innerText || element.textContent || ""; + return content.replace(/\s+/g, " ").trim(); + } + + function hasOwnTextCandidate(element, directOnly) { + const text = textContentFor(element, directOnly); if (!text) return false; + if (directOnly) return true; for (const child of Array.from(element.children)) { if (isVisibleElement(child) && textContentFor(child)) return false; } return true; } - function textRectFor(element) { - const range = document.createRange(); - range.selectNodeContents(element); - const rects = Array.from(range.getClientRects()).filter( - (rect) => rect.width > 0.5 && rect.height > 0.5, - ); - range.detach(); + function textClientRects(element, directOnly) { + const subjects = directOnly ? directTextNodes(element) : [element]; + const rects = []; + for (const subject of subjects) { + const range = document.createRange(); + range.selectNodeContents(subject); + rects.push( + ...Array.from(range.getClientRects()).filter( + (rect) => rect.width > 0.5 && rect.height > 0.5, + ), + ); + range.detach(); + } + return rects; + } + + function textRectFor(element, directOnly) { + const rects = textClientRects(element, directOnly); if (rects.length === 0) return null; const union = rects.reduce( @@ -602,6 +662,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 @@ -703,6 +764,70 @@ }; } + 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 +837,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) { diff --git a/packages/cli/src/commands/layout-audit.browser.test.ts b/packages/cli/src/commands/layout-audit.browser.test.ts index 061e18da99..01871c5a61 100644 --- a/packages/cli/src/commands/layout-audit.browser.test.ts +++ b/packages/cli/src/commands/layout-audit.browser.test.ts @@ -15,11 +15,20 @@ interface RectInput { height: number; } +afterEach(() => { + vi.restoreAllMocks(); + document.body.innerHTML = ""; + Reflect.deleteProperty(document, "elementFromPoint"); + Reflect.deleteProperty(window, "__hyperframesLayoutAudit"); + clearGeometryCollector(); +}); + describe("layout-audit.browser", () => { afterEach(() => { vi.restoreAllMocks(); document.body.innerHTML = ""; delete (window as unknown as { __hyperframesLayoutAudit?: unknown }).__hyperframesLayoutAudit; + clearGeometryCollector(); }); it("uses authored canvas dimensions when the root bounding rect is degenerate", () => { @@ -135,6 +144,206 @@ describe("layout-audit.browser", () => { expect(runAudit().some((issue) => issue.code === "text_box_overflow")).toBe(true); }); + + it("keeps auditing visible descendants beyond the second element", () => { + document.body.innerHTML = ` +
+
+
+
+
Late visible copy
+
+ `; + installGeometry({ + root: rect({ left: 0, top: 0, width: 640, height: 360 }), + late: rect({ left: 700, top: 100, width: 140, height: 40 }), + text: rect({ left: 700, top: 100, width: 140, height: 40 }), + }); + installAuditScript(); + + expect(runAudit()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: "canvas_overflow", selector: "#late" }), + ]), + ); + }); +}); + +it("is inert unless text or media candidates are explicitly requested", () => { + document.body.innerHTML = ` +
+
Visible copy
+
+ `; + installGeometry({ + root: rect({ left: 0, top: 0, width: 640, height: 360 }), + copy: rect({ left: 100, top: 100, width: 200, height: 40 }), + text: rect({ left: 100, top: 100, width: 200, height: 40 }), + }); + installAuditScript(); + + expect(runGeometryCandidates({ text: false, media: false, tolerance: 2 })).toEqual([]); +}); + +it("returns own-text rects and media overflow while excluding caption layers", () => { + document.body.innerHTML = ` +
+
+
Own copy Nested
+ + +
+

Authored captions

+
+ `; + installGeometry({ + root: rect({ left: 0, top: 0, width: 640, height: 360 }), + copy: rect({ left: 100, top: 260, width: 180, height: 40 }), + headline: rect({ left: 100, top: 260, width: 180, height: 40 }), + nested: rect({ left: 220, top: 260, width: 60, height: 40 }), + image: rect({ left: 600, top: 40, width: 200, height: 100 }), + vector: rect({ left: -130, top: 160, width: 100, height: 100 }), + caption: rect({ left: 200, top: 300, width: 240, height: 40 }), + text: rect({ left: 100, top: 260, width: 100, height: 40 }), + }); + installAuditScript(); + + const candidates = runGeometryCandidates({ text: true, media: true, tolerance: 2 }); + + expect(candidates).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "text", + tag: "div", + text: "Own copy", + selector: "#copy", + sourceFile: "scenes/hero.html", + rect: { left: 100, top: 260, right: 200, bottom: 300, width: 100, height: 40 }, + elementRect: { left: 100, top: 260, right: 280, bottom: 300, width: 180, height: 40 }, + }), + expect.objectContaining({ + kind: "media", + tag: "img", + selector: "#image", + overflow: { right: 160 }, + }), + expect.objectContaining({ + kind: "media", + tag: "svg", + selector: "#vector", + overflow: { left: 130 }, + }), + ]), + ); + expect(candidates.some((candidate) => candidate.selector === "#caption")).toBe(false); +}); + +it("scans body-level composition siblings and includes a media boundary root", () => { + document.body.innerHTML = ` + +
+

Portal copy

+
+ + `; + installGeometry({ + root: rect({ left: 0, top: 0, width: 640, height: 360 }), + "portal-copy": rect({ left: 100, top: 260, width: 180, height: 40 }), + "portal-image": rect({ left: 600, top: 80, width: 180, height: 100 }), + text: rect({ left: 100, top: 260, width: 180, height: 40 }), + }); + installAuditScript(); + + const candidates = runGeometryCandidates({ text: true, media: true, tolerance: 2 }); + + expect(candidates.map((candidate) => candidate.selector)).toEqual( + expect.arrayContaining(["#boundary", "#portal-copy", "#portal-image"]), + ); +}); + +it("returns unique structural selectors for repeated class-only media", () => { + document.body.innerHTML = ` +
+ + +
+ `; + installGeometry({ + root: rect({ left: 0, top: 0, width: 640, height: 360 }), + "": rect({ left: 100, top: 100, width: 100, height: 100 }), + }); + installAuditScript(); + + const candidates = runGeometryCandidates({ text: false, media: true, tolerance: 2 }); + const images = Array.from(document.querySelectorAll("img")); + + expect(candidates).toHaveLength(2); + expect(new Set(candidates.map((candidate) => candidate.selector)).size).toBe(2); + expect(document.querySelector(candidates[0]?.selector ?? "")).toBe(images[0]); + expect(document.querySelector(candidates[1]?.selector ?? "")).toBe(images[1]); +}); + +it("keeps visible clip-path text when pointer events do not participate in hit testing", () => { + document.body.innerHTML = ` +
+

Visible clipped copy

+
+ `; + installGeometry( + { + root: rect({ left: 0, top: 0, width: 640, height: 360 }), + "clipped-copy": rect({ left: 100, top: 100, width: 200, height: 40 }), + text: rect({ left: 100, top: 100, width: 200, height: 40 }), + }, + { "clipped-copy": { clipPath: "inset(0 10% 0 0)", pointerEvents: "none" } }, + ); + Reflect.set( + document, + "elementFromPoint", + vi.fn(() => document.getElementById("root")), + ); + installAuditScript(); + + const candidates = runGeometryCandidates({ text: true, media: false, tolerance: 2 }); + + expect(candidates).toEqual( + expect.arrayContaining([expect.objectContaining({ selector: "#clipped-copy" })]), + ); +}); + +it("uses the bridge opacity floor across the ancestor chain", () => { + document.body.innerHTML = ` +
+

Hidden copy

+

Visible copy

+

Stacked opacity copy

+
+ `; + installGeometry( + { + root: rect({ left: 0, top: 0, width: 640, height: 360 }), + "faint-parent": rect({ left: 40, top: 40, width: 200, height: 40 }), + "hidden-copy": rect({ left: 40, top: 40, width: 200, height: 40 }), + "soft-parent": rect({ left: 40, top: 120, width: 200, height: 40 }), + "visible-copy": rect({ left: 40, top: 120, width: 200, height: 40 }), + "stacked-parent": rect({ left: 40, top: 200, width: 200, height: 40 }), + "stacked-copy": rect({ left: 40, top: 200, width: 200, height: 40 }), + text: rect({ left: 40, top: 120, width: 200, height: 40 }), + }, + { + "faint-parent": { opacity: "0.04" }, + "soft-parent": { opacity: "0.1" }, + "stacked-parent": { opacity: "0.2" }, + "stacked-copy": { opacity: "0.2" }, + }, + ); + installAuditScript(); + + const candidates = runGeometryCandidates({ text: true, media: false, tolerance: 2 }); + + expect(candidates.some((candidate) => candidate.selector === "#hidden-copy")).toBe(false); + expect(candidates.some((candidate) => candidate.selector === "#visible-copy")).toBe(true); + expect(candidates.some((candidate) => candidate.selector === "#stacked-copy")).toBe(true); }); describe("layout-audit.browser content overlap", () => { @@ -143,6 +352,7 @@ describe("layout-audit.browser content overlap", () => { document.body.innerHTML = ""; delete (document as unknown as { elementFromPoint?: unknown }).elementFromPoint; delete (window as unknown as { __hyperframesLayoutAudit?: unknown }).__hyperframesLayoutAudit; + clearGeometryCollector(); }); it("flags two solid text blocks that overlap", () => { @@ -404,6 +614,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", () => { @@ -622,7 +833,10 @@ function runAudit(): Array<{ return audit({ time: 1, tolerance: 2 }); } -function installGeometry(rects: Record): void { +function installGeometry( + rects: Record, + styleOverrides: Record> = {}, +): void { vi.spyOn(window, "getComputedStyle").mockImplementation((element) => { const el = element as Element; const isBubble = el.id === "bubble"; @@ -648,6 +862,7 @@ function installGeometry(rects: Record): void { paddingBottom: isBubble ? "16px" : "0px", paddingLeft: isBubble ? "16px" : "0px", fontSize: "36px", + ...styleOverrides[el.id], } as unknown as CSSStyleDeclaration; }); @@ -678,6 +893,41 @@ function installGeometry(rects: Record): void { }); } +interface GeometryCandidateResult { + kind: "text" | "media"; + tag: string; + text: string; + selector: string; + sourceFile: string; + rect: Record; + elementRect: Record; + overflow?: Record; +} + +declare global { + interface Window { + __hyperframesGeometryCandidates?: (options: { + text: boolean; + media: boolean; + tolerance: number; + }) => GeometryCandidateResult[]; + } +} + +function runGeometryCandidates(options: { + text: boolean; + media: boolean; + tolerance: number; +}): GeometryCandidateResult[] { + const collector = window.__hyperframesGeometryCandidates; + if (!collector) throw new Error("Geometry collector was not installed"); + return collector(options); +} + +function clearGeometryCollector(): void { + delete window.__hyperframesGeometryCandidates; +} + function rect({ left, top, width, height }: RectInput): DOMRect { return { left, diff --git a/packages/cli/src/utils/checkBrowser.test.ts b/packages/cli/src/utils/checkBrowser.test.ts new file mode 100644 index 0000000000..1306a30c32 --- /dev/null +++ b/packages/cli/src/utils/checkBrowser.test.ts @@ -0,0 +1,110 @@ +// @vitest-environment happy-dom +import { afterEach, expect, it, vi } from "vitest"; +import { + openSettledCompositionPage, + type OpenSettledCompositionPageOptions, +} from "../capture/captureCompositionFrame.js"; +import { DEFAULT_CHECK_OPTIONS, runAuditGrid } from "./checkPipeline.js"; +import { runBrowserCheck } from "./checkBrowser.js"; +import type { ProjectDir } from "./project.js"; + +const mocks = vi.hoisted(() => ({ + serverClose: vi.fn(async () => undefined), +})); + +vi.mock("@hyperframes/core/compiler", () => ({ + bundleToSingleHtml: vi.fn(async () => ""), +})); + +vi.mock("../capture/captureCompositionFrame.js", () => ({ + openSettledCompositionPage: vi.fn(), + resolveCliChromeGpuMode: vi.fn(() => "hardware"), + seekCompositionTimeline: vi.fn(async () => undefined), + waitForPreferredSeekTarget: vi.fn(async () => undefined), +})); + +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(); + document.body.innerHTML = ""; + Reflect.deleteProperty(window, "__hyperframesGeometryCandidates"); + Reflect.deleteProperty(window, "__hyperframesLayoutAudit"); +}); + +it("carries raw browser geometry through the page driver and pipeline", async () => { + document.body.innerHTML = ` +
+
+ +
+
+ `; + Object.defineProperty(window, "innerWidth", { configurable: true, value: 640 }); + Object.defineProperty(window, "innerHeight", { configurable: true, value: 360 }); + installRects(); + const page = fakePage(); + 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 }; + }, + ); + + 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(mocks.serverClose).toHaveBeenCalledOnce(); +}); + +function installRects(): void { + const root = document.querySelector("[data-composition-id]"); + const image = document.querySelector("#hero-image"); + if (!root || !image) throw new Error("Geometry fixture failed to mount"); + vi.spyOn(root, "getBoundingClientRect").mockReturnValue(new DOMRect(0, 0, 640, 360)); + vi.spyOn(image, "getBoundingClientRect").mockReturnValue(new DOMRect(600, 80, 200, 100)); +} + +function fakePage() { + return Object.assign(Object.create(null), { + on: vi.fn(), + addScriptTag: vi.fn(async ({ content }: { content: string }) => { + window.eval(content); + }), + evaluate: vi.fn(async (callback: unknown, ...args: unknown[]) => { + if (typeof callback !== "function") throw new Error("Expected an evaluate callback"); + return Reflect.apply(callback, window, args); + }), + }); +} diff --git a/packages/cli/src/utils/checkBrowser.ts b/packages/cli/src/utils/checkBrowser.ts index 098d97a225..ca8152d0df 100644 --- a/packages/cli/src/utils/checkBrowser.ts +++ b/packages/cli/src/utils/checkBrowser.ts @@ -18,10 +18,12 @@ import type { CheckBbox, CheckBrowserResult, CheckFinding, + CheckGeometryCandidate, CheckOptions, CheckSeverity, ContrastAuditEntry, ContrastCapture, + GeometryCandidateRequest, MotionSpecResolution, RunAuditGrid, } from "./checkTypes.js"; @@ -193,6 +195,7 @@ function createPageDriver(page: Page, setTime: (time: number) => void): CheckAud await seekCompositionTimeline(page, time, SEEK_OPTIONS); }, collectLayout: (time, tolerance) => collectLayout(page, time, tolerance), + collectGeometryCandidates: (time, request) => collectGeometryCandidates(page, time, request), collectMotionFrame: (time, selectors, scopes) => collectMotionFrame(page, time, selectors, scopes), anchorMotionIssues: (issues) => anchorLayoutIssues(page, issues), @@ -304,6 +307,24 @@ async function collectLayout( return anchorLayoutIssues(page, raw.flatMap(parseLayoutIssue)); } +async function collectGeometryCandidates( + page: Page, + time: number, + request: GeometryCandidateRequest, +): Promise { + try { + const raw = await page.evaluate((options: GeometryCandidateRequest) => { + const collect = Reflect.get(window, "__hyperframesGeometryCandidates"); + if (typeof collect !== "function") return []; + const result = Reflect.apply(collect, window, [options]); + return Array.isArray(result) ? result : []; + }, request); + return raw.flatMap((value) => parseGeometryCandidate(value, time)); + } catch { + return []; + } +} + async function findAmbiguousSelectors( page: Page, selectors: string[], @@ -590,6 +611,54 @@ function parseLayoutIssue(value: unknown): LayoutIssue[] { return [issue]; } +function parseGeometryCandidate(value: unknown, time: number): CheckGeometryCandidate[] { + if (!isRecord(value)) return []; + const rect = parseRect(Reflect.get(value, "rect")); + const elementRect = parseRect(Reflect.get(value, "elementRect")); + if (!rect || !elementRect) return []; + const identity = parseGeometryIdentity(value); + if (!identity) return []; + const anchor = parseGeometryAnchor(value, rect, time); + if (!anchor) return []; + const candidate: CheckGeometryCandidate = { ...identity, ...anchor, rect, elementRect }; + const overflow = parseOverflow(Reflect.get(value, "overflow")); + if (overflow) candidate.overflow = overflow; + return [candidate]; +} + +function parseGeometryIdentity( + value: Record, +): Pick | null { + const kindValue = Reflect.get(value, "kind"); + const kind = kindValue === "text" || kindValue === "media" ? kindValue : null; + if (!kind) return null; + const tag = stringValue(value, "tag"); + if (!tag) return null; + const text = stringValue(value, "text"); + return text === null ? null : { kind, tag, text }; +} + +function parseGeometryAnchor( + value: Record, + rect: LayoutRect, + time: number, +): CheckAnchor | null { + const selector = stringValue(value, "selector"); + if (!selector) return null; + const sourceFile = stringValue(value, "sourceFile"); + if (!sourceFile) return null; + const dataAttributes = stringRecord(Reflect.get(value, "dataAttributes")); + return dataAttributes + ? { + selector, + sourceFile, + dataAttributes, + bbox: rectToBbox(rect), + time, + } + : null; +} + function assignOptionalLayoutFields(issue: LayoutIssue, value: Record): void { assignOptionalString(issue, value, "containerSelector"); assignOptionalString(issue, value, "text"); @@ -721,6 +790,8 @@ const LAYOUT_ISSUE_CODES: readonly LayoutIssueCode[] = [ "container_overflow", "content_overlap", "text_occluded", + "caption_zone_collision", + "frame_out_of_frame", "motion_appears_late", "motion_out_of_order", "motion_off_frame", diff --git a/packages/cli/src/utils/checkPipeline.ts b/packages/cli/src/utils/checkPipeline.ts index 854cb72c4e..07b65f2bbd 100644 --- a/packages/cli/src/utils/checkPipeline.ts +++ b/packages/cli/src/utils/checkPipeline.ts @@ -12,7 +12,12 @@ import { type LayoutIssue, type LayoutRect, } from "./layoutAudit.js"; -import { collectSamplingTargets, evaluateMotion, type MotionFrame } from "./motionAudit.js"; +import { + collectSamplingTargets, + evaluateMotion, + type Canvas, + type MotionFrame, +} from "./motionAudit.js"; import { findMotionSpec, readMotionSpec } from "./motionSpec.js"; import { normalizeErrorMessage } from "./errorMessage.js"; import { @@ -29,12 +34,14 @@ import type { CheckContrastFinding, CheckDependencies, CheckFinding, + CheckGeometryCandidate, CheckOptions, CheckReport, CheckScreenshot, CheckSection, CheckSeverity, ContrastAuditEntry, + GeometryCandidateRequest, MotionSpecResolution, } from "./checkTypes.js"; @@ -55,6 +62,9 @@ export type { 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, @@ -87,11 +97,23 @@ function buildMotionSampleTimes(duration: number): number[] { 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, @@ -109,16 +131,25 @@ async function buildSampleGrid( cap: options.maxTransitionSamples, }) : { times: [], dropped: 0 }; - const layoutSamples = mergeSampleTimes(baseSamples, transitions.times); + 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(layoutSamples) : [], + contrastSamples: options.contrast ? selectContrastTimes(auditSamples) : [], }; } @@ -151,6 +182,156 @@ interface GridSamples { screenshots: CheckScreenshot[]; } +interface GeometrySeen { + caption: Set; + frame: Set; +} + +function geometryRequest( + time: number, + grid: SampleGrid, + options: CheckOptions, +): GeometryCandidateRequest | null { + const text = grid.captionSamples.includes(time); + const media = grid.frameSamples.includes(time); + if (!text && !media) return null; + const configuredTolerance = options.frameCheck?.tol; + const tolerance = typeof configuredTolerance === "number" ? configuredTolerance : 2; + return { text, media, tolerance }; +} + +function candidateIsSized(candidate: CheckGeometryCandidate, canvas: Canvas): boolean { + if (candidate.elementRect.width < 4 || candidate.elementRect.height < 4) return false; + return !( + candidate.elementRect.width >= 0.95 * canvas.width && + candidate.elementRect.height >= 0.95 * canvas.height + ); +} + +function geometryIssueAnchor(candidate: CheckGeometryCandidate, time: number) { + return { + selector: candidate.selector, + dataAttributes: candidate.dataAttributes, + sourceFile: candidate.sourceFile, + bbox: candidate.bbox, + time, + rect: candidate.rect, + }; +} + +function captionFinding( + candidate: CheckGeometryCandidate, + options: CheckOptions, + canvas: Canvas, + time: number, +): { key: string; issue: AnchoredLayoutIssue } | null { + const zone = options.captionZone; + if (!zone || candidate.kind !== "text" || !candidateIsSized(candidate, canvas)) return null; + const cx = candidate.rect.left + candidate.rect.width / 2; + const cy = candidate.rect.top + candidate.rect.height / 2; + const inside = + cx >= zone.x0 * canvas.width && + cx <= zone.x1 * canvas.width && + cy >= zone.y0 * canvas.height && + cy <= zone.y1 * canvas.height; + if (!inside) return null; + const text = candidate.text.slice(0, 48); + const pctFromBottom = Math.round(((canvas.height - cy) / canvas.height) * 100); + return { + key: `${candidate.tag}|${text}`, + issue: { + ...geometryIssueAnchor(candidate, time), + code: "caption_zone_collision", + severity: zone.severity === "error" ? "error" : "warning", + text, + message: `<${candidate.tag}> "${text}" is centred in the reserved caption band (~${pctFromBottom}% up from the bottom).`, + fixHint: "Keep main content outside the configured caption band.", + }, + }; +} + +function maxOverflow(candidate: CheckGeometryCandidate): number { + if (!candidate.overflow) return 0; + return Math.max( + candidate.overflow.left ?? 0, + candidate.overflow.top ?? 0, + candidate.overflow.right ?? 0, + candidate.overflow.bottom ?? 0, + ); +} + +function overflowMessage(candidate: CheckGeometryCandidate): string { + const overflow = candidate.overflow ?? {}; + const edges: string[] = []; + if (overflow.left) edges.push(`${overflow.left}px past the left`); + if (overflow.top) edges.push(`${overflow.top}px past the top`); + if (overflow.right) edges.push(`${overflow.right}px past the right`); + if (overflow.bottom) edges.push(`${overflow.bottom}px past the bottom`); + return `<${candidate.tag}> "${candidate.text.slice(0, 48)}" spills outside the frame (${edges.join(", ")}).`; +} + +function frameFinding( + candidate: CheckGeometryCandidate, + options: CheckOptions, + canvas: Canvas, + time: number, +): { key: string; issue: AnchoredLayoutIssue } | null { + if (!options.frameCheck || candidate.kind !== "media" || !candidateIsSized(candidate, canvas)) { + return null; + } + const floor = Math.max( + FRAME_BREACH_FLOOR_PX, + FRAME_BREACH_FLOOR_FRACTION * Math.min(canvas.width, canvas.height), + ); + if (maxOverflow(candidate) < floor) return null; + const text = candidate.text.slice(0, 48); + return { + key: `${candidate.tag}|${text}|${Math.round(candidate.rect.left)},${Math.round(candidate.rect.top)}`, + issue: { + ...geometryIssueAnchor(candidate, time), + code: "frame_out_of_frame", + severity: options.frameCheck.severity === "error" ? "error" : "warning", + text, + overflow: candidate.overflow, + message: overflowMessage(candidate), + fixHint: "Keep media within the composition frame's safe area.", + }, + }; +} + +function appendGeometryFinding( + result: { key: string; issue: AnchoredLayoutIssue } | null, + seen: Set, + issues: AnchoredLayoutIssue[], +): void { + if (!result || seen.has(result.key)) return; + seen.add(result.key); + issues.push(result.issue); +} + +async function collectGeometryAt( + driver: CheckAuditDriver, + options: CheckOptions, + grid: SampleGrid, + canvas: Canvas, + time: number, + seen: GeometrySeen, +): Promise { + const request = geometryRequest(time, grid, options); + if (!request) return []; + const candidates = await driver.collectGeometryCandidates(time, request); + const issues: AnchoredLayoutIssue[] = []; + for (const candidate of candidates) { + if (request.text) { + appendGeometryFinding(captionFinding(candidate, options, canvas, time), seen.caption, issues); + } + if (request.media) { + appendGeometryFinding(frameFinding(candidate, options, canvas, time), seen.frame, issues); + } + } + return issues; +} + async function collectGridSamples( driver: CheckAuditDriver, options: CheckOptions, @@ -160,6 +341,9 @@ async function collectGridSamples( 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: [], @@ -171,6 +355,11 @@ async function collectGridSamples( if (layoutSet.has(time)) { collected.layoutIssues.push(...(await driver.collectLayout(time, options.tolerance))); } + if (canvas) { + collected.layoutIssues.push( + ...(await collectGeometryAt(driver, options, grid, canvas, time, geometrySeen)), + ); + } if (motionSet.has(time)) { collected.motionFrames.push( await driver.collectMotionFrame(time, motion.selectors, motion.livenessScopes), diff --git a/packages/cli/src/utils/checkTypes.ts b/packages/cli/src/utils/checkTypes.ts index e129793f7e..2c45dde925 100644 --- a/packages/cli/src/utils/checkTypes.ts +++ b/packages/cli/src/utils/checkTypes.ts @@ -1,5 +1,5 @@ import type { ProjectLintResult } from "./lintProject.js"; -import type { LayoutIssue } from "./layoutAudit.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"; @@ -16,6 +16,23 @@ export interface CheckOptions { 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"; @@ -70,6 +87,21 @@ export interface ContrastCapture { pngBase64: string; } +export interface GeometryCandidateRequest { + text: boolean; + media: boolean; + tolerance: number; +} + +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 } @@ -83,6 +115,10 @@ export interface CheckAuditDriver { findAmbiguousSelectors(selectors: string[]): Promise; seek(time: number): Promise; collectLayout(time: number, tolerance: number): Promise; + collectGeometryCandidates( + time: number, + request: GeometryCandidateRequest, + ): Promise; collectMotionFrame( time: number, selectors: string[], diff --git a/packages/cli/src/utils/layoutAudit.ts b/packages/cli/src/utils/layoutAudit.ts index 70b00deb9c..e006bf673c 100644 --- a/packages/cli/src/utils/layoutAudit.ts +++ b/packages/cli/src/utils/layoutAudit.ts @@ -16,6 +16,8 @@ export type LayoutIssueCode = | "container_overflow" | "content_overlap" | "text_occluded" + | "caption_zone_collision" + | "frame_out_of_frame" // Motion-verification findings (#1437) — evaluated against the seeked timeline. | "motion_appears_late" | "motion_out_of_order" @@ -152,6 +154,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); @@ -230,9 +233,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); From 3a02942a03edd42ffde353e1e1d9331a045283c0 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 10 Jul 2026 01:37:08 -0400 Subject: [PATCH 04/16] feat(cli): run-ID telemetry correlation and check breakdown event HYPERFRAMES_RUN_ID (trimmed, 128-char cap) attaches as run_id to the generic cli_command / cli_command_result events, absent when unset, so an orchestrator setting it per design element can group a verify loop's invocations in analytics. check additionally emits one check_report event per invocation (including lint-short-circuited and failing runs): gate booleans, per-class error/warning counts, launch/seek/contrast phase timings, sample counts, ok and exit code. Timings stay internal; no command output changes. --- packages/cli/src/cli.ts | 13 +- packages/cli/src/commands/check.test.ts | 176 ++++++++++++++++-- .../src/commands/layout-audit.browser.test.ts | 3 + packages/cli/src/commands/validate.ts | 10 +- packages/cli/src/telemetry/events.test.ts | 145 +++++++++++++++ packages/cli/src/telemetry/events.ts | 61 +++++- packages/cli/src/telemetry/runId.test.ts | 73 ++++++++ packages/cli/src/telemetry/runId.ts | 12 ++ packages/cli/src/utils/checkBrowser.test.ts | 6 + packages/cli/src/utils/checkBrowser.ts | 3 + packages/cli/src/utils/checkPipeline.ts | 38 +++- packages/cli/src/utils/checkTypes.ts | 7 + 12 files changed, 526 insertions(+), 21 deletions(-) create mode 100644 packages/cli/src/telemetry/runId.test.ts create mode 100644 packages/cli/src/telemetry/runId.ts diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 2f3cc5d8ff..69c7281d74 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -101,6 +101,7 @@ try { import { defineCommand, runMain } from "citty"; import type { ArgsDef, CommandDef } from "citty"; +import { getRunId } from "./telemetry/runId.js"; import { reportCommandFailure, trackCommandFailures } from "./utils/command-failure-tracking.js"; const isHelp = process.argv.includes("--help") || process.argv.includes("-h"); @@ -196,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; @@ -211,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(); }); } @@ -242,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 @@ -265,6 +273,7 @@ process.on("exit", (code) => { success: code === 0 && !commandFailed, exitCode: code, durationMs: Date.now() - commandStart, + runId, }); _flushSync?.(); }); diff --git a/packages/cli/src/commands/check.test.ts b/packages/cli/src/commands/check.test.ts index 8e1fb3cbea..581610b726 100644 --- a/packages/cli/src/commands/check.test.ts +++ b/packages/cli/src/commands/check.test.ts @@ -1,6 +1,13 @@ import { runCommand } from "citty"; import { readFileSync } from "node:fs"; import { afterEach, describe, expect, it, vi } from "vitest"; + +const trackCheckReport = vi.fn(); +vi.mock("../telemetry/events.js", () => ({ + trackCheckReport: (...args: unknown[]) => trackCheckReport(...args), + trackCommandFailure: vi.fn(), +})); + import { contrastRatio, parseColorRGBA } from "./contrast-bg.js"; import { createCheckCommand } from "./check.js"; import { @@ -35,6 +42,7 @@ const ORIGINAL_EXIT_CODE = process.exitCode; afterEach(() => { process.exitCode = ORIGINAL_EXIT_CODE; + trackCheckReport.mockClear(); vi.restoreAllMocks(); }); @@ -212,6 +220,20 @@ function noMotion(): MotionSpecResolution { return { kind: "none" }; } +function heroMotionFrame(time: number, visibleAt: (time: number) => boolean) { + return { + time, + data: { + "#hero": { + rect: { left: 10, top: 20, right: 310, bottom: 100, width: 300, height: 80 }, + opacity: visibleAt(time) ? 1 : 0, + visible: visibleAt(time), + }, + }, + liveness: {}, + }; +} + function dependencies( driver: CheckAuditDriver, options: { @@ -772,17 +794,7 @@ describe("check pipeline", () => { }; const driver = fakeDriver({ getDuration: vi.fn(async () => 1), - collectMotionFrame: vi.fn(async (time: number) => ({ - time, - data: { - "#hero": { - rect: { left: 10, top: 20, right: 310, bottom: 100, width: 300, height: 80 }, - opacity: time >= 0.5 ? 1 : 0, - visible: time >= 0.5, - }, - }, - liveness: {}, - })), + collectMotionFrame: vi.fn(async (time: number) => heroMotionFrame(time, (t) => t >= 0.5)), }); const { report } = await runScenario(driver, {}, { motion }); @@ -856,6 +868,148 @@ describe("check pipeline", () => { }); }); +describe("check report telemetry", () => { + it("reports one clean run with every gate and sampled-point count", async () => { + const motion: MotionSpecResolution = { + kind: "valid", + path: "/project/index.motion.json", + spec: { duration: 1, assertions: [{ kind: "appearsBy", selector: "#hero", bySec: 1 }] }, + }; + const driver = fakeDriver({ + getDuration: vi.fn(async () => 1), + collectMotionFrame: vi.fn(async (time: number) => heroMotionFrame(time, () => true)), + collectContrast: vi.fn(async (time: number) => ({ + entries: [contrastEntry({ time, ratio: 7, wcagAA: true })], + pngBase64: PNG_BASE64, + })), + }); + + const { report } = await runScenario( + driver, + { + samples: 1, + captionZone: { x0: 0, y0: 0.8, x1: 1, y1: 1 }, + frameCheck: {}, + snapshots: true, + }, + { motion }, + ); + + expect(trackCheckReport).toHaveBeenCalledTimes(1); + expect(trackCheckReport).toHaveBeenCalledWith( + expect.objectContaining({ + contrastGate: true, + motionGate: true, + captionZoneGate: true, + frameCheckGate: true, + snapshotsGate: true, + gridPoints: 2, + contrastPoints: 1, + ok: true, + exitCode: 0, + }), + ); + expect(report.ok).toBe(true); + }); + + it("reports one failing contrast run with its section error count", async () => { + const collectContrast = vi.fn(async (time: number) => ({ + entries: time === 0.5 ? [contrastEntry()] : [], + pngBase64: PNG_BASE64, + })); + + const { report } = await runScenario(fakeDriver({ collectContrast })); + + expect(trackCheckReport).toHaveBeenCalledTimes(1); + expect(trackCheckReport).toHaveBeenCalledWith( + expect.objectContaining({ + ok: false, + exitCode: 1, + contrastErrors: report.contrast.errorCount, + }), + ); + expect(report.contrast.errorCount).toBe(1); + }); + + it("reports zero browser samples and timings after a lint short circuit", async () => { + const lint = lintWith( + "error", + "root_missing_composition_id", + "Root element needs data-composition-id.", + ); + + const { report, browser } = await runScenario(fakeDriver(), {}, { lint }); + + expect(browser).not.toHaveBeenCalled(); + expect(trackCheckReport).toHaveBeenCalledTimes(1); + expect(trackCheckReport).toHaveBeenCalledWith( + expect.objectContaining({ + ok: false, + exitCode: 1, + gridPoints: 0, + contrastPoints: 0, + launchSettleMs: 0, + seekLoopMs: 0, + contrastMs: 0, + }), + ); + expect(report.ok).toBe(false); + }); + + it("matches report counts for mixed findings across classes", async () => { + const lint = lintWith("warning", "lint_warning", "Lint warning."); + const driver = fakeDriver({ + collectLayout: vi.fn(async () => [layoutIssue(), layoutIssue("warning")]), + collectContrast: vi.fn(async () => ({ + entries: [contrastEntry()], + pngBase64: PNG_BASE64, + })), + }); + + const { report } = await runScenario( + driver, + { samples: 1 }, + { lint, runtime: [runtimeError()] }, + ); + + expect(trackCheckReport).toHaveBeenCalledTimes(1); + expect(trackCheckReport).toHaveBeenCalledWith( + expect.objectContaining({ + lintErrors: report.lint.errorCount, + lintWarnings: report.lint.warningCount, + runtimeErrors: report.runtime.errorCount, + runtimeWarnings: report.runtime.warningCount, + layoutErrors: report.layout.errorCount, + layoutWarnings: report.layout.warningCount, + motionErrors: report.motion.errorCount, + motionWarnings: report.motion.warningCount, + contrastErrors: report.contrast.errorCount, + contrastWarnings: report.contrast.warningCount, + }), + ); + expect(report.lint.warningCount).toBe(1); + expect(report.runtime.errorCount).toBe(1); + expect(report.layout.errorCount).toBe(1); + expect(report.layout.warningCount).toBe(1); + }); + + it("measures contrast work inside the overall seek loop", async () => { + vi.spyOn(Date, "now") + .mockReturnValueOnce(100) + .mockReturnValueOnce(105) + .mockReturnValueOnce(110) + .mockReturnValueOnce(120); + + const result = await runAuditGrid( + fakeDriver(), + { ...DEFAULT_CHECK_OPTIONS, samples: 1 }, + noMotion(), + ); + + expect(result.timings).toEqual({ launchSettleMs: 0, seekLoopMs: 20, contrastMs: 5 }); + }); +}); + describe("contrast candidate round-trip", () => { it("passes the browser script's raw candidates back to finish, never the normalized copies", () => { const source = checkBrowserSource(); diff --git a/packages/cli/src/commands/layout-audit.browser.test.ts b/packages/cli/src/commands/layout-audit.browser.test.ts index 01871c5a61..0f05e80772 100644 --- a/packages/cli/src/commands/layout-audit.browser.test.ts +++ b/packages/cli/src/commands/layout-audit.browser.test.ts @@ -837,6 +837,9 @@ function installGeometry( rects: Record, styleOverrides: Record> = {}, ): void { + // Style-fixture branching mirrors the audit's per-property reads; splitting + // it would scatter one mock across helpers. + // fallow-ignore-next-line complexity vi.spyOn(window, "getComputedStyle").mockImplementation((element) => { const el = element as Element; const isBubble = el.id === "bubble"; diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index a5b40a6886..0852dec60e 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -1,3 +1,8 @@ +// The media-metadata wait exists twice on purpose: once Node-side and once +// inside a page.evaluate() body, which is serialized into the browser and +// cannot import the Node helper. Line-level markers don't survive the clone +// window drifting as the file is edited, hence the file-level suppression. +// fallow-ignore-file code-duplication import { defineCommand } from "citty"; import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -110,8 +115,6 @@ export function raceMediaReady( ): Promise { if (Number.isFinite(el.duration) && el.duration > 0) return Promise.resolve(); return new Promise((resolve) => { - // Clones its in-page twin below; evaluate() bodies can't import Node helpers. - // fallow-ignore-next-line code-duplication const onReady = () => { el.removeEventListener("loadedmetadata", onReady); el.removeEventListener("error", onReady); @@ -156,9 +159,6 @@ async function auditClipDurations( nodes.map((el) => { if (Number.isFinite(el.duration) && el.duration > 0) return Promise.resolve(); return new Promise((resolve) => { - // fallow-ignore-next-line code-duplication - // Serialized twin of the Node-side metadata wait above. - // fallow-ignore-next-line code-duplication const cleanup = () => { el.removeEventListener("loadedmetadata", onReady); el.removeEventListener("error", onReady); diff --git a/packages/cli/src/telemetry/events.test.ts b/packages/cli/src/telemetry/events.test.ts index 5801d8f9be..68932d58b3 100644 --- a/packages/cli/src/telemetry/events.test.ts +++ b/packages/cli/src/telemetry/events.test.ts @@ -12,6 +12,9 @@ vi.mock("./config.js", () => ({ })); const { + trackCommand, + trackCommandResult, + trackCheckReport, trackRenderComplete, trackRenderError, trackRenderObservation, @@ -26,6 +29,148 @@ const { identifyUser, } = await import("./events.js"); +describe("command telemetry events", () => { + beforeEach(() => { + trackEvent.mockClear(); + }); + + it("includes run_id in cli_command when a run ID is provided", () => { + trackCommand("check", "run-123"); + + expect(trackEvent).toHaveBeenCalledWith("cli_command", { + command: "check", + run_id: "run-123", + }); + }); + + it("omits run_id from cli_command when no run ID is provided", () => { + trackCommand("check"); + + const properties = trackEvent.mock.lastCall?.[1]; + expect(properties).not.toHaveProperty("run_id"); + }); + + it("includes run_id in cli_command_result when a run ID is provided", () => { + trackCommandResult({ + command: "check", + success: true, + exitCode: 0, + durationMs: 42, + runId: "run-123", + }); + + expect(trackEvent).toHaveBeenCalledWith("cli_command_result", { + command: "check", + success: true, + exit_code: 0, + duration_ms: 42, + run_id: "run-123", + }); + }); + + it("omits run_id from cli_command_result when no run ID is provided", () => { + trackCommandResult({ + command: "check", + success: false, + exitCode: 1, + durationMs: 42, + }); + + const properties = trackEvent.mock.lastCall?.[1]; + expect(properties).not.toHaveProperty("run_id"); + }); +}); + +describe("trackCheckReport", () => { + beforeEach(() => { + trackEvent.mockClear(); + }); + + it("emits the check breakdown with snake_case properties and a run ID", () => { + trackCheckReport({ + contrastGate: true, + motionGate: false, + captionZoneGate: true, + frameCheckGate: false, + snapshotsGate: true, + lintErrors: 1, + lintWarnings: 2, + runtimeErrors: 3, + runtimeWarnings: 4, + layoutErrors: 5, + layoutWarnings: 6, + motionErrors: 7, + motionWarnings: 8, + contrastErrors: 9, + contrastWarnings: 10, + launchSettleMs: 11, + seekLoopMs: 12, + contrastMs: 13, + gridPoints: 14, + contrastPoints: 15, + ok: false, + exitCode: 1, + runId: "run-123", + }); + + expect(trackEvent).toHaveBeenCalledWith("check_report", { + gate_contrast: true, + gate_motion: false, + gate_caption_zone: true, + gate_frame_check: false, + gate_snapshots: true, + lint_errors: 1, + lint_warnings: 2, + runtime_errors: 3, + runtime_warnings: 4, + layout_errors: 5, + layout_warnings: 6, + motion_errors: 7, + motion_warnings: 8, + contrast_errors: 9, + contrast_warnings: 10, + launch_settle_ms: 11, + seek_loop_ms: 12, + contrast_ms: 13, + grid_points: 14, + contrast_points: 15, + ok: false, + exit_code: 1, + run_id: "run-123", + }); + }); + + it("omits run_id when no run ID is provided", () => { + trackCheckReport({ + contrastGate: false, + motionGate: false, + captionZoneGate: false, + frameCheckGate: false, + snapshotsGate: false, + lintErrors: 0, + lintWarnings: 0, + runtimeErrors: 0, + runtimeWarnings: 0, + layoutErrors: 0, + layoutWarnings: 0, + motionErrors: 0, + motionWarnings: 0, + contrastErrors: 0, + contrastWarnings: 0, + launchSettleMs: 0, + seekLoopMs: 0, + contrastMs: 0, + gridPoints: 0, + contrastPoints: 0, + ok: true, + exitCode: 0, + }); + + const properties = trackEvent.mock.lastCall?.[1]; + expect(properties).not.toHaveProperty("run_id"); + }); +}); + describe("render telemetry events", () => { beforeEach(() => { trackEvent.mockClear(); diff --git a/packages/cli/src/telemetry/events.ts b/packages/cli/src/telemetry/events.ts index 89462246ae..ab91183f94 100644 --- a/packages/cli/src/telemetry/events.ts +++ b/packages/cli/src/telemetry/events.ts @@ -98,8 +98,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, + ...(runId !== undefined ? { run_id: runId } : {}), + }); } export function trackRenderComplete( @@ -544,11 +547,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, + ...(props.runId !== undefined ? { run_id: 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, + ...(props.runId !== undefined ? { run_id: props.runId } : {}), }); } diff --git a/packages/cli/src/telemetry/runId.test.ts b/packages/cli/src/telemetry/runId.test.ts new file mode 100644 index 0000000000..6a97b0726b --- /dev/null +++ b/packages/cli/src/telemetry/runId.test.ts @@ -0,0 +1,73 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const originalRunId = process.env["HYPERFRAMES_RUN_ID"]; + +async function loadGetRunId() { + const { getRunId } = await import("./runId.js"); + return getRunId; +} + +describe("getRunId", () => { + beforeEach(() => { + delete process.env["HYPERFRAMES_RUN_ID"]; + vi.resetModules(); + }); + + afterEach(() => { + if (originalRunId === undefined) delete process.env["HYPERFRAMES_RUN_ID"]; + else process.env["HYPERFRAMES_RUN_ID"] = originalRunId; + vi.resetModules(); + }); + + it("returns undefined when HYPERFRAMES_RUN_ID is unset", async () => { + const getRunId = await loadGetRunId(); + + expect(getRunId()).toBeUndefined(); + }); + + it("returns undefined when HYPERFRAMES_RUN_ID contains only whitespace", async () => { + process.env["HYPERFRAMES_RUN_ID"] = " \t\n "; + const getRunId = await loadGetRunId(); + + expect(getRunId()).toBeUndefined(); + }); + + it("returns a normal HYPERFRAMES_RUN_ID value", async () => { + process.env["HYPERFRAMES_RUN_ID"] = "run-123"; + const getRunId = await loadGetRunId(); + + expect(getRunId()).toBe("run-123"); + }); + + it("truncates HYPERFRAMES_RUN_ID to exactly 128 characters", async () => { + process.env["HYPERFRAMES_RUN_ID"] = "x".repeat(160); + const getRunId = await loadGetRunId(); + + expect(getRunId()).toBe("x".repeat(128)); + expect(getRunId()).toHaveLength(128); + }); + + it("trims whitespace around a real HYPERFRAMES_RUN_ID value", async () => { + process.env["HYPERFRAMES_RUN_ID"] = " run-123 \n"; + const getRunId = await loadGetRunId(); + + expect(getRunId()).toBe("run-123"); + }); + + it("memoizes the first environment read", async () => { + process.env["HYPERFRAMES_RUN_ID"] = "first-run"; + const getRunId = await loadGetRunId(); + + expect(getRunId()).toBe("first-run"); + process.env["HYPERFRAMES_RUN_ID"] = "second-run"; + expect(getRunId()).toBe("first-run"); + }); + + it("memoizes an initial undefined environment read", async () => { + const getRunId = await loadGetRunId(); + + expect(getRunId()).toBeUndefined(); + process.env["HYPERFRAMES_RUN_ID"] = "later-run"; + expect(getRunId()).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/telemetry/runId.ts b/packages/cli/src/telemetry/runId.ts new file mode 100644 index 0000000000..5c5e307fb1 --- /dev/null +++ b/packages/cli/src/telemetry/runId.ts @@ -0,0 +1,12 @@ +let resolved = false; +let runId: string | undefined; + +export function getRunId(): string | undefined { + if (!resolved) { + const value = process.env["HYPERFRAMES_RUN_ID"]?.trim().slice(0, 128); + runId = value ? value : undefined; + resolved = true; + } + + return runId; +} diff --git a/packages/cli/src/utils/checkBrowser.test.ts b/packages/cli/src/utils/checkBrowser.test.ts index 1306a30c32..63619aa67a 100644 --- a/packages/cli/src/utils/checkBrowser.test.ts +++ b/packages/cli/src/utils/checkBrowser.test.ts @@ -44,6 +44,11 @@ afterEach(() => { }); it("carries raw browser geometry through the page driver and pipeline", async () => { + vi.spyOn(Date, "now") + .mockReturnValueOnce(100) + .mockReturnValueOnce(160) + .mockReturnValueOnce(200) + .mockReturnValueOnce(240); document.body.innerHTML = `
@@ -85,6 +90,7 @@ it("carries raw browser geometry through the page driver and pipeline", async () time: 5, }), ]); + expect(result.timings).toEqual({ launchSettleMs: 60, seekLoopMs: 40, contrastMs: 0 }); expect(mocks.serverClose).toHaveBeenCalledOnce(); }); diff --git a/packages/cli/src/utils/checkBrowser.ts b/packages/cli/src/utils/checkBrowser.ts index ca8152d0df..edd34746cb 100644 --- a/packages/cli/src/utils/checkBrowser.ts +++ b/packages/cli/src/utils/checkBrowser.ts @@ -93,6 +93,7 @@ export async function runBrowserCheck( let chromeBrowser: import("puppeteer-core").Browser | undefined; try { + const launchSettleStart = Date.now(); const session = await openSettledCompositionPage(html, server.url, { renderReadyTimeoutMs: options.timeout, renderReadyWarningSuffix: "checking the current page state", @@ -104,12 +105,14 @@ export async function runBrowserCheck( await waitForPreferredSeekTarget(page, 500); const rootAnchor = await resolveRootAnchor(page); + const launchSettleMs = Date.now() - launchSettleStart; const driver = createPageDriver(page, (time) => { currentTime = time; }); const result = await runGrid(driver, options, motion); return { ...result, + timings: { ...result.timings, launchSettleMs }, runtimeFindings: drafts.map((draft) => runtimeFinding(draft, rootAnchor)), }; } finally { diff --git a/packages/cli/src/utils/checkPipeline.ts b/packages/cli/src/utils/checkPipeline.ts index 07b65f2bbd..e60fd559bb 100644 --- a/packages/cli/src/utils/checkPipeline.ts +++ b/packages/cli/src/utils/checkPipeline.ts @@ -1,5 +1,7 @@ import { mkdirSync, writeFileSync } from "node:fs"; import { join, relative } from "node:path"; +import { trackCheckReport } 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 { @@ -180,6 +182,7 @@ interface GridSamples { motionFrames: MotionFrame[]; contrastEntries: ContrastAuditEntry[]; screenshots: CheckScreenshot[]; + contrastMs: number; } interface GeometrySeen { @@ -349,6 +352,7 @@ async function collectGridSamples( motionFrames: [], contrastEntries: [], screenshots: [], + contrastMs: 0, }; for (const time of mergeSampleTimes(grid.layoutSamples, motion.times)) { await driver.seek(time); @@ -366,7 +370,9 @@ async function collectGridSamples( ); } if (contrastSet.has(time)) { + const contrastStart = Date.now(); const capture = await driver.collectContrast(time); + collected.contrastMs += Date.now() - contrastStart; collected.contrastEntries.push(...capture.entries); collected.screenshots.push({ time, pngBase64: capture.pngBase64 }); } @@ -382,7 +388,9 @@ export async function runAuditGrid( 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) { @@ -408,6 +416,7 @@ export async function runAuditGrid( contrastChecked: collected.contrastEntries.length, contrastPassed: contrast.passed, screenshots: collected.screenshots, + timings: { launchSettleMs: 0, seekLoopMs, contrastMs: collected.contrastMs }, }; } @@ -555,7 +564,7 @@ function buildReport( layout.errorCount + motionSection.errorCount + contrastSection.errorCount; - return { + const report: CheckReport = { ok: errorCount === 0 && (!options.strict || warningCount === 0), strict: options.strict, lint, @@ -580,6 +589,32 @@ function buildReport( times: options.snapshots ? browser.screenshots.map((shot) => shot.time) : [], }, }; + 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( @@ -651,6 +686,7 @@ function emptyBrowserResult(): CheckBrowserResult { contrastChecked: 0, contrastPassed: 0, screenshots: [], + timings: { launchSettleMs: 0, seekLoopMs: 0, contrastMs: 0 }, }; } diff --git a/packages/cli/src/utils/checkTypes.ts b/packages/cli/src/utils/checkTypes.ts index 2c45dde925..e9b0a32252 100644 --- a/packages/cli/src/utils/checkTypes.ts +++ b/packages/cli/src/utils/checkTypes.ts @@ -133,6 +133,12 @@ export interface CheckScreenshot { pngBase64: string; } +export interface CheckTimings { + launchSettleMs: number; + seekLoopMs: number; + contrastMs: number; +} + export interface CheckBrowserResult { duration: number; layoutSamples: number[]; @@ -147,6 +153,7 @@ export interface CheckBrowserResult { contrastChecked: number; contrastPassed: number; screenshots: CheckScreenshot[]; + timings: CheckTimings; } /** The seek-grid audit loop, injected into checkBrowser so it never imports checkPipeline back. */ From 58f45ef75871045ea9a5ae7fb7229e4f641336be Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 10 Jul 2026 01:56:08 -0400 Subject: [PATCH 05/16] feat(cli): deprecate validate, inspect, layout in favor of check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One stderr notice per invocation and _meta.deprecated: true in JSON mode (shared helper next to withMeta; layout owns both inspect and layout via createInspectCommand). Help descriptions gain the pointer. No behavior change; removal ships separately once migration telemetry says usage has decayed. fix(producer): route info/debug logs to stderr — the compiler's 'Localized remote media' line was landing on stdout ahead of validate's --json payload, breaking every piped consumer. Diagnostics now share stderr with warn/error; render progress uses its own channel. --- packages/cli/src/commands/inspect.test.ts | 81 ++++++++++++++++ packages/cli/src/commands/layout.test.ts | 104 +++++++++++++++++++++ packages/cli/src/commands/layout.ts | 92 ++++++++++-------- packages/cli/src/commands/validate.test.ts | 86 ++++++++++++++++- packages/cli/src/commands/validate.ts | 28 ++++-- packages/cli/src/utils/updateCheck.test.ts | 64 ++++++++++++- packages/cli/src/utils/updateCheck.ts | 27 +++++- packages/producer/src/logger.test.ts | 94 +++++++++++++------ packages/producer/src/logger.ts | 10 +- 9 files changed, 500 insertions(+), 86 deletions(-) create mode 100644 packages/cli/src/commands/inspect.test.ts create mode 100644 packages/cli/src/commands/layout.test.ts diff --git a/packages/cli/src/commands/inspect.test.ts b/packages/cli/src/commands/inspect.test.ts new file mode 100644 index 0000000000..59288c7ebb --- /dev/null +++ b/packages/cli/src/commands/inspect.test.ts @@ -0,0 +1,81 @@ +import type { CommandDef } from "citty"; +import { runCommand } from "citty"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +// 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. +const FAKE_PROJECT = { + dir: "/fake-project", + name: "fake-project", + indexPath: "/fake-project/index.html", +}; + +vi.mock("../utils/project.js", () => ({ + resolveProject: vi.fn(() => FAKE_PROJECT), +})); + +vi.mock("@hyperframes/core/compiler", () => ({ + bundleToSingleHtml: vi.fn(async () => { + throw new Error("bundling failed (test double)"); + }), +})); + +import inspectCommand from "./inspect.js"; + +function metaDescription(command: CommandDef): string { + const meta = command.meta; + if (meta && typeof meta === "object" && "description" in meta) { + return String(meta.description ?? ""); + } + throw new Error("expected a synchronous meta object"); +} + +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 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(inspectCommand, { rawArgs: ["--json"] }); + + const stderrText = stderrWrites.join(""); + expect(stderrText).toContain("hyperframes inspect"); + expect(stderrText).toContain("hyperframes check"); + expect(stdoutWrites.join("")).toBe(""); + }); + + it("--json output is valid JSON with _meta.deprecated === true on failure", async () => { + vi.spyOn(process.stderr, "write").mockImplementation(() => true); + vi.spyOn(process, "exit").mockImplementation(() => undefined as never); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + await runCommand(inspectCommand, { rawArgs: ["--json"] }); + + const jsonCall = logSpy.mock.calls.find( + ([arg]) => typeof arg === "string" && arg.trim().startsWith("{"), + ); + expect(jsonCall).toBeDefined(); + const parsed = JSON.parse(String(jsonCall?.[0])); + expect(parsed.ok).toBe(false); + expect(parsed._meta.deprecated).toBe(true); + }); +}); diff --git a/packages/cli/src/commands/layout.test.ts b/packages/cli/src/commands/layout.test.ts new file mode 100644 index 0000000000..bd7cdb4d72 --- /dev/null +++ b/packages/cli/src/commands/layout.test.ts @@ -0,0 +1,104 @@ +import type { CommandDef } from "citty"; +import { runCommand } from "citty"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +// 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. +const FAKE_PROJECT = { + dir: "/fake-project", + name: "fake-project", + indexPath: "/fake-project/index.html", +}; + +vi.mock("../utils/project.js", () => ({ + resolveProject: vi.fn(() => FAKE_PROJECT), +})); + +vi.mock("@hyperframes/core/compiler", () => ({ + bundleToSingleHtml: vi.fn(async () => { + throw new Error("bundling failed (test double)"); + }), +})); + +import { createInspectCommand } from "./layout.js"; + +/** + * citty's `meta` is `Resolvable` (object | promise | thunk). + * This file's commands always define it as a synchronous object literal, so + * narrow to that shape instead of asserting it with `as`. + */ +function metaDescription(command: CommandDef): string { + const meta = command.meta; + if (meta && typeof meta === "object" && "description" in meta) { + return String(meta.description ?? ""); + } + throw new Error("expected a synchronous meta object"); +} + +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 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(createInspectCommand("layout"), { rawArgs: ["--json"] }); + + const stderrText = stderrWrites.join(""); + expect(stderrText).toContain("hyperframes layout"); + expect(stderrText).toContain("hyperframes check"); + expect(stdoutWrites.join("")).toBe(""); + }); + + it("--json output is valid JSON with _meta.deprecated === true on failure", async () => { + vi.spyOn(process.stderr, "write").mockImplementation(() => true); + vi.spyOn(process, "exit").mockImplementation(() => undefined as never); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + await runCommand(createInspectCommand("layout"), { rawArgs: ["--json"] }); + + const jsonCall = logSpy.mock.calls.find( + ([arg]) => typeof arg === "string" && arg.trim().startsWith("{"), + ); + expect(jsonCall).toBeDefined(); + const parsed = JSON.parse(String(jsonCall?.[0])); + expect(parsed.ok).toBe(false); + expect(parsed._meta.deprecated).toBe(true); + }); + + it("the inspect command name produces the same _meta.deprecated === true envelope", async () => { + vi.spyOn(process.stderr, "write").mockImplementation(() => true); + vi.spyOn(process, "exit").mockImplementation(() => undefined as never); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + await runCommand(createInspectCommand("inspect"), { rawArgs: ["--json"] }); + + const jsonCall = logSpy.mock.calls.find( + ([arg]) => typeof arg === "string" && arg.trim().startsWith("{"), + ); + const parsed = JSON.parse(String(jsonCall?.[0])); + expect(parsed._meta.deprecated).toBe(true); + }); +}); diff --git a/packages/cli/src/commands/layout.ts b/packages/cli/src/commands/layout.ts index 55d2c49d24..1fb0f3a28a 100644 --- a/packages/cli/src/commands/layout.ts +++ b/packages/cli/src/commands/layout.ts @@ -7,7 +7,7 @@ import { c } from "../ui/colors.js"; import { resolveProject } from "../utils/project.js"; import { normalizeErrorMessage } from "../utils/errorMessage.js"; import { serveStaticProjectHtml } from "../utils/staticProjectServer.js"; -import { withMeta } from "../utils/updateCheck.js"; +import { printDeprecationNotice, withMeta } from "../utils/updateCheck.js"; import { buildLayoutSampleTimes, buildTransitionSampleTimes, @@ -388,16 +388,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, ), @@ -422,7 +425,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 }, @@ -476,6 +479,7 @@ export function createInspectCommand(commandName: "inspect" | "layout") { // 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); @@ -534,25 +538,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, ), @@ -602,16 +609,19 @@ export function createInspectCommand(commandName: "inspect" | "layout") { if (args.json) { console.log( JSON.stringify( - withMeta({ - schemaVersion: INSPECT_SCHEMA_VERSION, - ok: false, - error: message, - issues: [], - errorCount: 0, - warningCount: 0, - infoCount: 0, - issueCount: 0, - }), + withMeta( + { + schemaVersion: INSPECT_SCHEMA_VERSION, + ok: false, + error: message, + issues: [], + errorCount: 0, + warningCount: 0, + infoCount: 0, + issueCount: 0, + }, + { deprecated: true }, + ), null, 2, ), diff --git a/packages/cli/src/commands/validate.test.ts b/packages/cli/src/commands/validate.test.ts index bd3941286e..f35f3ffd86 100644 --- a/packages/cli/src/commands/validate.test.ts +++ b/packages/cli/src/commands/validate.test.ts @@ -1,4 +1,6 @@ -import { describe, expect, it, vi } from "vitest"; +import type { CommandDef } from "citty"; +import { runCommand } from "citty"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { extractCompositionErrorsFromLint, navigationTimeoutHint, @@ -28,6 +30,29 @@ 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. +const FAKE_PROJECT = { + dir: "/fake-project", + name: "fake-project", + indexPath: "/fake-project/index.html", +}; + +vi.mock("../utils/project.js", () => ({ + resolveProject: vi.fn(() => FAKE_PROJECT), +})); + +vi.mock("../utils/lintProject.js", () => ({ + lintProject: vi.fn(async () => { + throw new Error("lint failed (test double)"); + }), +})); + // 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. @@ -282,3 +307,62 @@ describe("navigationTimeoutHint", () => { expect(navigationTimeoutHint("some string failure", 10000)).toBeNull(); }); }); + +function metaDescription(command: CommandDef): string { + const meta = command.meta; + if (meta && typeof meta === "object" && "description" in meta) { + return String(meta.description ?? ""); + } + throw new Error("expected a synchronous meta object"); +} + +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 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(() => {}); + + const { default: validateCommand } = await import("./validate.js"); + await runCommand(validateCommand, { rawArgs: ["--json"] }); + + const stderrText = stderrWrites.join(""); + expect(stderrText).toContain("hyperframes validate"); + expect(stderrText).toContain("hyperframes check"); + expect(stdoutWrites.join("")).toBe(""); + }); + + it("--json output is valid JSON with _meta.deprecated === true on failure", async () => { + vi.spyOn(process.stderr, "write").mockImplementation(() => true); + vi.spyOn(process, "exit").mockImplementation(() => undefined as never); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + const { default: validateCommand } = await import("./validate.js"); + await runCommand(validateCommand, { rawArgs: ["--json"] }); + + const jsonCall = logSpy.mock.calls.find( + ([arg]) => typeof arg === "string" && arg.trim().startsWith("{"), + ); + expect(jsonCall).toBeDefined(); + const parsed = JSON.parse(String(jsonCall?.[0])); + expect(parsed.ok).toBe(false); + expect(parsed._meta.deprecated).toBe(true); + }); +}); diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index 0852dec60e..823ae23677 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -13,7 +13,7 @@ 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 { resolveCliChromeGpuMode, seekCompositionTimeline, @@ -514,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, ), @@ -567,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; } @@ -577,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 @@ -602,6 +609,7 @@ Examples: }, }, async run({ args }) { + printDeprecationNotice("validate"); const project = resolveProject(args.dir); const timeout = parseInt(args.timeout as string, 10) || 3000; const useContrast = args.contrast ?? true; diff --git a/packages/cli/src/utils/updateCheck.test.ts b/packages/cli/src/utils/updateCheck.test.ts index 1de5d35bcb..9a9daf28ce 100644 --- a/packages/cli/src/utils/updateCheck.test.ts +++ b/packages/cli/src/utils/updateCheck.test.ts @@ -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"); diff --git a/packages/cli/src/utils/updateCheck.ts b/packages/cli/src/utils/updateCheck.ts index caaab09201..6a58fad521 100644 --- a/packages/cli/src/utils/updateCheck.ts +++ b/packages/cli/src/utils/updateCheck.ts @@ -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( + 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 withMeta(data: T): T & { _meta: UpdateMeta } { - return { ...data, _meta: getUpdateMeta() }; +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`, + ); } /** diff --git a/packages/producer/src/logger.test.ts b/packages/producer/src/logger.test.ts index f4d1f66c9f..8e767d36f5 100644 --- a/packages/producer/src/logger.test.ts +++ b/packages/producer/src/logger.test.ts @@ -1,13 +1,13 @@ -import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createConsoleLogger, defaultLogger } from "./logger.js"; import type { LogLevel, ProducerLogger } from "./logger.js"; describe("createConsoleLogger", () => { - // We capture calls to console.{log,warn,error} via `mock` so we can + // We capture calls to console.{log,warn,error} via `vi.fn` so we can // assert what would have been printed without polluting test output. - let logSpy: ReturnType; - let warnSpy: ReturnType; - let errorSpy: ReturnType; + let logSpy: ReturnType; + let warnSpy: ReturnType; + let errorSpy: ReturnType; let origLog: typeof console.log; let origWarn: typeof console.warn; let origError: typeof console.error; @@ -16,9 +16,9 @@ describe("createConsoleLogger", () => { origLog = console.log; origWarn = console.warn; origError = console.error; - logSpy = mock(() => {}); - warnSpy = mock(() => {}); - errorSpy = mock(() => {}); + logSpy = vi.fn(); + warnSpy = vi.fn(); + errorSpy = vi.fn(); console.log = logSpy as unknown as typeof console.log; console.warn = warnSpy as unknown as typeof console.warn; console.error = errorSpy as unknown as typeof console.error; @@ -30,6 +30,43 @@ describe("createConsoleLogger", () => { console.error = origError; }); + // All four levels are stderr-bound (console.error/console.warn); console.log + // (stdout) must never be touched, since producer runs inside CLI commands + // whose stdout is a machine-readable contract (e.g. `validate --json`). + describe("stdout/stderr routing", () => { + it("info and debug write to console.error (stderr), never console.log (stdout)", () => { + const log = createConsoleLogger("debug"); + log.info("info-msg"); + log.debug("debug-msg"); + + expect(logSpy).not.toHaveBeenCalled(); + expect(errorSpy.mock.calls.map((c) => c[0])).toEqual([ + "[INFO] info-msg", + "[DEBUG] debug-msg", + ]); + }); + + it("warn and error keep their pre-existing channels (console.warn / console.error)", () => { + const log = createConsoleLogger("debug"); + log.warn("warn-msg"); + log.error("error-msg"); + + expect(logSpy).not.toHaveBeenCalled(); + expect(warnSpy.mock.calls[0]?.[0]).toBe("[WARN] warn-msg"); + expect(errorSpy.mock.calls[0]?.[0]).toBe("[ERROR] error-msg"); + }); + + it("console.log is never called at any level", () => { + const log = createConsoleLogger("debug"); + log.debug("d"); + log.info("i"); + log.warn("w"); + log.error("e"); + + expect(logSpy).not.toHaveBeenCalled(); + }); + }); + describe("level filtering", () => { it("level=info drops debug, keeps info/warn/error", () => { const log = createConsoleLogger("info"); @@ -38,12 +75,12 @@ describe("createConsoleLogger", () => { log.warn("warn-msg"); log.error("error-msg"); - expect(logSpy.mock.calls.length).toBe(1); - expect(logSpy.mock.calls[0]?.[0]).toBe("[INFO] info-msg"); + // info + error both route to console.error now (info: stderr routing, error: always stderr). + expect(errorSpy.mock.calls.length).toBe(2); + expect(errorSpy.mock.calls[0]?.[0]).toBe("[INFO] info-msg"); + expect(errorSpy.mock.calls[1]?.[0]).toBe("[ERROR] error-msg"); expect(warnSpy.mock.calls.length).toBe(1); expect(warnSpy.mock.calls[0]?.[0]).toBe("[WARN] warn-msg"); - expect(errorSpy.mock.calls.length).toBe(1); - expect(errorSpy.mock.calls[0]?.[0]).toBe("[ERROR] error-msg"); }); it("level=debug keeps all four levels", () => { @@ -53,12 +90,12 @@ describe("createConsoleLogger", () => { log.warn("w"); log.error("e"); - // info + debug both go to console.log - expect(logSpy.mock.calls.length).toBe(2); - expect(logSpy.mock.calls[0]?.[0]).toBe("[DEBUG] d"); - expect(logSpy.mock.calls[1]?.[0]).toBe("[INFO] i"); + // debug + info + error all go to console.error + expect(errorSpy.mock.calls.length).toBe(3); + expect(errorSpy.mock.calls[0]?.[0]).toBe("[DEBUG] d"); + expect(errorSpy.mock.calls[1]?.[0]).toBe("[INFO] i"); + expect(errorSpy.mock.calls[2]?.[0]).toBe("[ERROR] e"); expect(warnSpy.mock.calls.length).toBe(1); - expect(errorSpy.mock.calls.length).toBe(1); }); it("level=warn drops info and debug, keeps warn/error", () => { @@ -68,9 +105,9 @@ describe("createConsoleLogger", () => { log.warn("w"); log.error("e"); - expect(logSpy.mock.calls.length).toBe(0); - expect(warnSpy.mock.calls.length).toBe(1); expect(errorSpy.mock.calls.length).toBe(1); + expect(errorSpy.mock.calls[0]?.[0]).toBe("[ERROR] e"); + expect(warnSpy.mock.calls.length).toBe(1); }); it("level=error drops everything except error", () => { @@ -80,7 +117,6 @@ describe("createConsoleLogger", () => { log.warn("w"); log.error("e"); - expect(logSpy.mock.calls.length).toBe(0); expect(warnSpy.mock.calls.length).toBe(0); expect(errorSpy.mock.calls.length).toBe(1); }); @@ -90,8 +126,8 @@ describe("createConsoleLogger", () => { log.debug("d"); log.info("i"); - expect(logSpy.mock.calls.length).toBe(1); - expect(logSpy.mock.calls[0]?.[0]).toBe("[INFO] i"); + expect(errorSpy.mock.calls.length).toBe(1); + expect(errorSpy.mock.calls[0]?.[0]).toBe("[INFO] i"); }); }); @@ -100,14 +136,14 @@ describe("createConsoleLogger", () => { const log = createConsoleLogger("info"); log.info("hello", { a: 1, b: "two" }); - expect(logSpy.mock.calls[0]?.[0]).toBe('[INFO] hello {"a":1,"b":"two"}'); + expect(errorSpy.mock.calls[0]?.[0]).toBe('[INFO] hello {"a":1,"b":"two"}'); }); it("emits message only when meta is omitted", () => { const log = createConsoleLogger("info"); log.info("plain"); - expect(logSpy.mock.calls[0]?.[0]).toBe("[INFO] plain"); + expect(errorSpy.mock.calls[0]?.[0]).toBe("[INFO] plain"); }); it("does not invoke JSON.stringify when level is filtered out", () => { @@ -123,7 +159,7 @@ describe("createConsoleLogger", () => { }; // Should not throw — debug is below the info threshold. log.debug("trap", trap as unknown as Record); - expect(logSpy.mock.calls.length).toBe(0); + expect(errorSpy.mock.calls.length).toBe(0); }); }); @@ -185,7 +221,7 @@ describe("createConsoleLogger", () => { } expect(buildCount).toBe(0); - expect(logSpy.mock.calls.length).toBe(0); + expect(errorSpy.mock.calls.length).toBe(0); }); it("call-site gate runs the meta builder when debug is enabled", () => { @@ -203,7 +239,7 @@ describe("createConsoleLogger", () => { } expect(buildCount).toBe(5); - expect(logSpy.mock.calls.length).toBe(5); + expect(errorSpy.mock.calls.length).toBe(5); }); it("custom logger without isLevelEnabled falls back to running the meta builder (`?? true`)", () => { @@ -242,8 +278,8 @@ describe("createConsoleLogger", () => { defaultLogger.info("default-info"); defaultLogger.debug("default-debug"); - expect(logSpy.mock.calls.length).toBe(1); - expect(logSpy.mock.calls[0]?.[0]).toBe("[INFO] default-info"); + expect(errorSpy.mock.calls.length).toBe(1); + expect(errorSpy.mock.calls[0]?.[0]).toBe("[INFO] default-info"); }); it("exposes isLevelEnabled gating debug at info threshold", () => { diff --git a/packages/producer/src/logger.ts b/packages/producer/src/logger.ts index d0fa8b77fd..845d321e58 100644 --- a/packages/producer/src/logger.ts +++ b/packages/producer/src/logger.ts @@ -4,6 +4,12 @@ * Lightweight pluggable logger with zero dependencies. * Default implementation writes to console with level filtering. * + * All levels write to stderr (console.error/console.warn), never stdout — + * producer runs inside CLI commands whose stdout is a machine-readable + * contract (e.g. `validate --json`, `check --json`); an info/debug line on + * stdout would corrupt that output. There is no diagnostic use case that + * needs these lines on stdout specifically, so the whole logger is stderr-only. + * * Users can provide their own logger (e.g. Winston, Pino) by * implementing the ProducerLogger interface. */ @@ -71,12 +77,12 @@ export function createConsoleLogger(level: LogLevel = "info"): ProducerLogger { }, info(message, meta) { if (shouldLog("info")) { - console.log(`[INFO] ${message}${formatMeta(meta)}`); + console.error(`[INFO] ${message}${formatMeta(meta)}`); } }, debug(message, meta) { if (shouldLog("debug")) { - console.log(`[DEBUG] ${message}${formatMeta(meta)}`); + console.error(`[DEBUG] ${message}${formatMeta(meta)}`); } }, isLevelEnabled(msgLevel) { From f4cef54b8bde538b9a470f4bcee35f989ef3c71a Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 10 Jul 2026 02:25:10 -0400 Subject: [PATCH 06/16] feat(cli): snapshot --zoom and per-finding crops on check --snapshots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit snapshot --zoom + --zoom-scale (default 3) crops via Puppeteer clip at raised deviceScaleFactor — density changes, layout never does. Selector resolves per frame with 24px padding; no match is a loud error, and a frame whose clamped region is a sliver (element collapsed or animated off-canvas) is skipped with a stderr note rather than written as a useless few-pixel image. check --snapshots additionally writes finding-NN-.png crops for error findings with bboxes (cap 12, deterministic re-seek in a second session) and draws labeled annotation boxes on overview frames via a transient overlay injected only after audits complete. Skill reference gains the zoom workflow: check reports a finding, zoom into it, fix, re-check. --- .../capture/captureCompositionFrame.test.ts | 129 ++++++++++++++ .../src/capture/captureCompositionFrame.ts | 135 +++++++++++++++ packages/cli/src/commands/check.test.ts | 162 +++++++++++++++++- packages/cli/src/commands/check.ts | 6 + packages/cli/src/commands/snapshot.test.ts | 22 ++- packages/cli/src/commands/snapshot.ts | 52 +++++- packages/cli/src/utils/checkBrowser.test.ts | 42 ++++- packages/cli/src/utils/checkBrowser.ts | 156 ++++++++++++++++- packages/cli/src/utils/checkPipeline.ts | 142 +++++++++++++-- packages/cli/src/utils/checkTypes.ts | 25 ++- skills-manifest.json | 2 +- .../references/lint-validate-inspect.md | 14 ++ 12 files changed, 856 insertions(+), 31 deletions(-) diff --git a/packages/cli/src/capture/captureCompositionFrame.test.ts b/packages/cli/src/capture/captureCompositionFrame.test.ts index 53e1ef4c23..a8954a6e54 100644 --- a/packages/cli/src/capture/captureCompositionFrame.test.ts +++ b/packages/cli/src/capture/captureCompositionFrame.test.ts @@ -3,7 +3,12 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { + captureRegionCrop, + clampCropRegion, + padCropRegion, + parseZoomTarget, resolveCliChromeGpuMode, + resolveCropRegion, runFfmpegOnce, seekCompositionTimeline, type CompositionSeekPage, @@ -181,6 +186,130 @@ describe("screenshot Chrome arguments", () => { }); }); +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(); diff --git a/packages/cli/src/capture/captureCompositionFrame.ts b/packages/cli/src/capture/captureCompositionFrame.ts index 92ac4b93bf..34a3150b51 100644 --- a/packages/cli/src/capture/captureCompositionFrame.ts +++ b/packages/cli/src/capture/captureCompositionFrame.ts @@ -286,6 +286,141 @@ export async function waitForCompositionFonts( .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}$/; +const DEFAULT_ZOOM_PADDING_PX = 24; + +/** Parse `snapshot --zoom` into either a CSS selector or an exact pixel region "x,y,w,h". */ +export function parseZoomTarget(value: string): ZoomTarget { + const trimmed = value.trim(); + if (ZOOM_REGION_PATTERN.test(trimmed)) { + const [x, y, width, height] = trimmed.split(",").map(Number) as [ + number, + number, + number, + number, + ]; + return { kind: "region", region: { x, y, width, height } }; + } + return { kind: "selector", selector: trimmed }; +} + +/** Clamp a region to the canvas bounds — Puppeteer's clip screenshot rejects a + * region that spills outside the viewport. Keeps at least 1px on each side. */ +export function clampCropRegion(region: CropRegion, canvas: CropCanvas): CropRegion { + const x = Math.max(0, Math.min(region.x, canvas.width)); + const y = Math.max(0, Math.min(region.y, canvas.height)); + const x2 = Math.max(x + 1, Math.min(region.x + region.width, canvas.width)); + const y2 = Math.max(y + 1, Math.min(region.y + region.height, canvas.height)); + return { x, y, width: x2 - x, height: y2 - y }; +} + +/** Pad a region on every side (context around a zoomed element), then clamp. */ +export function padCropRegion( + region: CropRegion, + canvas: CropCanvas, + paddingPx: number, +): CropRegion { + return clampCropRegion( + { + x: region.x - paddingPx, + y: region.y - paddingPx, + width: region.width + paddingPx * 2, + height: region.height + paddingPx * 2, + }, + canvas, + ); +} + +export interface ZoomSelectorPage { + evaluate( + pageFunction: (selector: string) => CropRegion | null, + selector: string, + ): Promise; +} + +/** + * Resolve a `--zoom` target to a concrete crop region. A selector resolves to + * its live bbox (padded ~24px, then clamped); an explicit region is used + * as-is (clamped only, never padded — region form crops exactly). A selector + * matching nothing throws: a loud error beats a silent full-frame fallback. + */ +// A selector can match an element whose visible box is gone at the sampled +// time — collapsed (display:none mid-timeline) or animated off-canvas, where +// clamping leaves a pixel-wide remnant. Either way the crop would be a sliver +// that tells an agent nothing, so the final clamped region is what's guarded +// and callers skip the frame on null. Explicit x,y,w,h regions stay literal. +const MIN_CROP_REGION_PX = 8; + +export async function resolveCropRegion( + page: ZoomSelectorPage, + target: ZoomTarget, + canvas: CropCanvas, + paddingPx = DEFAULT_ZOOM_PADDING_PX, +): Promise { + if (target.kind === "region") return clampCropRegion(target.region, canvas); + const bbox = await page.evaluate((selector) => { + const element = document.querySelector(selector); + if (!element) return null; + const rect = element.getBoundingClientRect(); + return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }; + }, target.selector); + if (!bbox) throw new Error(`--zoom selector matched no element: ${target.selector}`); + const region = padCropRegion(bbox, canvas, paddingPx); + if (region.width < MIN_CROP_REGION_PX || region.height < MIN_CROP_REGION_PX) return null; + return region; +} + +export interface CropCapturePage { + viewport(): { width: number; height: number; deviceScaleFactor?: number } | null; + setViewport(viewport: { + width: number; + height: number; + deviceScaleFactor?: number; + }): Promise; + screenshot(options: { clip: CropRegion; type: "png" }): Promise; +} + +/** + * Capture a high-density crop of `region`: raise `deviceScaleFactor` to + * `scale`, take a clip screenshot, then restore the original viewport. + * Deliberately NOT CSS zoom or a viewport resize — DSF only changes how + * densely Chrome rasterizes the existing CSS-pixel layout, so the + * composition's layout (and its render determinism) is untouched. The PNG + * comes out at `region.width * scale` real device pixels, not an upscale. + */ +export async function captureRegionCrop( + page: CropCapturePage, + region: CropRegion, + scale: number, +): Promise { + const original = page.viewport(); + if (original) await page.setViewport({ ...original, deviceScaleFactor: scale }); + try { + const shot = await page.screenshot({ clip: region, type: "png" }); + return Buffer.isBuffer(shot) ? shot : Buffer.from(shot); + } finally { + if (original) await page.setViewport(original); + } +} + export async function runFfmpegOnce( ffmpegPath: string, args: readonly string[], diff --git a/packages/cli/src/commands/check.test.ts b/packages/cli/src/commands/check.test.ts index 581610b726..5718465db7 100644 --- a/packages/cli/src/commands/check.test.ts +++ b/packages/cli/src/commands/check.test.ts @@ -13,15 +13,18 @@ import { createCheckCommand } from "./check.js"; import { DEFAULT_CHECK_OPTIONS, checkExitCode, + findingCropFilename, runAuditGrid, runCheckPipeline, selectContrastTimes, + selectFindingCropRequests, type AnchoredLayoutIssue, type CheckAnchor, type CheckAuditDriver, type CheckBrowserResult, type CheckDependencies, type CheckFinding, + type CheckFindingCropRequest, type CheckOptions, type CheckReport, type ContrastAuditEntry, @@ -29,7 +32,12 @@ import { } from "../utils/checkPipeline.js"; import { resolveCompositionViewportFromHtml } from "../utils/compositionViewport.js"; import type { ProjectLintResult } from "../utils/lintProject.js"; -import type { LayoutIssue, LayoutOverflow, LayoutRect } from "../utils/layoutAudit.js"; +import type { + LayoutIssue, + LayoutIssueCode, + LayoutOverflow, + LayoutRect, +} from "../utils/layoutAudit.js"; import type { ProjectDir } from "../utils/project.js"; const PROJECT: ProjectDir = { @@ -241,6 +249,7 @@ function dependencies( motion?: MotionSpecResolution; runtime?: CheckFinding[]; writeSnapshot?: CheckDependencies["writeSnapshot"]; + captureFindingCrops?: CheckDependencies["captureFindingCrops"]; } = {}, ): { deps: CheckDependencies; runBrowserCheck: ReturnType } { const runBrowserCheck = vi.fn( @@ -264,6 +273,7 @@ function dependencies( `snapshots/frame-${String(index).padStart(2, "0")}-at-${time.toFixed(1)}s.png`, ), ), + captureFindingCrops: options.captureFindingCrops ?? vi.fn(async () => []), }; return { deps, runBrowserCheck }; } @@ -655,6 +665,115 @@ it("keeps contrast and snapshot sampling on the pre-gate layout grid", async () expect(collectContrast).toHaveBeenCalledWith(5); }); +function layoutFindingOf( + code: LayoutIssueCode, + severity: "error" | "warning" | "info", + bbox: { x: number; y: number; width: number; height: number }, + time = 1, +): AnchoredLayoutIssue { + return { + code, + severity, + message: code, + ...anchor("#el", time), + bbox, + rect: { + left: bbox.x, + top: bbox.y, + right: bbox.x + bbox.width, + bottom: bbox.y + bbox.height, + ...bbox, + }, + }; +} + +function checkFindingOf( + code: string, + severity: "error" | "warning" | "info", + bbox: { x: number; y: number; width: number; height: number }, + time = 1, +): CheckFinding { + return { code, severity, message: code, ...anchor("#el", time), bbox }; +} + +function emptySection(findings: T[] = []) { + return { ok: true, errorCount: 0, warningCount: 0, infoCount: 0, findings }; +} + +function reportWithFindings(overrides: Partial = {}): CheckReport { + return { + ok: true, + strict: false, + lint: { ...emptySection(), filesScanned: 0 }, + runtime: emptySection(), + layout: { + ...emptySection(), + duration: 10, + samples: [], + transitionSamples: [], + transitionSamplesDropped: 0, + tolerance: 2, + totalIssueCount: 0, + truncated: false, + }, + motion: { ...emptySection(), enabled: false, samples: 0 }, + contrast: { ...emptySection(), enabled: true, samples: [], checked: 0, passed: 0 }, + snapshots: { enabled: false, files: [], times: [], findingFiles: [] }, + ...overrides, + }; +} + +describe("selectFindingCropRequests", () => { + const NON_ZERO_BBOX = { x: 10, y: 20, width: 100, height: 50 }; + const ZERO_BBOX = { x: 0, y: 0, width: 0, height: 0 }; + + it("filenames a request finding-NN-.png with the finding's time and bbox", () => { + const report = reportWithFindings({ + layout: { + ...reportWithFindings().layout, + findings: [layoutFindingOf("clipped_text", "error", NON_ZERO_BBOX, 2.5)], + }, + }); + + expect(selectFindingCropRequests(report)).toEqual([ + { filename: "finding-00-clipped_text.png", time: 2.5, bbox: NON_ZERO_BBOX }, + ]); + }); + + it("skips warnings/info and findings without a real bbox", () => { + const report = reportWithFindings({ + layout: { + ...reportWithFindings().layout, + findings: [ + layoutFindingOf("content_overlap", "warning", NON_ZERO_BBOX), + layoutFindingOf("clipped_text", "error", ZERO_BBOX), + ], + }, + runtime: { ...emptySection([checkFindingOf("console_error", "info", NON_ZERO_BBOX)]) }, + }); + + expect(selectFindingCropRequests(report)).toEqual([]); + }); + + it("caps at 12 requests across sections", () => { + const findings = Array.from({ length: 15 }, (_, index) => + checkFindingOf(`code_${index}`, "error", NON_ZERO_BBOX, index), + ); + const report = reportWithFindings({ + runtime: { ...emptySection(findings) }, + }); + + const requests = selectFindingCropRequests(report); + expect(requests).toHaveLength(12); + expect(requests[0]?.filename).toBe(findingCropFilename(0, "code_0")); + expect(requests[11]?.filename).toBe(findingCropFilename(11, "code_11")); + }); + + it("sanitizes unusual characters out of the code when building a filename", () => { + expect(findingCropFilename(3, "weird code/name")).toBe("finding-03-weird_code_name.png"); + }); +}); + describe("check pipeline", () => { const originalExitCode = process.exitCode; @@ -833,6 +952,47 @@ describe("check pipeline", () => { expect(absentWriter).not.toHaveBeenCalled(); }); + it("captures finding crops for error findings with bboxes only when --snapshots is set", async () => { + const capture = vi.fn( + async ( + _project: ProjectDir, + _options: CheckOptions, + _requests: CheckFindingCropRequest[], + ) => ["snapshots/finding-00-clipped_text.png"], + ); + const { report } = await runScenario( + fakeDriver({ collectLayout: vi.fn(async () => [layoutIssue()]) }), + { snapshots: true }, + { captureFindingCrops: capture }, + ); + + expect(capture).toHaveBeenCalledTimes(1); + expect(capture.mock.calls[0]?.[2]).toEqual([ + { + filename: "finding-00-clipped_text.png", + time: 0.5, + bbox: { x: 10, y: 20, width: 300, height: 80 }, + }, + ]); + expect(report.snapshots.findingFiles).toEqual(["snapshots/finding-00-clipped_text.png"]); + + const withoutSnapshots = vi.fn(async () => ["unused.png"]); + await runScenario( + fakeDriver({ collectLayout: vi.fn(async () => [layoutIssue()]) }), + { snapshots: false }, + { captureFindingCrops: withoutSnapshots }, + ); + expect(withoutSnapshots).not.toHaveBeenCalled(); + + const noErrors = vi.fn(async () => ["unused.png"]); + await runScenario( + fakeDriver({ collectLayout: vi.fn(async () => [layoutIssue("warning")]) }), + { snapshots: true }, + { captureFindingCrops: noErrors }, + ); + expect(noErrors).not.toHaveBeenCalled(); + }); + it("--strict flips a warnings-only result from exit 0 to exit 1", async () => { const warningDriver = () => fakeDriver({ collectLayout: vi.fn(async () => [layoutIssue("warning")]) }); diff --git a/packages/cli/src/commands/check.ts b/packages/cli/src/commands/check.ts index 36c48aa64b..6bc86fab5b 100644 --- a/packages/cli/src/commands/check.ts +++ b/packages/cli/src/commands/check.ts @@ -339,6 +339,12 @@ function printSnapshotSection(report: CheckReport): void { } 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)}`); + } } } diff --git a/packages/cli/src/commands/snapshot.test.ts b/packages/cli/src/commands/snapshot.test.ts index 0537f1705d..4cb0d394c7 100644 --- a/packages/cli/src/commands/snapshot.test.ts +++ b/packages/cli/src/commands/snapshot.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { computeSnapshotTimes, tailFrameTime } from "./snapshot.js"; +import { computeSnapshotTimes, parseZoomScale, tailFrameTime } from "./snapshot.js"; + +// --zoom's crop-region math (selector bbox + padding + clamp, exact region +// form, no-match error) is owned by and tested in +// ../capture/captureCompositionFrame.test.ts alongside its implementation. describe("tailFrameTime", () => { it("backs off ~3% of duration so the final frame isn't the blank exact-end", () => { @@ -59,3 +63,19 @@ describe("computeSnapshotTimes (FINDING [7]: tail is always captured)", () => { expect(appendedTail).toBe(false); }); }); + +describe("parseZoomScale (--zoom-scale)", () => { + it("defaults to 3 when unset", () => { + expect(parseZoomScale(undefined)).toBe(3); + }); + + it("honors an explicit scale", () => { + expect(parseZoomScale("2")).toBe(2); + }); + + it("falls back to the default for invalid or non-positive input", () => { + expect(parseZoomScale("abc")).toBe(3); + expect(parseZoomScale("0")).toBe(3); + expect(parseZoomScale("-1")).toBe(3); + }); +}); diff --git a/packages/cli/src/commands/snapshot.ts b/packages/cli/src/commands/snapshot.ts index 51e9f3c7eb..4d7a2349ca 100644 --- a/packages/cli/src/commands/snapshot.ts +++ b/packages/cli/src/commands/snapshot.ts @@ -4,9 +4,13 @@ import { existsSync, mkdtempSync, readFileSync, mkdirSync, rmSync, writeFileSync import { tmpdir } from "node:os"; import { resolve, join, relative, isAbsolute, basename } from "node:path"; import { + 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 +108,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 : 3; +} + /** * 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 +188,8 @@ async function captureSnapshots( outputDir?: string; angle?: Camera; includeEnd?: boolean; + zoom?: ZoomTarget; + zoomScale?: number; }, ): Promise { const { bundleToSingleHtml } = await import("@hyperframes/core/compiler"); @@ -425,7 +443,25 @@ 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 ?? 3); + 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 +516,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 +554,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 +577,8 @@ export default defineCommand({ outputDir: snapshotDir, angle: camera, includeEnd: args.end !== false, + zoom: zoomTarget, + zoomScale, }); if (paths.length === 0) { diff --git a/packages/cli/src/utils/checkBrowser.test.ts b/packages/cli/src/utils/checkBrowser.test.ts index 63619aa67a..49ef56e253 100644 --- a/packages/cli/src/utils/checkBrowser.test.ts +++ b/packages/cli/src/utils/checkBrowser.test.ts @@ -1,11 +1,11 @@ // @vitest-environment happy-dom -import { afterEach, expect, it, vi } from "vitest"; +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 { runBrowserCheck } from "./checkBrowser.js"; +import { captureOverviewShot, runBrowserCheck } from "./checkBrowser.js"; import type { ProjectDir } from "./project.js"; const mocks = vi.hoisted(() => ({ @@ -94,6 +94,44 @@ it("carries raw browser geometry through the page driver and pipeline", async () expect(mocks.serverClose).toHaveBeenCalledOnce(); }); +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"); diff --git a/packages/cli/src/utils/checkBrowser.ts b/packages/cli/src/utils/checkBrowser.ts index edd34746cb..da777d59fe 100644 --- a/packages/cli/src/utils/checkBrowser.ts +++ b/packages/cli/src/utils/checkBrowser.ts @@ -1,6 +1,10 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; import type { Page } from "puppeteer-core"; import { + captureRegionCrop, openSettledCompositionPage, + padCropRegion, resolveCliChromeGpuMode, seekCompositionTimeline, waitForPreferredSeekTarget, @@ -14,10 +18,12 @@ import { serveStaticProjectHtml } from "./staticProjectServer.js"; import type { AnchoredLayoutIssue, CheckAnchor, + CheckAnnotationBox, CheckAuditDriver, CheckBbox, CheckBrowserResult, CheckFinding, + CheckFindingCropRequest, CheckGeometryCandidate, CheckOptions, CheckSeverity, @@ -36,6 +42,11 @@ const SEEK_OPTIONS = { settleMs: 120, }; +// --zoom's default padding/scale, reused here so finding crops carry the same +// bit of surrounding context an agent gets from `snapshot --zoom`. +const FINDING_CROP_PADDING_PX = 24; +const FINDING_CROP_SCALE = 3; + interface RuntimeDraft { code: string; severity: CheckSeverity; @@ -121,6 +132,53 @@ export async function runBrowserCheck( } } +/** + * `check --snapshots`'s per-finding evidence crops. Opens its own session + * (the main grid session already closed by the time findings are shaped) and + * re-seeks to each finding's sample time — renders are deterministic, so a + * fresh page at the same time reproduces the same pixels the grid audited. + */ +export async function captureFindingCrops( + project: ProjectDir, + options: CheckOptions, + requests: CheckFindingCropRequest[], +): Promise { + if (requests.length === 0) return []; + const { bundleToSingleHtml } = await import("@hyperframes/core/compiler"); + const html = await bundleToSingleHtml(project.dir); + const server = await serveStaticProjectHtml(project.dir, html, "Failed to bind check server"); + let chromeBrowser: import("puppeteer-core").Browser | undefined; + const written: string[] = []; + try { + const session = await openSettledCompositionPage(html, server.url, { + renderReadyTimeoutMs: options.timeout, + renderReadyWarningSuffix: "capturing finding crops", + browserGpuMode: resolveCliChromeGpuMode(), + }); + chromeBrowser = session.browser; + const page = session.page; + await waitForPreferredSeekTarget(page, 500); + + const snapshotDir = join(project.dir, "snapshots"); + mkdirSync(snapshotDir, { recursive: true }); + for (const request of requests) { + await seekCompositionTimeline(page, request.time, SEEK_OPTIONS); + const canvas = await page.evaluate(() => ({ + width: window.innerWidth, + height: window.innerHeight, + })); + const region = padCropRegion(request.bbox, canvas, FINDING_CROP_PADDING_PX); + const buffer = await captureRegionCrop(page, region, FINDING_CROP_SCALE); + writeFileSync(join(snapshotDir, request.filename), buffer); + written.push(join("snapshots", request.filename)); + } + return written; + } finally { + await chromeBrowser?.close().catch(() => undefined); + await server.close(); + } +} + function wireRuntimeListeners(page: Page, drafts: RuntimeDraft[], currentTime: () => number): void { page.on("console", (message) => { const type = message.type(); @@ -202,7 +260,7 @@ function createPageDriver(page: Page, setTime: (time: number) => void): CheckAud collectMotionFrame: (time, selectors, scopes) => collectMotionFrame(page, time, selectors, scopes), anchorMotionIssues: (issues) => anchorLayoutIssues(page, issues), - collectContrast: (time) => collectContrast(page, time), + collectContrast: (time, annotations) => collectContrast(page, time, annotations), }; } @@ -430,20 +488,36 @@ async function compositionBbox(page: Page): Promise { }); } -async function collectContrast(page: Page, time: number): Promise { +async function collectContrast( + page: Page, + time: number, + layoutAnnotations: CheckAnnotationBox[] = [], +): Promise { let prepared: PreparedContrast[] = []; try { prepared = parsePreparedContrast(await prepareContrast(page, time)); - const screenshot = await page.screenshot({ encoding: "base64", type: "png" }); - if (typeof screenshot !== "string") throw new Error("Contrast screenshot was not base64"); + // This screenshot is the one contrast math is sampled from below — it must + // stay untouched by the annotation overlay (finishContrast reads real + // painted pixels), so annotation only ever happens on a SECOND shot. + const measurementShot = await page.screenshot({ encoding: "base64", type: "png" }); + if (typeof measurementShot !== "string") throw new Error("Contrast screenshot was not base64"); const raw = await finishContrast( page, - screenshot, + measurementShot, time, prepared.map((entry) => entry.raw), ); const finished = raw.flatMap(parseFinishedContrast); - return { entries: joinContrastEntries(finished, prepared), pngBase64: screenshot }; + const entries = joinContrastEntries(finished, prepared); + // Contrast failures are only known once measurement above completes, so + // they're appended to the layout-derived annotations passed in by the + // pipeline rather than being requested up front. + const annotations = [ + ...layoutAnnotations, + ...contrastFailureAnnotations(entries, layoutAnnotations.length), + ]; + const pngBase64 = await captureOverviewShot(page, annotations, measurementShot); + return { entries, pngBase64 }; } finally { await page .evaluate(() => { @@ -454,6 +528,76 @@ async function collectContrast(page: Page, time: number): Promise !entry.wcagAA) + .map((entry, index) => ({ + label: `${labelOffset + index + 1} contrast_aa_failure`, + bbox: entry.bbox, + })); +} + +const ANNOTATION_OVERLAY_ID = "__hyperframesCheckAnnotations"; + +/** + * `check --snapshots`'s overview-frame annotation: every audit for this + * sample time has already run (layout/geometry findings arrive via + * `annotations`; contrast failures were just measured above), so it's safe + * to draw labeled boxes and take one more shot without perturbing anything + * audits read. No-op (returns the plain screenshot) when there's nothing to + * annotate — the common case stays exactly as before this feature existed. + */ +export async function captureOverviewShot( + page: Page, + annotations: CheckAnnotationBox[], + fallbackScreenshot: string, +): Promise { + if (annotations.length === 0) return fallbackScreenshot; + await injectAnnotationOverlay(page, annotations); + try { + const shot = await page.screenshot({ encoding: "base64", type: "png" }); + return typeof shot === "string" ? shot : fallbackScreenshot; + } finally { + await removeAnnotationOverlay(page); + } +} + +/** One small, self-contained DOM overlay: fixed-position labeled boxes over + * each finding's bbox. Injected immediately before the annotated overview + * shot and torn down immediately after (see `captureOverviewShot`), so it + * never leaks into any audit's DOM reads. */ +async function injectAnnotationOverlay(page: Page, boxes: CheckAnnotationBox[]): Promise { + await page.evaluate( + (items: CheckAnnotationBox[], overlayId: string) => { + const root = document.createElement("div"); + root.id = overlayId; + root.style.cssText = "position:fixed;inset:0;z-index:2147483647;pointer-events:none;"; + for (const item of items) { + const box = document.createElement("div"); + box.style.cssText = `position:fixed;left:${item.bbox.x}px;top:${item.bbox.y}px;width:${item.bbox.width}px;height:${item.bbox.height}px;border:2px solid #ff2d55;box-sizing:border-box;`; + const label = document.createElement("div"); + label.textContent = item.label; + label.style.cssText = + "position:absolute;top:-18px;left:0;background:#ff2d55;color:#fff;font:11px/16px monospace;padding:0 4px;white-space:nowrap;"; + box.appendChild(label); + root.appendChild(box); + } + document.body.appendChild(root); + }, + boxes, + ANNOTATION_OVERLAY_ID, + ); +} + +async function removeAnnotationOverlay(page: Page): Promise { + await page.evaluate((overlayId: string) => { + document.getElementById(overlayId)?.remove(); + }, ANNOTATION_OVERLAY_ID); +} + async function prepareContrast(page: Page, time: number): Promise { // Candidate-to-element provenance must be captured while the prepare restore list is live. // fallow-ignore-next-line complexity diff --git a/packages/cli/src/utils/checkPipeline.ts b/packages/cli/src/utils/checkPipeline.ts index e60fd559bb..0f1b08684e 100644 --- a/packages/cli/src/utils/checkPipeline.ts +++ b/packages/cli/src/utils/checkPipeline.ts @@ -30,12 +30,14 @@ import { } from "../commands/contrast-bg.js"; import type { AnchoredLayoutIssue, + CheckAnnotationBox, CheckAuditDriver, CheckBbox, CheckBrowserResult, CheckContrastFinding, CheckDependencies, CheckFinding, + CheckFindingCropRequest, CheckGeometryCandidate, CheckOptions, CheckReport, @@ -54,6 +56,7 @@ export type { CheckBrowserResult, CheckDependencies, CheckFinding, + CheckFindingCropRequest, CheckOptions, CheckReport, CheckSection, @@ -356,13 +359,26 @@ async function collectGridSamples( }; 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)) { - collected.layoutIssues.push(...(await driver.collectLayout(time, options.tolerance))); + const layoutIssues = await driver.collectLayout(time, options.tolerance); + collected.layoutIssues.push(...layoutIssues); + issuesAtTime.push(...layoutIssues); } if (canvas) { - collected.layoutIssues.push( - ...(await collectGeometryAt(driver, options, grid, canvas, time, geometrySeen)), + const geometryIssues = await collectGeometryAt( + driver, + options, + grid, + canvas, + time, + geometrySeen, ); + collected.layoutIssues.push(...geometryIssues); + issuesAtTime.push(...geometryIssues); } if (motionSet.has(time)) { collected.motionFrames.push( @@ -371,7 +387,12 @@ async function collectGridSamples( } if (contrastSet.has(time)) { const contrastStart = Date.now(); - const capture = await driver.collectContrast(time); + // 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 }); @@ -380,6 +401,15 @@ async function collectGridSamples( return collected; } +/** 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, @@ -456,21 +486,87 @@ export async function runCheckPipeline( browser.runtimeFindings.push(runtimeFailure(error)); } - const snapshotFiles: string[] = []; - if (options.snapshots) { - for (let index = 0; index < browser.screenshots.length; index += 1) { - const shot = browser.screenshots[index]; - if (!shot) continue; - try { - snapshotFiles.push( - await dependencies.writeSnapshot(project.dir, index, shot.time, shot.pngBase64), - ); - } catch (error) { - browser.runtimeFindings.push(runtimeFailure(error, "snapshot_write_failed")); - } + const snapshotFiles = options.snapshots + ? await writeContrastSnapshots(dependencies, project.dir, browser) + : []; + const report = buildReport(options, lint, browser, motion, [], snapshotFiles); + return options.snapshots + ? await withFindingCrops(dependencies, project, options, report) + : report; +} + +/** Persists the contrast pass's already-captured overview PNGs (or the + * annotated versions — see `collectContrast`'s overlay). A write failure + * becomes a runtime finding rather than aborting the whole report. */ +async function writeContrastSnapshots( + dependencies: CheckDependencies, + projectDir: string, + browser: CheckBrowserResult, +): Promise { + const files: string[] = []; + for (let index = 0; index < browser.screenshots.length; index += 1) { + const shot = browser.screenshots[index]; + if (!shot) continue; + try { + files.push(await dependencies.writeSnapshot(projectDir, index, shot.time, shot.pngBase64)); + } catch (error) { + browser.runtimeFindings.push(runtimeFailure(error, "snapshot_write_failed")); } } - return buildReport(options, lint, browser, motion, [], snapshotFiles); + return files; +} + +/** Finding crops are bonus evidence, not gating — no eligible finding, or a + * capture failure (e.g. a second Chrome launch failing), returns the report + * unchanged rather than sinking an otherwise-good run. */ +async function withFindingCrops( + dependencies: CheckDependencies, + project: ProjectDir, + options: CheckOptions, + report: CheckReport, +): Promise { + const cropRequests = selectFindingCropRequests(report); + if (cropRequests.length === 0) return report; + try { + const findingFiles = await dependencies.captureFindingCrops(project, options, cropRequests); + return { ...report, snapshots: { ...report.snapshots, findingFiles } }; + } catch { + return report; + } +} + +const MAX_FINDING_CROPS = 12; + +/** Which error findings get a `finding-NN-.png` crop for `check --snapshots`: + * error severity, a real (non-zero) bbox, capped at 12. Pure and order-preserving + * so it's directly unit-testable without a browser. */ +export function selectFindingCropRequests(report: CheckReport): CheckFindingCropRequest[] { + const candidates: CheckFinding[] = [ + ...report.layout.findings, + ...report.motion.findings, + ...report.contrast.findings, + ...report.runtime.findings, + ]; + const requests: CheckFindingCropRequest[] = []; + for (const finding of candidates) { + if (requests.length >= MAX_FINDING_CROPS) break; + if (finding.severity !== "error" || !hasRealBbox(finding.bbox)) continue; + requests.push({ + filename: findingCropFilename(requests.length, finding.code), + time: finding.time, + bbox: finding.bbox, + }); + } + return requests; +} + +function hasRealBbox(bbox: CheckBbox): boolean { + return bbox.width > 0 && bbox.height > 0; +} + +export function findingCropFilename(index: number, code: string): string { + const safeCode = code.replace(/[^a-zA-Z0-9_-]/g, "_"); + return `finding-${String(index).padStart(2, "0")}-${safeCode}.png`; } export function checkExitCode(report: CheckReport): 0 | 1 { @@ -587,6 +683,7 @@ function buildReport( enabled: options.snapshots, files: snapshotFiles, times: options.snapshots ? browser.screenshots.map((shot) => shot.time) : [], + findingFiles: [], }, }; trackCheckReport({ @@ -771,9 +868,20 @@ async function writeSnapshot( return join("snapshots", filename); } +async function captureFindingCrops( + project: ProjectDir, + options: CheckOptions, + requests: CheckFindingCropRequest[], +): Promise { + const module = await import("./checkBrowser.js"); + // Handed over the same way runBrowserCheck is (checkBrowser never imports this module back). + return module.captureFindingCrops(project, options, requests); +} + const DEFAULT_DEPENDENCIES: CheckDependencies = { lintProject, resolveMotionSpec, runBrowserCheck, writeSnapshot, + captureFindingCrops, }; diff --git a/packages/cli/src/utils/checkTypes.ts b/packages/cli/src/utils/checkTypes.ts index e9b0a32252..63d97a3a9e 100644 --- a/packages/cli/src/utils/checkTypes.ts +++ b/packages/cli/src/utils/checkTypes.ts @@ -93,6 +93,22 @@ export interface GeometryCandidateRequest { 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; @@ -125,7 +141,7 @@ export interface CheckAuditDriver { livenessScopes: string[], ): Promise; anchorMotionIssues(issues: LayoutIssue[]): Promise; - collectContrast(time: number): Promise; + collectContrast(time: number, annotations?: CheckAnnotationBox[]): Promise; } export interface CheckScreenshot { @@ -192,7 +208,7 @@ export interface CheckReport { checked: number; passed: number; }; - snapshots: { enabled: boolean; files: string[]; times: number[] }; + snapshots: { enabled: boolean; files: string[]; times: number[]; findingFiles: string[] }; } export interface CheckDependencies { @@ -209,4 +225,9 @@ export interface CheckDependencies { time: number, pngBase64: string, ): Promise; + captureFindingCrops( + project: ProjectDir, + options: CheckOptions, + requests: CheckFindingCropRequest[], + ): Promise; } diff --git a/skills-manifest.json b/skills-manifest.json index 82e22e1c56..e346b5d1cd 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -26,7 +26,7 @@ "files": 99 }, "hyperframes-cli": { - "hash": "8ca3bef87f169ba1", + "hash": "9544d2cee786ebaf", "files": 7 }, "hyperframes-core": { diff --git a/skills/hyperframes-cli/references/lint-validate-inspect.md b/skills/hyperframes-cli/references/lint-validate-inspect.md index 13d2245062..65700dab8e 100644 --- a/skills/hyperframes-cli/references/lint-validate-inspect.md +++ b/skills/hyperframes-cli/references/lint-validate-inspect.md @@ -119,3 +119,17 @@ npx hyperframes snapshot --frames 10 # evenly-spaced N frames ``` Captures still PNGs from the composition for visual diffing, thumbnails, or attaching to a PR. Faster than rendering a video when you only need a few hero frames. Output lands in the project's snapshots directory. + +### Zooming into a reported finding + +`hyperframes check --snapshots` already writes a `finding-NN-.png` crop for every error finding that carries a bbox, but the same zoom is available standalone once you know what to look at: + +```bash +npx hyperframes check --snapshots # reports a finding, e.g. content_overlap on "#cta" +npx hyperframes snapshot --zoom "#cta" # crop the element to verify the defect, at 3x density +npx hyperframes snapshot --zoom "100,50,400,300" --zoom-scale 2 # or an exact pixel region +# fix the composition HTML, then re-check: +npx hyperframes check +``` + +`--zoom` takes a CSS selector or an exact `x,y,w,h` pixel region and always produces a real high-density crop (a raised `deviceScaleFactor`, never CSS zoom or a viewport resize), so the composition's layout — and its render determinism — is untouched. A selector matching nothing is a loud error, not a silent full-frame fallback. From 94f6de8b8d57bbbadb5032905584881c7b3eaad8 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 10 Jul 2026 03:37:15 -0400 Subject: [PATCH 07/16] feat(cli): persistence-tiered findings, frozen-sweep guard, occlusion coverage Layout findings now distinguish held defects from entrance/exit transients: a dynamic issue seen at a single grid sample demotes to info, while content_overlap held across two-plus samples (or 500ms+) promotes to error, resolving the long-standing re-promotion TODO. Static compositions keep their severity. check gains a sweep_static error when a 3s+ composition shows zero geometry change across every sample (a frozen timeline makes every green verdict unreliable); skipped when the motion sidecar already reported motion_frozen. text_occluded findings carry a coveredFraction; atomic labels (short, no whitespace) flag on any cover while prose needs 15%, since partial cover changes what a short label reads as. Deprecation-test scaffolding consolidates into deprecationTestHarness; tier logic and logger tests restructured under the complexity gate without suppression markers. Detection mechanics adapted from Adam Rosler's open-sourced visual-linter design (github.com/Adam-Rosler/hyperframes-visual-linter-design); the elementFromPoint paint model, opt-out attributes, and single-audit architecture are unchanged. --- packages/cli/src/commands/check.test.ts | 102 ++++++++++++++-- .../src/commands/deprecationTestHarness.ts | 115 ++++++++++++++++++ packages/cli/src/commands/inspect.test.ts | 65 ++-------- .../cli/src/commands/layout-audit.browser.js | 84 ++++++++++--- .../src/commands/layout-audit.browser.test.ts | 99 +++++++++++++-- packages/cli/src/commands/layout.test.ts | 81 +++--------- packages/cli/src/commands/layout.ts | 2 +- packages/cli/src/commands/validate.test.ts | 69 +++-------- packages/cli/src/utils/checkBrowser.ts | 12 ++ packages/cli/src/utils/checkPipeline.ts | 67 +++++++++- packages/cli/src/utils/checkTypes.ts | 4 + packages/cli/src/utils/layoutAudit.test.ts | 76 ++++++++++++ packages/cli/src/utils/layoutAudit.ts | 103 ++++++++++++++-- packages/producer/src/logger.test.ts | 71 +++++++---- 14 files changed, 712 insertions(+), 238 deletions(-) create mode 100644 packages/cli/src/commands/deprecationTestHarness.ts diff --git a/packages/cli/src/commands/check.test.ts b/packages/cli/src/commands/check.test.ts index 5718465db7..18fd3568ea 100644 --- a/packages/cli/src/commands/check.test.ts +++ b/packages/cli/src/commands/check.test.ts @@ -108,10 +108,14 @@ function anchor(selector: string, time: number): CheckAnchor { }; } -function layoutIssue(severity: "error" | "warning" | "info" = "error"): AnchoredLayoutIssue { +function layoutIssue( + severity: "error" | "warning" | "info" = "error", + overrides: { time?: number; code?: AnchoredLayoutIssue["code"] } = {}, +): AnchoredLayoutIssue { + const time = overrides.time ?? 0.5; return { - ...anchor("#hero", 0.5), - code: severity === "warning" ? "content_overlap" : "clipped_text", + ...anchor("#hero", time), + code: overrides.code ?? (severity === "warning" ? "content_overlap" : "clipped_text"), severity, text: "Hero", message: severity === "warning" ? "Text may overlap." : "Text is clipped.", @@ -133,6 +137,10 @@ function contrastEntry(overrides: Partial = {}): ContrastAud } function fakeDriver(overrides: Partial = {}): CheckAuditDriver { + // A distinct string per call so the frozen-sweep guard (#U10) never fires + // by accident in unrelated scenarios — tests that want it force a constant + // via `collectLayoutGeometry: vi.fn(async () => "same")`. + let geometryCallCount = 0; return { initialize: vi.fn(async (_contrast: boolean) => undefined), getDuration: vi.fn(async () => 9), @@ -141,6 +149,7 @@ function fakeDriver(overrides: Partial = {}): CheckAuditDriver findAmbiguousSelectors: vi.fn(async (_selectors: string[]) => []), seek: vi.fn(async (_time: number) => undefined), collectLayout: vi.fn(async (_time: number, _tolerance: number) => []), + collectLayoutGeometry: vi.fn(async () => `geometry-${geometryCallCount++}`), collectGeometryCandidates: vi.fn(async () => []), collectMotionFrame: vi.fn(async (time: number) => ({ time, data: {}, liveness: {} })), anchorMotionIssues: vi.fn(async (issues: LayoutIssue[]) => @@ -883,7 +892,9 @@ describe("check pipeline", () => { it("preserves a resolving selector, source file, identity, bbox, and sample time", async () => { const { report } = await runScenario( - fakeDriver({ collectLayout: vi.fn(async () => [layoutIssue()]) }), + fakeDriver({ + collectLayout: vi.fn(async (time: number) => [layoutIssue("error", { time })]), + }), ); expect(report.layout.findings[0]).toMatchObject({ selector: "#hero", @@ -896,7 +907,9 @@ describe("check pipeline", () => { it("reports layout and runtime errors from one browser session", async () => { const { report, browser } = await runScenario( - fakeDriver({ collectLayout: vi.fn(async () => [layoutIssue()]) }), + fakeDriver({ + collectLayout: vi.fn(async (time: number) => [layoutIssue("error", { time })]), + }), {}, { runtime: [runtimeError()] }, ); @@ -961,7 +974,9 @@ describe("check pipeline", () => { ) => ["snapshots/finding-00-clipped_text.png"], ); const { report } = await runScenario( - fakeDriver({ collectLayout: vi.fn(async () => [layoutIssue()]) }), + fakeDriver({ + collectLayout: vi.fn(async (time: number) => [layoutIssue("error", { time })]), + }), { snapshots: true }, { captureFindingCrops: capture }, ); @@ -978,7 +993,9 @@ describe("check pipeline", () => { const withoutSnapshots = vi.fn(async () => ["unused.png"]); await runScenario( - fakeDriver({ collectLayout: vi.fn(async () => [layoutIssue()]) }), + fakeDriver({ + collectLayout: vi.fn(async (time: number) => [layoutIssue("error", { time })]), + }), { snapshots: false }, { captureFindingCrops: withoutSnapshots }, ); @@ -986,7 +1003,15 @@ describe("check pipeline", () => { const noErrors = vi.fn(async () => ["unused.png"]); await runScenario( - fakeDriver({ collectLayout: vi.fn(async () => [layoutIssue("warning")]) }), + fakeDriver({ + collectLayout: vi.fn( + async (time: number) => + // container_overflow, not content_overlap: this fixture wants a plain + // warning-severity finding held across the whole run, unaffected by + // content_overlap's #U10 held-duration re-promotion to error. + [layoutIssue("warning", { time, code: "container_overflow" })], + ), + }), { snapshots: true }, { captureFindingCrops: noErrors }, ); @@ -995,7 +1020,15 @@ describe("check pipeline", () => { it("--strict flips a warnings-only result from exit 0 to exit 1", async () => { const warningDriver = () => - fakeDriver({ collectLayout: vi.fn(async () => [layoutIssue("warning")]) }); + fakeDriver({ + collectLayout: vi.fn( + async (time: number) => + // container_overflow, not content_overlap: this fixture wants a plain + // warning-severity finding held across the whole run, unaffected by + // content_overlap's #U10 held-duration re-promotion to error. + [layoutIssue("warning", { time, code: "container_overflow" })], + ), + }); const normal = await runScenario(warningDriver(), { strict: false }); const strict = await runScenario(warningDriver(), { strict: true }); @@ -1026,6 +1059,57 @@ describe("check pipeline", () => { ); expect(checkExitCode(report)).toBe(1); }); + + describe("frozen-sweep guard (#U10)", () => { + it("fails with sweep_static when a 6s composition's geometry never changes across samples", async () => { + const driver = fakeDriver({ + getDuration: vi.fn(async () => 6), + collectLayoutGeometry: vi.fn(async () => "frozen"), + }); + const { report } = await runScenario(driver); + + expect(report.ok).toBe(false); + expect( + report.layout.findings.some( + (finding) => + finding.code === "sweep_static" && + finding.severity === "error" && + finding.message.includes("did not advance"), + ), + ).toBe(true); + }); + + it("does not flag a 1.5s static title card — too short for the guard to apply", async () => { + const driver = fakeDriver({ + getDuration: vi.fn(async () => 1.5), + collectLayoutGeometry: vi.fn(async () => "frozen"), + }); + const { report } = await runScenario(driver); + + expect(report.layout.findings.some((finding) => finding.code === "sweep_static")).toBe(false); + }); + + it("does not double-report when a motion_frozen finding already covers the same symptom", async () => { + const motion: MotionSpecResolution = { + kind: "valid", + path: "/project/index.motion.json", + spec: { assertions: [{ kind: "keepsMoving" }] }, + }; + const driver = fakeDriver({ + getDuration: vi.fn(async () => 6), + collectLayoutGeometry: vi.fn(async () => "frozen"), + collectMotionFrame: vi.fn(async (time: number) => ({ + time, + data: {}, + liveness: { "*": "unchanging" }, + })), + }); + const { report } = await runScenario(driver, {}, { motion }); + + expect(report.motion.findings.some((finding) => finding.code === "motion_frozen")).toBe(true); + expect(report.layout.findings.some((finding) => finding.code === "sweep_static")).toBe(false); + }); + }); }); describe("check report telemetry", () => { diff --git a/packages/cli/src/commands/deprecationTestHarness.ts b/packages/cli/src/commands/deprecationTestHarness.ts new file mode 100644 index 0000000000..bce26290db --- /dev/null +++ b/packages/cli/src/commands/deprecationTestHarness.ts @@ -0,0 +1,115 @@ +// Shared scaffolding for the U5 deprecation tests in inspect.test.ts, +// layout.test.ts, and validate.test.ts: those commands all fail fast (via a +// mocked dynamic import) so the tests can assert the shared deprecation +// envelope (stderr notice, JSON `_meta.deprecated`) without needing a real +// project or headless Chrome. +// +// vi.mock factories are hoisted above imports, so each test file keeps its +// own thin `vi.mock("", () => someFactory())` call (mocking a module +// path can't itself be shared across files) but delegates the factory body +// here. +import type { ArgsDef, CommandDef } from "citty"; +import { runCommand } from "citty"; +import { expect, vi } from "vitest"; + +const FAKE_PROJECT = { + dir: "/fake-project", + name: "fake-project", + indexPath: "/fake-project/index.html", +}; + +export function resolveProjectMock() { + return { resolveProject: vi.fn(() => FAKE_PROJECT) }; +} + +export function bundleToSingleHtmlFailureMock() { + return { + bundleToSingleHtml: vi.fn(async () => { + throw new Error("bundling failed (test double)"); + }), + }; +} + +export function lintProjectFailureMock() { + return { + lintProject: vi.fn(async () => { + throw new Error("lint failed (test double)"); + }), + }; +} + +/** + * citty's `meta` is `Resolvable` (object | promise | thunk). + * These test files always define it as a synchronous object literal, so + * narrow to that shape instead of asserting it with `as`. + */ +export function metaDescription(command: CommandDef): string { + const meta = command.meta; + if (meta && typeof meta === "object" && "description" in meta) { + return String(meta.description ?? ""); + } + throw new Error("expected a synchronous meta object"); +} + +/** + * Run a command with stdout/stderr writes captured (and process.exit / + * console.log stubbed so the run stays silent and non-terminating), and + * return the captured text for the caller to assert on. + */ +export async function runAndCaptureStdio( + command: CommandDef, + rawArgs: string[] = ["--json"], +): Promise<{ stderrText: string; stdoutText: string }> { + const stderrWrites: string[] = []; + const stdoutWrites: string[] = []; + vi.spyOn(process.stderr, "write").mockImplementation((chunk: unknown) => { + stderrWrites.push(String(chunk)); + return true; + }); + vi.spyOn(process.stdout, "write").mockImplementation((chunk: unknown) => { + stdoutWrites.push(String(chunk)); + return true; + }); + vi.spyOn(process, "exit").mockImplementation(() => undefined as never); + vi.spyOn(console, "log").mockImplementation(() => {}); + + await runCommand(command, { rawArgs }); + + return { stderrText: stderrWrites.join(""), stdoutText: stdoutWrites.join("") }; +} + +/** + * Run a command with process.exit stubbed and console.log spied, returning + * the first console.log call that looks like a JSON object (the `--json` + * failure envelope). Callers assert on definedness/shape themselves, since + * that differs slightly per call site. + */ +export async function runAndFindJsonLogCall( + command: CommandDef, + rawArgs: string[] = ["--json"], +): Promise { + vi.spyOn(process.stderr, "write").mockImplementation(() => true); + vi.spyOn(process, "exit").mockImplementation(() => undefined as never); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + await runCommand(command, { rawArgs }); + + return logSpy.mock.calls.find(([arg]) => typeof arg === "string" && arg.trim().startsWith("{")); +} + +/** + * Convenience wrapper: parse the JSON envelope found by runAndFindJsonLogCall. + * `parsed` is intentionally left as JSON.parse's inferred `any` (matching + * every call site's prior inline `JSON.parse(...)` usage) rather than + * annotated `unknown`, since callers assert directly into its shape + * (`.ok`, `._meta.deprecated`) the same way the original inline tests did. + */ +export async function runAndParseJsonEnvelope( + command: CommandDef, + rawArgs: string[] = ["--json"], +) { + const jsonCall = await runAndFindJsonLogCall(command, rawArgs); + expect(jsonCall).toBeDefined(); + const parsed = JSON.parse(String(jsonCall?.[0])); + return { jsonCall, parsed }; +} diff --git a/packages/cli/src/commands/inspect.test.ts b/packages/cli/src/commands/inspect.test.ts index 59288c7ebb..3c378363a5 100644 --- a/packages/cli/src/commands/inspect.test.ts +++ b/packages/cli/src/commands/inspect.test.ts @@ -1,37 +1,21 @@ -import type { CommandDef } from "citty"; -import { runCommand } from "citty"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { + bundleToSingleHtmlFailureMock, + metaDescription, + resolveProjectMock, + runAndCaptureStdio, + runAndParseJsonEnvelope, +} 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. -const FAKE_PROJECT = { - dir: "/fake-project", - name: "fake-project", - indexPath: "/fake-project/index.html", -}; - -vi.mock("../utils/project.js", () => ({ - resolveProject: vi.fn(() => FAKE_PROJECT), -})); - -vi.mock("@hyperframes/core/compiler", () => ({ - bundleToSingleHtml: vi.fn(async () => { - throw new Error("bundling failed (test double)"); - }), -})); +vi.mock("../utils/project.js", () => resolveProjectMock()); +vi.mock("@hyperframes/core/compiler", () => bundleToSingleHtmlFailureMock()); import inspectCommand from "./inspect.js"; -function metaDescription(command: CommandDef): string { - const meta = command.meta; - if (meta && typeof meta === "object" && "description" in meta) { - return String(meta.description ?? ""); - } - throw new Error("expected a synchronous meta object"); -} - afterEach(() => { vi.restoreAllMocks(); }); @@ -42,39 +26,14 @@ describe("inspect command deprecation (U5)", () => { }); it("prints a one-line deprecation notice naming 'inspect' on stderr, never stdout", async () => { - 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(inspectCommand, { rawArgs: ["--json"] }); - - const stderrText = stderrWrites.join(""); + const { stderrText, stdoutText } = await runAndCaptureStdio(inspectCommand); expect(stderrText).toContain("hyperframes inspect"); expect(stderrText).toContain("hyperframes check"); - expect(stdoutWrites.join("")).toBe(""); + expect(stdoutText).toBe(""); }); it("--json output is valid JSON with _meta.deprecated === true on failure", async () => { - vi.spyOn(process.stderr, "write").mockImplementation(() => true); - vi.spyOn(process, "exit").mockImplementation(() => undefined as never); - const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - - await runCommand(inspectCommand, { rawArgs: ["--json"] }); - - const jsonCall = logSpy.mock.calls.find( - ([arg]) => typeof arg === "string" && arg.trim().startsWith("{"), - ); - expect(jsonCall).toBeDefined(); - const parsed = JSON.parse(String(jsonCall?.[0])); + const { parsed } = await runAndParseJsonEnvelope(inspectCommand); expect(parsed.ok).toBe(false); expect(parsed._meta.deprecated).toBe(true); }); diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index 568ccfbae4..f939a9dcee 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -627,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, @@ -728,37 +731,66 @@ 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.", }; @@ -855,4 +887,28 @@ issues.push(...contentOverlapIssues(root, time)); return issues; }; + + // Frozen-sweep guard (#U10, checkPipeline.ts): a compact per-sample + // fingerprint of every visible element's box + opacity, in DOM order. Node + // calls this once per seeked grid point and compares the strings across the + // whole run — if every sample produces the identical string, the seek never + // actually moved anything and the whole audit run is unreliable. Deliberately + // a single opaque string (not a structured array) since Node only ever needs + // equality, not per-element diffing. + window.__hyperframesLayoutGeometry = function collectLayoutGeometry() { + const root = + document.querySelector("[data-composition-id][data-width][data-height]") || + document.querySelector("[data-composition-id]") || + document.body; + const elements = Array.from(root.querySelectorAll("*")).filter((element) => + isVisibleElement(element), + ); + return elements + .map((element) => { + const rect = toRect(element.getBoundingClientRect()); + const opacity = round(opacityChain(element)); + return `${rect.left},${rect.top},${rect.width},${rect.height},${opacity}`; + }) + .join("|"); + }; })(); diff --git a/packages/cli/src/commands/layout-audit.browser.test.ts b/packages/cli/src/commands/layout-audit.browser.test.ts index 0f05e80772..dbd6d00e4e 100644 --- a/packages/cli/src/commands/layout-audit.browser.test.ts +++ b/packages/cli/src/commands/layout-audit.browser.test.ts @@ -651,8 +651,92 @@ describe("layout-audit.browser occlusion", () => { }); expect(issues.some((issue) => issue.code === "text_occluded")).toBe(false); }); + + it("carries the fully-covered fraction when the occluder hits every probe point", () => { + const occluded = auditOcclusionScene({ + overlayStyle: { backgroundColor: "rgb(10, 10, 10)" }, + topmostId: "overlay", + }).find((issue) => issue.code === "text_occluded"); + expect(occluded?.coveredFraction).toBe(1); + }); + + // #U10: a 2-point hit on the 27-point probe grid (3 rows x 9 columns) is a + // sliver of edge cover — reports ~0.07 coverage either way, but only GATES + // (produces a finding) for short atomic labels; ordinary prose survives it. + it("reports ~0.07 coverage for a 2-of-27 grid hit and flags an atomic label at that coverage", () => { + const issues = auditCoverageScene({ text: "SUBSCRIBE", hitCount: 2 }); + const occluded = issues.find((issue) => issue.code === "text_occluded"); + expect(occluded).toBeDefined(); + expect(occluded?.coveredFraction).toBe(0.07); + }); + + it("does not flag ordinary prose at the same ~0.07 coverage a label would flag at", () => { + const issues = auditCoverageScene({ + text: "This paragraph is long enough to read as ordinary prose, not a label.", + hitCount: 2, + }); + expect(issues.some((issue) => issue.code === "text_occluded")).toBe(false); + }); + + it("flags prose once coverage clears the 0.15 floor", () => { + // 5/27 ≈ 0.185, comfortably over the ~0.15 prose floor. + const issues = auditCoverageScene({ + text: "This paragraph is long enough to read as ordinary prose, not a label.", + hitCount: 5, + }); + expect(issues.some((issue) => issue.code === "text_occluded")).toBe(true); + }); }); +// Mirrors OCCLUSION_PROBE_Y_FRACTIONS / OCCLUSION_PROBE_X_FRACTIONS in +// layout-audit.browser.js, so a test can force an exact number of grid hits +// against the same probe coordinates the audit itself sweeps. +const OCCLUSION_PROBE_Y_FRACTIONS = [0.25, 0.5, 0.75]; +const OCCLUSION_PROBE_X_FRACTIONS = [0.03, 0.1, 0.2, 0.35, 0.5, 0.65, 0.8, 0.9, 0.97]; + +function occlusionProbePoints(textRect: RectInput): Array<{ x: number; y: number }> { + const points: Array<{ x: number; y: number }> = []; + for (const yFraction of OCCLUSION_PROBE_Y_FRACTIONS) { + const y = textRect.top + textRect.height * yFraction; + for (const xFraction of OCCLUSION_PROBE_X_FRACTIONS) { + points.push({ x: textRect.left + textRect.width * xFraction, y }); + } + } + return points; +} + +// Builds an occlusion scene where exactly `hitCount` of the 27 probe points +// are covered by an opaque overlay and the rest hit the headline itself +// (self-hit — not foreign, so not counted as occluded). +function auditCoverageScene(options: { + text: string; + hitCount: number; +}): ReturnType { + const textRect = { left: 200, top: 500, width: 600, height: 80 }; + document.body.innerHTML = ` +
+
${options.text}
+
+
+ `; + installOcclusionGeometry({ + styleOverrides: { overlay: { backgroundColor: "rgb(10, 10, 10)" } }, + headlineTextRect: rect(textRect), + topmostId: "headline", + }); + const hitPoints = occlusionProbePoints(textRect).slice(0, options.hitCount); + ( + document as unknown as { elementFromPoint: (x: number, y: number) => Element | null } + ).elementFromPoint = (x, y) => { + const isHit = hitPoints.some( + (point) => Math.abs(point.x - x) < 0.01 && Math.abs(point.y - y) < 0.01, + ); + return document.getElementById(isHit ? "overlay" : "headline"); + }; + installAuditScript(); + return runAudit(); +} + function auditOcclusionScene(options: { headlineAttrs?: string; overlayStyle: Partial>; @@ -812,22 +896,19 @@ async function runContrastAudit(): Promise>> { return w.__contrastAuditFinish("stub", 0, candidates); } -function runAudit(): Array<{ +interface AuditIssue { code: string; selector: string; containerSelector?: string; overflow?: Record; message?: string; -}> { + coveredFraction?: number; +} + +function runAudit(): AuditIssue[] { const audit = ( window as unknown as { - __hyperframesLayoutAudit: (options: { time: number; tolerance: number }) => Array<{ - code: string; - selector: string; - containerSelector?: string; - overflow?: Record; - message?: string; - }>; + __hyperframesLayoutAudit: (options: { time: number; tolerance: number }) => AuditIssue[]; } ).__hyperframesLayoutAudit; return audit({ time: 1, tolerance: 2 }); diff --git a/packages/cli/src/commands/layout.test.ts b/packages/cli/src/commands/layout.test.ts index bd7cdb4d72..ab63315c5e 100644 --- a/packages/cli/src/commands/layout.test.ts +++ b/packages/cli/src/commands/layout.test.ts @@ -1,6 +1,12 @@ -import type { CommandDef } from "citty"; -import { runCommand } from "citty"; 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 @@ -9,37 +15,11 @@ import { afterEach, describe, expect, it, vi } from "vitest"; // 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. -const FAKE_PROJECT = { - dir: "/fake-project", - name: "fake-project", - indexPath: "/fake-project/index.html", -}; - -vi.mock("../utils/project.js", () => ({ - resolveProject: vi.fn(() => FAKE_PROJECT), -})); - -vi.mock("@hyperframes/core/compiler", () => ({ - bundleToSingleHtml: vi.fn(async () => { - throw new Error("bundling failed (test double)"); - }), -})); +vi.mock("../utils/project.js", () => resolveProjectMock()); +vi.mock("@hyperframes/core/compiler", () => bundleToSingleHtmlFailureMock()); import { createInspectCommand } from "./layout.js"; -/** - * citty's `meta` is `Resolvable` (object | promise | thunk). - * This file's commands always define it as a synchronous object literal, so - * narrow to that shape instead of asserting it with `as`. - */ -function metaDescription(command: CommandDef): string { - const meta = command.meta; - if (meta && typeof meta === "object" && "description" in meta) { - return String(meta.description ?? ""); - } - throw new Error("expected a synchronous meta object"); -} - afterEach(() => { vi.restoreAllMocks(); }); @@ -51,53 +31,20 @@ describe("layout command deprecation (U5)", () => { }); it("prints a one-line deprecation notice to stderr and never to stdout", async () => { - 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(createInspectCommand("layout"), { rawArgs: ["--json"] }); - - const stderrText = stderrWrites.join(""); + const { stderrText, stdoutText } = await runAndCaptureStdio(createInspectCommand("layout")); expect(stderrText).toContain("hyperframes layout"); expect(stderrText).toContain("hyperframes check"); - expect(stdoutWrites.join("")).toBe(""); + expect(stdoutText).toBe(""); }); it("--json output is valid JSON with _meta.deprecated === true on failure", async () => { - vi.spyOn(process.stderr, "write").mockImplementation(() => true); - vi.spyOn(process, "exit").mockImplementation(() => undefined as never); - const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - - await runCommand(createInspectCommand("layout"), { rawArgs: ["--json"] }); - - const jsonCall = logSpy.mock.calls.find( - ([arg]) => typeof arg === "string" && arg.trim().startsWith("{"), - ); - expect(jsonCall).toBeDefined(); - const parsed = JSON.parse(String(jsonCall?.[0])); + 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 () => { - vi.spyOn(process.stderr, "write").mockImplementation(() => true); - vi.spyOn(process, "exit").mockImplementation(() => undefined as never); - const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - - await runCommand(createInspectCommand("inspect"), { rawArgs: ["--json"] }); - - const jsonCall = logSpy.mock.calls.find( - ([arg]) => typeof arg === "string" && arg.trim().startsWith("{"), - ); + const jsonCall = await runAndFindJsonLogCall(createInspectCommand("inspect")); const parsed = JSON.parse(String(jsonCall?.[0])); expect(parsed._meta.deprecated).toBe(true); }); diff --git a/packages/cli/src/commands/layout.ts b/packages/cli/src/commands/layout.ts index 1fb0f3a28a..82bea53773 100644 --- a/packages/cli/src/commands/layout.ts +++ b/packages/cli/src/commands/layout.ts @@ -529,7 +529,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); diff --git a/packages/cli/src/commands/validate.test.ts b/packages/cli/src/commands/validate.test.ts index f35f3ffd86..7b5aebaa0b 100644 --- a/packages/cli/src/commands/validate.test.ts +++ b/packages/cli/src/commands/validate.test.ts @@ -1,6 +1,15 @@ -import type { CommandDef } from "citty"; -import { runCommand } from "citty"; 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, @@ -37,21 +46,8 @@ vi.mock("../utils/producer.js", () => ({ // (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. -const FAKE_PROJECT = { - dir: "/fake-project", - name: "fake-project", - indexPath: "/fake-project/index.html", -}; - -vi.mock("../utils/project.js", () => ({ - resolveProject: vi.fn(() => FAKE_PROJECT), -})); - -vi.mock("../utils/lintProject.js", () => ({ - lintProject: vi.fn(async () => { - throw new Error("lint failed (test double)"); - }), -})); +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, @@ -308,14 +304,6 @@ describe("navigationTimeoutHint", () => { }); }); -function metaDescription(command: CommandDef): string { - const meta = command.meta; - if (meta && typeof meta === "object" && "description" in meta) { - return String(meta.description ?? ""); - } - throw new Error("expected a synchronous meta object"); -} - describe("validate command deprecation (U5)", () => { afterEach(() => { vi.restoreAllMocks(); @@ -327,41 +315,16 @@ describe("validate command deprecation (U5)", () => { }); it("prints a one-line deprecation notice to stderr and never to stdout", async () => { - 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(() => {}); - const { default: validateCommand } = await import("./validate.js"); - await runCommand(validateCommand, { rawArgs: ["--json"] }); - - const stderrText = stderrWrites.join(""); + const { stderrText, stdoutText } = await runAndCaptureStdio(validateCommand); expect(stderrText).toContain("hyperframes validate"); expect(stderrText).toContain("hyperframes check"); - expect(stdoutWrites.join("")).toBe(""); + expect(stdoutText).toBe(""); }); it("--json output is valid JSON with _meta.deprecated === true on failure", async () => { - vi.spyOn(process.stderr, "write").mockImplementation(() => true); - vi.spyOn(process, "exit").mockImplementation(() => undefined as never); - const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - const { default: validateCommand } = await import("./validate.js"); - await runCommand(validateCommand, { rawArgs: ["--json"] }); - - const jsonCall = logSpy.mock.calls.find( - ([arg]) => typeof arg === "string" && arg.trim().startsWith("{"), - ); - expect(jsonCall).toBeDefined(); - const parsed = JSON.parse(String(jsonCall?.[0])); + const { parsed } = await runAndParseJsonEnvelope(validateCommand); expect(parsed.ok).toBe(false); expect(parsed._meta.deprecated).toBe(true); }); diff --git a/packages/cli/src/utils/checkBrowser.ts b/packages/cli/src/utils/checkBrowser.ts index da777d59fe..8978e2cb8a 100644 --- a/packages/cli/src/utils/checkBrowser.ts +++ b/packages/cli/src/utils/checkBrowser.ts @@ -256,6 +256,7 @@ function createPageDriver(page: Page, setTime: (time: number) => void): CheckAud await seekCompositionTimeline(page, time, SEEK_OPTIONS); }, collectLayout: (time, tolerance) => collectLayout(page, time, tolerance), + collectLayoutGeometry: () => collectLayoutGeometry(page), collectGeometryCandidates: (time, request) => collectGeometryCandidates(page, time, request), collectMotionFrame: (time, selectors, scopes) => collectMotionFrame(page, time, selectors, scopes), @@ -368,6 +369,15 @@ async function collectLayout( return anchorLayoutIssues(page, raw.flatMap(parseLayoutIssue)); } +async function collectLayoutGeometry(page: Page): Promise { + return page.evaluate(() => { + const geometry = Reflect.get(window, "__hyperframesLayoutGeometry"); + if (typeof geometry !== "function") return ""; + const result = Reflect.apply(geometry, window, []); + return typeof result === "string" ? result : ""; + }); +} + async function collectGeometryCandidates( page: Page, time: number, @@ -814,6 +824,8 @@ function assignOptionalLayoutFields(issue: LayoutIssue, value: Record | null { diff --git a/packages/cli/src/utils/checkPipeline.ts b/packages/cli/src/utils/checkPipeline.ts index 0f1b08684e..0a6ae30085 100644 --- a/packages/cli/src/utils/checkPipeline.ts +++ b/packages/cli/src/utils/checkPipeline.ts @@ -186,6 +186,8 @@ interface GridSamples { contrastEntries: ContrastAuditEntry[]; screenshots: CheckScreenshot[]; contrastMs: number; + /** One geometry+opacity fingerprint per layout sample (#U10 frozen-sweep guard). */ + geometrySignatures: string[]; } interface GeometrySeen { @@ -356,6 +358,7 @@ async function collectGridSamples( contrastEntries: [], screenshots: [], contrastMs: 0, + geometrySignatures: [], }; for (const time of mergeSampleTimes(grid.layoutSamples, motion.times)) { await driver.seek(time); @@ -367,6 +370,7 @@ async function collectGridSamples( 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( @@ -401,6 +405,55 @@ async function collectGridSamples( 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. */ @@ -431,6 +484,11 @@ export async function runAuditGrid( ); motionIssues = await driver.anchorMotionIssues(evaluated); } + const sweepFindings = detectSweepStatic( + grid.duration, + collected.geometrySignatures, + motionIssues, + ); const contrast = buildContrastResults(collected.contrastEntries); return { duration: grid.duration, @@ -438,7 +496,7 @@ export async function runAuditGrid( transitionSamples: grid.transitionSamples, transitionSamplesDropped: grid.transitionSamplesDropped, runtimeFindings: [], - layoutIssues: collected.layoutIssues, + layoutIssues: [...collected.layoutIssues, ...sweepFindings], motionIssues, motionSampleCount: collected.motionFrames.length, contrastSamples: grid.contrastSamples, @@ -719,7 +777,7 @@ function shapeLayoutSection( browser: CheckBrowserResult, options: CheckOptions, ): CheckReport["layout"] { - const shaped = shapeLayoutFindings(issues, options); + const shaped = shapeLayoutFindings(issues, options, browser.layoutSamples.length); return { ...section(shaped.findings), duration: browser.duration, @@ -735,9 +793,12 @@ function shapeLayoutSection( function shapeLayoutFindings( issues: AnchoredLayoutIssue[], options: CheckOptions, + totalSampleCount?: number, ): { findings: AnchoredLayoutIssue[]; totalIssueCount: number; truncated: boolean } { const deduped = dedupeLayoutIssues(issues); - const all = options.collapseStatic ? collapseStaticLayoutIssues(deduped) : deduped; + const all = options.collapseStatic + ? collapseStaticLayoutIssues(deduped, totalSampleCount) + : deduped; const limited = limitLayoutIssues(all, options.maxIssues); return { findings: limited.issues.map(ensureAnchoredLayoutIssue), diff --git a/packages/cli/src/utils/checkTypes.ts b/packages/cli/src/utils/checkTypes.ts index 63d97a3a9e..8625c61a28 100644 --- a/packages/cli/src/utils/checkTypes.ts +++ b/packages/cli/src/utils/checkTypes.ts @@ -131,6 +131,10 @@ export interface CheckAuditDriver { findAmbiguousSelectors(selectors: string[]): Promise; seek(time: number): Promise; collectLayout(time: number, tolerance: number): Promise; + /** 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; collectGeometryCandidates( time: number, request: GeometryCandidateRequest, diff --git a/packages/cli/src/utils/layoutAudit.test.ts b/packages/cli/src/utils/layoutAudit.test.ts index 6c4ffc9704..ba57171033 100644 --- a/packages/cli/src/utils/layoutAudit.test.ts +++ b/packages/cli/src/utils/layoutAudit.test.ts @@ -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, diff --git a/packages/cli/src/utils/layoutAudit.ts b/packages/cli/src/utils/layoutAudit.ts index e006bf673c..6ba63ccb93 100644 --- a/packages/cli/src/utils/layoutAudit.ts +++ b/packages/cli/src/utils/layoutAudit.ts @@ -18,6 +18,9 @@ export type LayoutIssueCode = | "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" @@ -42,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; } @@ -164,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 = 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, { @@ -193,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( diff --git a/packages/producer/src/logger.test.ts b/packages/producer/src/logger.test.ts index 8e767d36f5..90b6797a2f 100644 --- a/packages/producer/src/logger.test.ts +++ b/packages/producer/src/logger.test.ts @@ -2,6 +2,52 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createConsoleLogger, defaultLogger } from "./logger.js"; import type { LogLevel, ProducerLogger } from "./logger.js"; +// `isLevelEnabled` is optional on ProducerLogger, so every call site guards +// it with `?.`; pulled out once so the loops below stay single-branch. +function isLevelEnabledSafe( + log: Pick, + level: LogLevel, +): boolean | undefined { + return log.isLevelEnabled?.(level); +} + +// Shared by the isLevelEnabled matrix cases below: assert a threshold's +// enabled levels report true and its disabled levels report false. +function assertLevelEnabledMatrix( + log: Pick, + enabled: ReadonlyArray, + disabled: ReadonlyArray, +): void { + for (const lvl of enabled) { + expect(isLevelEnabledSafe(log, lvl)).toBe(true); + } + for (const lvl of disabled) { + expect(isLevelEnabledSafe(log, lvl)).toBe(false); + } +} + +// The `isLevelEnabled?.("debug") ?? true` call-site gate pattern itself, +// isolated so runGatedDebugLoop's own branch count stays at "loop + if". +function isDebugGated(log: Pick): boolean { + return log.isLevelEnabled?.("debug") ?? true; +} + +// Shared by the call-site gate cases below: run the `isLevelEnabled?.("debug") +// ?? true` pattern callers use to skip expensive meta construction, the +// exact number of times the test needs, so each test asserts only the +// pattern's outcome (buildCount / logged calls) and not the loop mechanics. +function runGatedDebugLoop( + log: Pick, + iterations: number, + buildMeta: () => Record, +): void { + for (let i = 0; i < iterations; i++) { + if (isDebugGated(log)) { + log.debug("evt", buildMeta()); + } + } +} + describe("createConsoleLogger", () => { // We capture calls to console.{log,warn,error} via `vi.fn` so we can // assert what would have been printed without polluting test output. @@ -194,12 +240,7 @@ describe("createConsoleLogger", () => { for (const { threshold, enabled, disabled } of cases) { it(`level=${threshold} reports enabled levels correctly`, () => { const log = createConsoleLogger(threshold); - for (const lvl of enabled) { - expect(log.isLevelEnabled?.(lvl)).toBe(true); - } - for (const lvl of disabled) { - expect(log.isLevelEnabled?.(lvl)).toBe(false); - } + assertLevelEnabledMatrix(log, enabled, disabled); }); } @@ -214,11 +255,7 @@ describe("createConsoleLogger", () => { return { expensive: true }; }; - for (let i = 0; i < 100; i++) { - if (log.isLevelEnabled?.("debug") ?? true) { - log.debug("hot-loop", buildMeta()); - } - } + runGatedDebugLoop(log, 100, buildMeta); expect(buildCount).toBe(0); expect(errorSpy.mock.calls.length).toBe(0); @@ -232,11 +269,7 @@ describe("createConsoleLogger", () => { return { iter: buildCount }; }; - for (let i = 0; i < 5; i++) { - if (log.isLevelEnabled?.("debug") ?? true) { - log.debug("loop", buildMeta()); - } - } + runGatedDebugLoop(log, 5, buildMeta); expect(buildCount).toBe(5); expect(errorSpy.mock.calls.length).toBe(5); @@ -260,11 +293,7 @@ describe("createConsoleLogger", () => { return { i: buildCount }; }; - for (let i = 0; i < 3; i++) { - if (customLog.isLevelEnabled?.("debug") ?? true) { - customLog.debug("evt", buildMeta()); - } - } + runGatedDebugLoop(customLog, 3, buildMeta); expect(buildCount).toBe(3); expect(calls).toHaveLength(3); From b218ee744397a0f27eb16586db9c7691049903e7 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 10 Jul 2026 04:00:37 -0400 Subject: [PATCH 08/16] chore(examples): mark product-promo cursor and comment layering intentional The persistence-tier promotion surfaced two held content_overlap errors on the hero heading; zooming in shows the collaborative-cursor demo (cursor badge + typed comment bubble) hovering by design. Opt out with data-layout-allow-overlap per the finding's own fixHint. The example's 4 pre-existing gsap_css_transform_conflict lint errors stay for a separate example-maintenance pass. --- .../product-promo/compositions/scene2-4-canvas.html | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/registry/examples/product-promo/compositions/scene2-4-canvas.html b/registry/examples/product-promo/compositions/scene2-4-canvas.html index 41fa0db250..2c9537337a 100644 --- a/registry/examples/product-promo/compositions/scene2-4-canvas.html +++ b/registry/examples/product-promo/compositions/scene2-4-canvas.html @@ -72,7 +72,7 @@

Design without limits

-
+
@@ -82,19 +82,19 @@

Design without limits

-
+
Designer
-
+
Engineer
-
+
From cf7c1d7609cb7816eaaa5e6651d53dd3873de162 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 10 Jul 2026 12:47:14 -0400 Subject: [PATCH 09/16] docs(cli,skills): teach check as the canonical verification gate Scaffolded projects' npm run check now invokes the single check command instead of chaining lint, validate, and inspect (three Chrome boots become one). The CLI skill, its correctness reference, the entry skill's capability map, README/docs catalog rows, the Mintlify CLI page (new check section, deprecation banner on inspect), template CLAUDE/AGENTS (byte-identical), root CLAUDE/AGENTS, and every creation-workflow skill that taught the old sequence all point at check. snapshot keeps its standalone sections; validate/inspect stay documented as deprecated aliases with their check equivalents. --- AGENTS.md | 2 +- CLAUDE.md | 4 +- README.md | 2 +- docs/contributing/catalog.mdx | 2 +- docs/guides/pipeline.mdx | 2 +- docs/guides/prompting.mdx | 2 +- docs/guides/skills.mdx | 2 +- docs/guides/video-editor-cheatsheet.mdx | 10 +- docs/packages/cli.mdx | 30 +++++ packages/cli/src/commands/init.test.ts | 6 +- packages/cli/src/commands/init.ts | 4 +- packages/cli/src/templates/_shared/AGENTS.md | 4 +- packages/cli/src/templates/_shared/CLAUDE.md | 4 +- skills-manifest.json | 32 +++--- skills/faceless-explainer/SKILL.md | 4 +- skills/figma/SKILL.md | 2 +- skills/general-video/SKILL.md | 4 +- .../hyperframes-animation/adapters/animejs.md | 2 +- .../adapters/css-animations.md | 2 +- .../hyperframes-animation/adapters/lottie.md | 2 +- .../hyperframes-animation/adapters/three.md | 2 +- .../hyperframes-animation/adapters/waapi.md | 2 +- .../rules/motion-blur-streak.md | 2 +- skills/hyperframes-cli/SKILL.md | 27 ++--- .../references/lint-validate-inspect.md | 106 +++++++++--------- skills/hyperframes-core/SKILL.md | 4 +- .../references/data-attributes.md | 2 +- .../hyperframes-core/references/tailwind.md | 6 +- .../references/house-style.md | 2 +- skills/hyperframes-keyframes/SKILL.md | 2 +- .../references/contributing.md | 4 +- skills/hyperframes/SKILL.md | 2 +- skills/motion-graphics/SKILL.md | 2 +- skills/motion-graphics/agents/builder.md | 2 +- skills/motion-graphics/agents/finalize.md | 2 +- .../references/builder-contract.md | 2 +- skills/music-to-video/SKILL.md | 2 +- skills/pr-to-video/SKILL.md | 4 +- skills/product-launch-video/SKILL.md | 4 +- skills/slideshow/SKILL.md | 2 +- skills/website-to-video/SKILL.md | 2 +- 41 files changed, 164 insertions(+), 141 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7264b1adb8..1c1cd9eff3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,7 +51,7 @@ After creating or editing any `.html` composition: ```bash npx hyperframes lint # Static HTML structure check -npx hyperframes validate # Runtime check (headless Chrome — catches JS errors, missing assets) +npx hyperframes check # Browser gate (headless Chrome — runtime errors, layout, motion, WCAG contrast) ``` Both must pass before previewing or considering work complete. diff --git a/CLAUDE.md b/CLAUDE.md index 7c895db16d..abd8a019f2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,7 +39,7 @@ Atomic capabilities the creation workflows compose against — pull one when you - `/hyperframes-keyframes` — seek-safe keyframe authoring across runtimes: GSAP timelines, CSS keyframes, Anime.js, WAAPI, FLIP, paths, masks, SVG morph/draw, text trails, 3D depth; plus `hyperframes keyframes` diagnostics for surfacing and verifying rendered motion. - `/hyperframes-creative` — non-animation creative direction: `frame.md` / `design.md` handling, palettes, typography, narration, beat planning, audio-reactive visuals, composition patterns. - `/media-use` — the media OS: resolve any media need (BGM, SFX, image, icon, logo, voice, color grade, LUT) into a frozen local file or paste-ready block + ledger record; generate via TTS / music / image models when the catalog misses; transcribe, caption, remove backgrounds, and reuse assets across projects. One shared `scripts/audio.mjs` engine + manifest tracking; keeps search noise on disk. -- `/hyperframes-cli` — CLI dev loop: `init`, `add`, `lint`, `validate`, `inspect`, `preview`, `render`, `publish`, `doctor`, `lambda` (AWS Lambda cloud rendering). +- `/hyperframes-cli` — CLI dev loop: `init`, `add`, `lint`, `check`, `snapshot`, `preview`, `render`, `publish`, `doctor`, `lambda` (AWS Lambda cloud rendering). - `/hyperframes-registry` — install and wire registry blocks and components into compositions via `hyperframes add`. Covers authoring a new block or component to contribute upstream. - `/figma` — import Figma assets, tokens, components, and storyboard sections → reconstructed motion (frames read as states, not slides) (REST/CLI) plus Motion animations (MCP) and shaders (MCP source / native export) into a composition. @@ -81,7 +81,7 @@ After creating or editing any `.html` composition: ```bash npx hyperframes lint # Static HTML structure check -npx hyperframes validate # Runtime check (headless Chrome — catches JS errors, missing assets) +npx hyperframes check # Browser gate (headless Chrome — runtime errors, layout, motion, WCAG contrast) ``` Both must pass before previewing or considering work complete. diff --git a/README.md b/README.md index e6b2a4a7bb..d12516acef 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ Atomic capabilities the creation workflows compose against — pull one when you | `/hyperframes-keyframes` | Seek-safe keyframe authoring across runtimes — GSAP timelines, CSS keyframes, Anime.js, WAAPI, FLIP, paths, masks, SVG morph/draw, 3D depth — plus `hyperframes keyframes` diagnostics for rendered motion. | | `/hyperframes-creative` | Non-animation creative direction — `frame.md` / `design.md`, palettes, typography, narration, beat planning, audio-reactive visuals, composition patterns. | | `/media-use` | The media OS — resolve any media need (BGM, SFX, image, icon, logo, voice, color grade, LUT) into a frozen local file or paste-ready block + ledger record, generate via TTS/music/image models when the catalog misses, transcribe, caption, remove backgrounds, and reuse assets across projects. One shared audio engine + manifest tracking. | -| `/hyperframes-cli` | CLI dev loop — `init`, `lint`, `validate`, `inspect`, `preview`, `render`, `publish`, `doctor`, plus AWS Lambda cloud rendering (`lambda deploy / render / progress`). | +| `/hyperframes-cli` | CLI dev loop — `init`, `lint`, `check`, `snapshot`, `preview`, `render`, `publish`, `doctor`, plus AWS Lambda cloud rendering (`lambda deploy / render / progress`). | | `/hyperframes-registry` | Install and wire registry blocks and components into compositions via `hyperframes add`. Authoring a new block or component to contribute upstream. | | `/figma` | Import Figma assets, tokens, components, and storyboard sections → reconstructed motion (frames read as states, not slides) (REST/CLI) plus Motion animations (MCP) and shaders (MCP source / native export) into a composition. | diff --git a/docs/contributing/catalog.mdx b/docs/contributing/catalog.mdx index a630a41e23..be61d7f571 100644 --- a/docs/contributing/catalog.mdx +++ b/docs/contributing/catalog.mdx @@ -141,7 +141,7 @@ Not everything belongs in the registry. The bar is production quality. ```bash hyperframes lint - hyperframes validate + hyperframes check npx oxfmt your-block.html ``` diff --git a/docs/guides/pipeline.mdx b/docs/guides/pipeline.mdx index c701ddb7ac..8298adf986 100644 --- a/docs/guides/pipeline.mdx +++ b/docs/guides/pipeline.mdx @@ -165,7 +165,7 @@ Three checks before delivery: ```bash npx hyperframes lint # static HTML structure checks -npx hyperframes validate # loads in headless Chrome, catches runtime errors +npx hyperframes check # one browser session: runtime errors, layout, motion, contrast npx hyperframes snapshot my-video --at 2.9,10.4 # PNGs at beat midpoints ``` diff --git a/docs/guides/prompting.mdx b/docs/guides/prompting.mdx index 49907ccc6a..fe78e69047 100644 --- a/docs/guides/prompting.mdx +++ b/docs/guides/prompting.mdx @@ -268,7 +268,7 @@ Things that cause friction (or wrong output): - **Don't ask for React / Vue components.** Hyperframes compositions are plain HTML with `data-*` attributes and a GSAP timeline. Asking for "a React component for the intro" forces the agent to translate later. - **Don't ask for 4K or 60fps unless you need it.** Defaults (1920×1080, 30fps) render fast and look great. Higher specs slow rendering meaningfully. - **Don't skip the slash command.** Without `/hyperframes`, the agent may guess at HTML video conventions instead of using the framework's actual rules (`class="clip"` on timed elements, `window.__timelines` registration, etc.). -- **Don't paste long error logs into the prompt without context.** Run `npx hyperframes lint` and `npx hyperframes validate` first — lint catches structural issues, validate catches runtime errors (JS exceptions, missing assets, contrast problems). +- **Don't paste long error logs into the prompt without context.** Run `npx hyperframes check` first — lint catches structural issues, validate catches runtime errors (JS exceptions, missing assets, contrast problems). - **Don't assume the agent knows your assets.** Mention file paths explicitly (`assets/intro.mp4`, `assets/logo.png`) — the agent will check what's there but a hint speeds it up. ## Recommended workflow diff --git a/docs/guides/skills.mdx b/docs/guides/skills.mdx index 9579f7df62..254d85aea3 100644 --- a/docs/guides/skills.mdx +++ b/docs/guides/skills.mdx @@ -99,7 +99,7 @@ Atomic capabilities the creation workflows compose against — pull one when you | `/hyperframes-keyframes` | Seek-safe keyframe authoring across runtimes — GSAP timelines, CSS keyframes, Anime.js, WAAPI, FLIP, paths, masks, SVG morph/draw, 3D depth — plus `hyperframes keyframes` diagnostics for rendered motion. | | `/hyperframes-creative` | Non-animation creative direction — `frame.md` / `design.md`, palettes, typography, narration, beat planning, audio-reactive visuals, composition patterns. | | `/media-use` | The media OS — resolve any media need (BGM, SFX, image, icon, logo, voice, color grade, LUT) into a frozen local file or paste-ready block + ledger record, generate via TTS/music/image models when the catalog misses, transcribe, caption, remove backgrounds, and reuse assets across projects. One shared audio engine + manifest tracking. | -| `/hyperframes-cli` | CLI dev loop — `init`, `lint`, `validate`, `inspect`, `preview`, `render`, `publish`, `doctor`, plus AWS Lambda cloud rendering (`lambda deploy / render / progress / destroy / policies`). | +| `/hyperframes-cli` | CLI dev loop — `init`, `lint`, `check`, `snapshot`, `preview`, `render`, `publish`, `doctor`, plus AWS Lambda cloud rendering (`lambda deploy / render / progress / destroy / policies`). | | `/hyperframes-registry` | Install and wire registry blocks and components into compositions via `hyperframes add`. Authoring a new block or component to contribute upstream. | | `/figma` | Import Figma assets, tokens, components, and storyboard sections → reconstructed motion (frames read as states, not slides) (REST/CLI) plus Motion animations (MCP) and shaders (MCP source / native export) into a composition. | diff --git a/docs/guides/video-editor-cheatsheet.mdx b/docs/guides/video-editor-cheatsheet.mdx index e9216f6bf9..16e3431ab6 100644 --- a/docs/guides/video-editor-cheatsheet.mdx +++ b/docs/guides/video-editor-cheatsheet.mdx @@ -27,7 +27,7 @@ Before showing or rendering a project: ```bash npx hyperframes lint -npx hyperframes validate +npx hyperframes check npx hyperframes render --quality standard --output review.mp4 ``` @@ -174,8 +174,8 @@ I moved the hero title and resized the CTA manually in Studio. Inspect the chang | `npx hyperframes capture https://example.com` | Capture a website as source material for a video | | `npx hyperframes preview` | Open the live Studio preview | | `npx hyperframes lint` | Catch structural mistakes before preview or render | -| `npx hyperframes validate` | Run the composition in headless Chrome to catch runtime errors | -| `npx hyperframes inspect` | Find text overflow and layout problems across the timeline | +| `npx hyperframes check` | Run the composition in headless Chrome to catch runtime errors | +| `npx hyperframes check` | Find text overflow and layout problems across the timeline | | `npx hyperframes snapshot --at 1,3,5` | Save PNG checks at exact timestamps | | `npx hyperframes render --output final.mp4` | Render the video | | `npx hyperframes publish` | Upload the project and get a shareable HyperFrames URL | @@ -278,9 +278,9 @@ For editor-facing changes, keep `npx hyperframes preview` running, then have the | --- | --- | | Preview will not start | `npx hyperframes doctor` | | Port already in use | `npx hyperframes preview --port 4567` | -| Render fails | `npx hyperframes lint` then `npx hyperframes validate` | +| Render fails | `npx hyperframes lint` then `npx hyperframes check` | | Need exact frame checks | `npx hyperframes snapshot --at 1,2.5,5` | -| Text overflows in the frame | `npx hyperframes inspect` | +| Text overflows in the frame | `npx hyperframes check` | | Final render is too slow | Try `--quality draft`, reduce image sizes, or lower `--fps` | | Need to share editable project | `npx hyperframes publish` | diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index bd48396259..91094b0ed2 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -520,6 +520,34 @@ Word-level transcripts (whisper output) are grouped into readable caption cues o The linter detects missing attributes, missing adapter libraries (GSAP, Lottie, Three.js), structural problems, and more. See [Common Mistakes](/guides/common-mistakes) for details on each rule. + ### `check` + + The browser verification gate: everything the old `validate` → `inspect` → `snapshot` loop did, in **one** command with one browser session: + + ```bash + npx hyperframes check [dir] + npx hyperframes check [dir] --json # {ok, lint, runtime, layout, motion, contrast, snapshots} + npx hyperframes check [dir] --snapshots # annotated overview frames + per-finding crops + npx hyperframes check [dir] --at 1.5,4,7.25 + npx hyperframes check [dir] --strict # exit non-zero on warnings too + ``` + + `check` runs the linter first (browser skipped entirely on lint errors), then loads the bundled composition once and sweeps one seek grid running every audit per sample: runtime console errors and failed requests, layout defects (overflow, clipping, held overlaps, occlusion), `*.motion.json` sidecar assertions, and WCAG AA contrast. + + | Flag | Description | + |------|-------------| + | `--json` | Aggregated machine-readable envelope; every finding carries selector, `data-*` identity, source file, bbox, and sample time | + | `--snapshots` | Write overview frames (annotated with labeled finding boxes when there are errors) plus `finding-NN-.png` crops | + | `--samples` / `--at` / `--at-transitions` | Control the seek grid (default 9 samples; `--at-transitions` adds tween boundaries) | + | `--tolerance` | Allowed overflow in px before reporting (default 2) | + | `--timeout` | Initial settle budget in ms (default 3000) | + | `--no-contrast` | Skip the WCAG audit while iterating | + | `--strict` | Exit non-zero on warnings too (default: only errors) | + | `--caption-zone ""` | Opt-in band gate: flags content whose center sits inside the fractional band (optional `severity`, `seek`) | + | `--frame-check` | Opt-in media out-of-frame detection (img/svg/video/canvas) | + + Contrast failures are **errors** and include the sampled fg/bg colors, measured vs required ratio, and a suggested compliant color. Severity is persistence-aware: single-sample transients demote to info, held findings gate the exit code, and a frozen timeline on a 3s+ composition fails with `sweep_static`. + ### `beats` Detect the beats in a composition's music track and write them to a beat file the Studio uses to draw beat guides on the timeline: @@ -549,6 +577,8 @@ Word-level transcripts (whisper output) are grouped into readable caption cues o ### `inspect` + Deprecated: use [`check`](#check) — it covers this layout sweep plus runtime, motion, and contrast in one browser session. `inspect` keeps working and marks `_meta.deprecated: true` in JSON output. + Inspect rendered visual layout across the composition timeline: ```bash diff --git a/packages/cli/src/commands/init.test.ts b/packages/cli/src/commands/init.test.ts index e913d61191..ef73b31be8 100644 --- a/packages/cli/src/commands/init.test.ts +++ b/packages/cli/src/commands/init.test.ts @@ -50,8 +50,7 @@ describe("hyperframes init flag rename", () => { 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", + check: "npx --yes hyperframes check", render: "npx --yes hyperframes render", publish: "npx --yes hyperframes publish", }); @@ -84,8 +83,7 @@ describe("hyperframes init flag rename", () => { }; expect(pkg.scripts).toMatchObject({ dev: "npx --yes hyperframes preview", - check: - "npx --yes hyperframes lint && npx --yes hyperframes validate && npx --yes hyperframes inspect", + check: "npx --yes hyperframes check", render: "npx --yes hyperframes render", publish: "npx --yes hyperframes publish", }); diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 3866a4fbc9..4bb625c7f4 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -224,9 +224,7 @@ function hyperframesScript(command: string): string { function buildPackageScripts(): Record { return { dev: hyperframesScript("preview"), - check: - `${hyperframesScript("lint")} && ${hyperframesScript("validate")} && ` + - `${hyperframesScript("inspect")}`, + check: hyperframesScript("check"), render: hyperframesScript("render"), publish: hyperframesScript("publish"), }; diff --git a/packages/cli/src/templates/_shared/AGENTS.md b/packages/cli/src/templates/_shared/AGENTS.md index a30e02dbe4..3e8a31aba6 100644 --- a/packages/cli/src/templates/_shared/AGENTS.md +++ b/packages/cli/src/templates/_shared/AGENTS.md @@ -33,7 +33,7 @@ The domain skills (`/hyperframes-core`, `/hyperframes-animation`, `/hyperframes- ```bash npm run dev # start the preview server (long-running — keep it alive in background) -npm run check # lint + validate + inspect +npm run check # lint + runtime + layout + motion + contrast (one command) npm run render # render to MP4 npm run publish # publish and get a shareable link npx hyperframes lint --verbose # include info-level findings @@ -76,7 +76,7 @@ After creating or editing any `.html` composition, **always** run the full check npm run check ``` -Fix all errors before presenting the result. Inspect warnings should be reviewed before rendering. +Fix all errors before presenting the result. Warnings should be reviewed before rendering. ## Key Rules diff --git a/packages/cli/src/templates/_shared/CLAUDE.md b/packages/cli/src/templates/_shared/CLAUDE.md index a30e02dbe4..3e8a31aba6 100644 --- a/packages/cli/src/templates/_shared/CLAUDE.md +++ b/packages/cli/src/templates/_shared/CLAUDE.md @@ -33,7 +33,7 @@ The domain skills (`/hyperframes-core`, `/hyperframes-animation`, `/hyperframes- ```bash npm run dev # start the preview server (long-running — keep it alive in background) -npm run check # lint + validate + inspect +npm run check # lint + runtime + layout + motion + contrast (one command) npm run render # render to MP4 npm run publish # publish and get a shareable link npx hyperframes lint --verbose # include info-level findings @@ -76,7 +76,7 @@ After creating or editing any `.html` composition, **always** run the full check npm run check ``` -Fix all errors before presenting the result. Inspect warnings should be reviewed before rendering. +Fix all errors before presenting the result. Warnings should be reviewed before rendering. ## Key Rules diff --git a/skills-manifest.json b/skills-manifest.json index e346b5d1cd..97daf2d5f3 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -6,43 +6,43 @@ "files": 144 }, "faceless-explainer": { - "hash": "09bc257e79dabebc", + "hash": "a09191a97564b114", "files": 18 }, "figma": { - "hash": "99538ee56a4ca553", + "hash": "0e6e96f5a76ff824", "files": 2 }, "general-video": { - "hash": "67f3dae100541eed", + "hash": "1aed9f4f68414a45", "files": 1 }, "hyperframes": { - "hash": "132596767485f923", + "hash": "fce43b4c3355cde9", "files": 1 }, "hyperframes-animation": { - "hash": "16ecdf00cf5cebf1", + "hash": "b982a1af9821e326", "files": 99 }, "hyperframes-cli": { - "hash": "9544d2cee786ebaf", + "hash": "f1c8c70693101e59", "files": 7 }, "hyperframes-core": { - "hash": "511a6283a5dbd7ea", + "hash": "507aecd0bb312a94", "files": 14 }, "hyperframes-creative": { - "hash": "d60c84ddca9ea6db", + "hash": "3e5bcbf46dc14427", "files": 69 }, "hyperframes-keyframes": { - "hash": "555c0cd491c40cea", + "hash": "040453e302a0e15b", "files": 3 }, "hyperframes-registry": { - "hash": "e3b389526834109d", + "hash": "5f49178cc43e100b", "files": 10 }, "media-use": { @@ -50,19 +50,19 @@ "files": 122 }, "motion-graphics": { - "hash": "0f1ac928e387a74c", + "hash": "dafcdce07d221aa4", "files": 23 }, "music-to-video": { - "hash": "5bb405421a7e19ba", + "hash": "ccb6181af8bcef09", "files": 132 }, "pr-to-video": { - "hash": "a93fde2b33b26e75", + "hash": "21dcf8aa94fc1033", "files": 22 }, "product-launch-video": { - "hash": "14404973ef5d38a0", + "hash": "158398dbfc4ab652", "files": 20 }, "remotion-to-hyperframes": { @@ -70,7 +70,7 @@ "files": 70 }, "slideshow": { - "hash": "114b57cf22b39068", + "hash": "0f0364c54f77cb9b", "files": 2 }, "talking-head-recut": { @@ -78,7 +78,7 @@ "files": 27 }, "website-to-video": { - "hash": "79af52a847abaa43", + "hash": "e8143700c50b7469", "files": 32 } } diff --git a/skills/faceless-explainer/SKILL.md b/skills/faceless-explainer/SKILL.md index 2f965990e8..a402597b79 100644 --- a/skills/faceless-explainer/SKILL.md +++ b/skills/faceless-explainer/SKILL.md @@ -177,9 +177,9 @@ Inject transitions, run checks, pause for review, then render. `npx hyperframes lint` -`npx hyperframes validate` +`npx hyperframes check` -`npx hyperframes inspect` +`npx hyperframes check` `npx hyperframes snapshot --at ` diff --git a/skills/figma/SKILL.md b/skills/figma/SKILL.md index b08f771743..605cd954d9 100644 --- a/skills/figma/SKILL.md +++ b/skills/figma/SKILL.md @@ -95,7 +95,7 @@ No REST equivalent exists. You drive the MCP tools, then hand output to the pure 2b. **Validate against ground truth before calling it done — mandatory**: `export_video` on the cohort's `rootNodeId` gives Figma's own render of the timeline. Run `node skills/figma/scripts/verify-motion.mjs --reference --render --crop WxH+X+Y` — it compares motion-energy deltas (static import fidelity cancels out) and fails below 15dB min motion-PSNR (calibrated: faithful ≈ 20+, diverging ≈ 5). Measure `--crop` from the render's actual card edges, don't guess. FAIL means re-check the translation, not the threshold. 3. `motionToGsap(doc)` → `emitTimelineScript(spec)` → inject as a `