feat(cli): add check — single-session verification gate

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.
This commit is contained in:
Miguel Angel Simon Sierra
2026-07-10 13:27:52 -04:00
parent feb256df8a
commit 7d6d41361b
11 changed files with 2335 additions and 5 deletions
@@ -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);
});
});
@@ -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<void>;
}
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 };
+1
View File
@@ -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),
+428
View File
@@ -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> = {}): 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> = {}): 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<typeof vi.fn> } {
const runBrowserCheck = vi.fn(
async (
_project: ProjectDir,
checkOptions: CheckOptions,
motion: MotionSpecResolution,
): Promise<CheckBrowserResult> => {
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<CheckOptions> = {},
dependencyOverrides: Parameters<typeof dependencies>[1] = {},
): Promise<{ report: CheckReport; deps: CheckDependencies; browser: ReturnType<typeof vi.fn> }> {
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\)/);
});
});
+262
View File
@@ -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<CheckReport>;
withMeta(value: object): object;
}
const DEFAULT_COMMAND_DEPENDENCIES: CheckCommandDependencies = {
resolveProject,
runPipeline: runCheckPipeline,
withMeta,
};
export function createCheckCommand(
dependencies: CheckCommandDependencies = DEFAULT_COMMAND_DEPENDENCIES,
) {
return defineCommand({
meta: {
name: "check",
description:
"Run lint, runtime, layout, motion, and WCAG contrast verification in one browser session",
},
args: {
dir: { type: "positional", description: "Project directory", required: false },
json: { type: "boolean", description: "Output agent-readable JSON", default: false },
samples: {
type: "string",
description: "Number of midpoint samples across the duration (default: 9)",
default: "9",
},
at: {
type: "string",
description: "Comma-separated timestamps in seconds (e.g., --at 1.5,4,7.25)",
},
"at-transitions": {
type: "boolean",
description:
"Also sample at every tween start/end boundary (plus segment midpoints) to catch transient overlaps at transition seams",
default: false,
},
"max-transition-samples": {
type: "string",
description:
"Optional cap on transition-derived samples; when it truncates, the omitted count is reported (default: unlimited)",
},
"max-issues": {
type: "string",
description: "Maximum issues to print or return after static collapse (default: 80)",
default: "80",
},
"collapse-static": {
type: "boolean",
description: "Collapse repeated static issues across samples (default: true)",
default: true,
},
tolerance: {
type: "string",
description: "Allowed pixel overflow before reporting an issue (default: 2)",
default: "2",
},
timeout: {
type: "string",
description: "Ms to wait for scripts and media to settle initially (default: 3000)",
default: "3000",
},
contrast: {
type: "boolean",
description: "Run the WCAG AA contrast pass (enabled by default)",
default: true,
},
strict: {
type: "boolean",
description: "Exit non-zero on warnings too",
default: false,
},
snapshots: {
type: "boolean",
description: "Save the five contrast-pass PNGs under snapshots/",
default: false,
},
},
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<string, unknown>): 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();
+56 -1
View File
@@ -1,5 +1,12 @@
import { describe, expect, it } from "vitest";
import { parseColorRGBA, pickOpaqueBackground } from "./contrast-bg.js";
import {
contrastRatio,
parseColorRGBA,
pickOpaqueBackground,
relativeLuminance,
requiredContrastRatio,
suggestCompliantForegroundColor,
} from "./contrast-bg.js";
const opaque = (bg: string) => ({ backgroundColor: bg, backgroundImage: "none" });
@@ -53,3 +60,51 @@ describe("pickOpaqueBackground", () => {
expect(pickOpaqueBackground([opaque("rgba(0, 0, 0, 0)")])).toBeNull();
});
});
describe("relativeLuminance", () => {
it("uses the WCAG sRGB transfer function", () => {
expect(relativeLuminance([0, 0, 0])).toBe(0);
expect(relativeLuminance([255, 255, 255])).toBe(1);
expect(relativeLuminance([255, 0, 0])).toBeCloseTo(0.2126, 4);
});
});
describe("contrastRatio", () => {
it("is symmetric and reaches 21:1 for black and white", () => {
expect(contrastRatio([0, 0, 0], [255, 255, 255])).toBe(21);
expect(contrastRatio([255, 255, 255], [0, 0, 0])).toBe(21);
});
});
describe("requiredContrastRatio", () => {
it("requires 3:1 for large text and 4.5:1 otherwise", () => {
expect(requiredContrastRatio(true)).toBe(3);
expect(requiredContrastRatio(false)).toBe(4.5);
});
});
describe("suggestCompliantForegroundColor", () => {
it("brightens a failing foreground on a dark background until it passes", () => {
const background: [number, number, number] = [20, 20, 20];
const foreground: [number, number, number] = [80, 80, 80];
const suggested = suggestCompliantForegroundColor(foreground, background, 4.5);
expect(suggested[0]).toBeGreaterThan(foreground[0]);
expect(contrastRatio(suggested, background)).toBeGreaterThanOrEqual(4.5);
});
it("darkens a failing foreground on a light background until it passes", () => {
const background: [number, number, number] = [245, 245, 245];
const foreground: [number, number, number] = [180, 180, 180];
const suggested = suggestCompliantForegroundColor(foreground, background, 4.5);
expect(suggested[0]).toBeLessThan(foreground[0]);
expect(contrastRatio(suggested, background)).toBeGreaterThanOrEqual(4.5);
});
it("preserves a foreground that already passes", () => {
expect(suggestCompliantForegroundColor([255, 255, 255], [0, 0, 0], 4.5)).toEqual([
255, 255, 255,
]);
});
});
+53
View File
@@ -20,6 +20,59 @@
export type Rgb = [number, number, number];
export type Rgba = [number, number, number, number];
/** WCAG relative luminance for an sRGB color. Mirrors contrast-audit.browser.js. */
export function relativeLuminance(color: Rgb): number {
const channel = (value: number) => {
const srgb = value / 255;
return srgb <= 0.03928 ? srgb / 12.92 : ((srgb + 0.055) / 1.055) ** 2.4;
};
return 0.2126 * channel(color[0]) + 0.7152 * channel(color[1]) + 0.0722 * channel(color[2]);
}
/** WCAG contrast ratio between two opaque sRGB colors. */
export function contrastRatio(first: Rgb, second: Rgb): number {
const firstLuminance = relativeLuminance(first);
const secondLuminance = relativeLuminance(second);
const lighter = Math.max(firstLuminance, secondLuminance);
const darker = Math.min(firstLuminance, secondLuminance);
return (lighter + 0.05) / (darker + 0.05);
}
/** WCAG AA minimum contrast for body or large text. */
export function requiredContrastRatio(large: boolean): number {
return large ? 3 : 4.5;
}
/**
* Find the nearest passing foreground on the line toward the higher-contrast
* pole: white for a dark background, black for a light background.
*/
export function suggestCompliantForegroundColor(
foreground: Rgb,
background: Rgb,
requiredRatio: number,
): Rgb {
if (contrastRatio(foreground, background) >= requiredRatio) return [...foreground];
const black: Rgb = [0, 0, 0];
const white: Rgb = [255, 255, 255];
const target =
contrastRatio(white, background) >= contrastRatio(black, background) ? white : black;
for (let step = 1; step <= 255; step += 1) {
const amount = step / 255;
const candidate: Rgb = [
Math.round(foreground[0] + (target[0] - foreground[0]) * amount),
Math.round(foreground[1] + (target[1] - foreground[1]) * amount),
Math.round(foreground[2] + (target[2] - foreground[2]) * amount),
];
if (contrastRatio(candidate, background) >= requiredRatio) return candidate;
}
return [...target];
}
/** Parse a CSS `rgb()`/`rgba()` string. Returns null if it is not rgb(a). */
export function parseColorRGBA(color: string | null | undefined): Rgba | null {
const body = /rgba?\(([^)]+)\)/.exec(color ?? "")?.[1];
+2 -2
View File
@@ -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(",")
+793
View File
@@ -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<CheckBrowserResult> {
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 '&lt;'")) {
return;
}
drafts.push({ code: "page_error", severity: "error", message, time: currentTime() });
});
wireNetworkListeners(page, drafts, currentTime);
}
function wireNetworkListeners(page: Page, drafts: RuntimeDraft[], currentTime: () => number): void {
page.on("requestfailed", (request) => {
const url = request.url();
if (url.includes("favicon") || url.startsWith("data:")) return;
const failure = request.failure()?.errorText;
if (shouldIgnoreRequestFailure(url, failure, request.resourceType())) return;
drafts.push({
code: "request_failed",
severity: "error",
message: `Failed to load ${urlPath(url)}: ${failure ?? "net::ERR_FAILED"}`,
time: currentTime(),
url,
});
});
page.on("response", (response) => {
if (response.status() < 400) return;
const url = response.url();
if (url.includes("favicon")) return;
drafts.push({
code: "http_error",
severity: "error",
message: `${response.status()} loading ${urlPath(url)}`,
time: currentTime(),
url,
});
});
}
function createPageDriver(page: Page, setTime: (time: number) => void): CheckAuditDriver {
return {
initialize: (contrast) => injectAuditScripts(page, contrast),
getDuration: () => getCompositionDuration(page),
getTransitionBoundaries: () => collectTweenBoundaries(page),
getCanvas: () =>
page.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight })),
findAmbiguousSelectors: (selectors) => findAmbiguousSelectors(page, selectors),
seek: async (time) => {
setTime(time);
await seekCompositionTimeline(page, time, 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<void> {
await page.addScriptTag({ content: loadBrowserScript("layout-audit.browser.js") });
await page.addScriptTag({ content: loadBrowserScript("motion-sample.browser.js") });
if (contrast) {
await page.addScriptTag({ content: loadBrowserScript("contrast-audit.browser.js") });
}
}
async function getCompositionDuration(page: Page): Promise<number> {
// Duration resolution is serialized into the page and must remain self-contained.
// fallow-ignore-next-line complexity
return page.evaluate(() => {
const value = (target: unknown, key: string): unknown =>
typeof target === "object" && target !== null ? Reflect.get(target, key) : undefined;
const positive = (candidate: unknown): number | null =>
typeof candidate === "number" && candidate > 0 ? candidate : null;
const callDuration = (target: unknown): number | null => {
const duration = value(target, "duration");
if (typeof duration === "function") {
const result = Reflect.apply(duration, target, []);
return positive(result);
}
return positive(duration);
};
const hfDuration = positive(value(Reflect.get(window, "__hf"), "duration"));
if (hfDuration) return hfDuration;
const playerDuration = callDuration(Reflect.get(window, "__player"));
if (playerDuration) return playerDuration;
const root = document.querySelector("[data-composition-id][data-duration]");
const authored = root ? parseFloat(root.getAttribute("data-duration") ?? "0") : 0;
if (authored > 0) return authored;
const timelines = Reflect.get(window, "__timelines");
if (typeof timelines !== "object" || timelines === null) return 0;
for (const key of Object.keys(timelines)) {
const duration = callDuration(Reflect.get(timelines, key));
if (duration) return duration;
}
return 0;
});
}
async function collectTweenBoundaries(page: Page): Promise<number[]> {
// GSAP getter binding and parent-time conversion form one serialized algorithm.
// fallow-ignore-next-line complexity
return page.evaluate(() => {
const property = (target: unknown, key: string): unknown =>
(typeof target === "object" && target !== null) || typeof target === "function"
? Reflect.get(target, key)
: undefined;
const numberCall = (target: unknown, key: string, fallback: number): number => {
const method = property(target, key);
if (typeof method !== "function") return fallback;
const result = Reflect.apply(method, target, []);
return typeof result === "number" ? result : fallback;
};
const rootTime = (root: unknown, animation: unknown, local: number): number => {
let time = local;
let node = animation;
while (node && node !== root) {
time = numberCall(node, "startTime", 0) + time / (numberCall(node, "timeScale", 1) || 1);
node = property(node, "parent");
}
return time;
};
const timelines = Reflect.get(window, "__timelines");
if (typeof timelines !== "object" || timelines === null) return [];
const boundaries: number[] = [];
for (const key of Object.keys(timelines)) {
const timeline = Reflect.get(timelines, key);
const getChildren = property(timeline, "getChildren");
if (typeof getChildren !== "function") continue;
try {
const children = Reflect.apply(getChildren, timeline, [true, true, false]);
if (!Array.isArray(children)) continue;
for (const child of children) {
const duration = numberCall(child, "duration", Number.NaN);
if (!Number.isFinite(duration)) continue;
boundaries.push(rootTime(timeline, child, 0), rootTime(timeline, child, duration));
}
} catch {
continue;
}
}
return boundaries.filter(Number.isFinite);
});
}
async function collectLayout(
page: Page,
time: number,
tolerance: number,
): Promise<AnchoredLayoutIssue[]> {
const raw = await page.evaluate(
(options: { time: number; tolerance: number }) => {
const audit = Reflect.get(window, "__hyperframesLayoutAudit");
if (typeof audit !== "function") return [];
const result = Reflect.apply(audit, window, [options]);
return Array.isArray(result) ? result : [];
},
{ time, tolerance },
);
return anchorLayoutIssues(page, raw.flatMap(parseLayoutIssue));
}
async function findAmbiguousSelectors(
page: Page,
selectors: string[],
): Promise<AnchoredLayoutIssue[]> {
const ambiguous = await page.evaluate(
(values: string[]) =>
values.filter((selector) => {
try {
return document.querySelectorAll(selector).length > 1;
} catch {
return false;
}
}),
selectors,
);
return anchorLayoutIssues(page, ambiguous.map(ambiguousIssue));
}
async function collectMotionFrame(
page: Page,
time: number,
selectors: string[],
livenessScopes: string[],
): Promise<MotionFrame> {
const raw = await page.evaluate(
(options: { selectors: string[]; livenessScopes: string[] }) => {
const sample = Reflect.get(window, "__hyperframesMotionSample");
if (typeof sample !== "function") return null;
return Reflect.apply(sample, window, [options]);
},
{ selectors, livenessScopes },
);
return parseMotionFrame(raw, time, selectors, livenessScopes);
}
async function anchorLayoutIssues(
page: Page,
issues: LayoutIssue[],
): Promise<AnchoredLayoutIssue[]> {
const requests = issues.map((issue) => ({
selector: issue.selector,
time: issue.time,
bbox: rectToBbox(issue.rect),
}));
const anchors = await resolveAnchors(page, requests);
return issues.map((issue, index) => ({
...issue,
...(anchors[index] ?? fallbackAnchor(requests[index])),
}));
}
async function resolveAnchors(page: Page, requests: AnchorRequest[]): Promise<CheckAnchor[]> {
if (requests.length === 0) return [];
return page.evaluate((values: AnchorRequest[]) => {
const root = document.querySelector("[data-composition-id]");
return values.map((request) => {
let element: Element | null = null;
try {
element = document.querySelector(request.selector);
} catch {
element = null;
}
element ??= root;
// Clones the anchor-extraction block in prepareContrast's evaluate() below;
// both run inside separate serialized browser closures and can't share a
// Node-side helper.
// fallow-ignore-next-line code-duplication
const dataAttributes: Record<string, string> = {};
for (const attribute of Array.from(element?.attributes ?? [])) {
if (attribute.name.startsWith("data-")) dataAttributes[attribute.name] = attribute.value;
}
const source = element
?.closest("[data-composition-file]")
?.getAttribute("data-composition-file");
return {
selector: element ? request.selector : "[data-composition-id]",
dataAttributes,
sourceFile: source || "index.html",
bbox: request.bbox,
time: request.time,
};
});
}, requests);
}
async function resolveRootAnchor(page: Page): Promise<CheckAnchor> {
const anchors = await resolveAnchors(page, [
{ selector: "[data-composition-id]", time: 0, bbox: await compositionBbox(page) },
]);
return anchors[0] ?? fallbackAnchor(undefined);
}
async function compositionBbox(page: Page): Promise<CheckBbox> {
return page.evaluate(() => {
const element = document.querySelector("[data-composition-id]");
const rect = element?.getBoundingClientRect();
return rect
? { x: rect.x, y: rect.y, width: rect.width, height: rect.height }
: { x: 0, y: 0, width: 0, height: 0 };
});
}
async function collectContrast(page: Page, time: number): Promise<ContrastCapture> {
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<unknown[]> {
// Candidate-to-element provenance must be captured while the prepare restore list is live.
// fallow-ignore-next-line complexity
return page.evaluate((sampleTime: number) => {
const prepare = Reflect.get(window, "__contrastAuditPrepare");
const candidates = typeof prepare === "function" ? Reflect.apply(prepare, window, []) : [];
if (!Array.isArray(candidates)) return [];
const restores = Reflect.get(window, "__contrastAuditRestores");
const restoreList = Array.isArray(restores) ? restores : [];
const escape = (value: string) =>
typeof CSS !== "undefined" && typeof CSS.escape === "function"
? CSS.escape(value)
: value.replace(/[^a-zA-Z0-9_-]/g, "\\$&");
const selectorFor = (element: Element | null, fallback: string): string => {
if (!element) return fallback;
if (element.id) return `#${escape(element.id)}`;
const parts: string[] = [];
for (
let current: Element | null = element;
current && current !== document.body;
current = current.parentElement
) {
const tag = current.tagName.toLowerCase();
const siblings = current.parentElement
? Array.from(current.parentElement.children).filter(
(item) => item.tagName === current?.tagName,
)
: [];
parts.push(
siblings.length > 1 ? `${tag}:nth-of-type(${siblings.indexOf(current) + 1})` : tag,
);
}
return parts.reverse().join(" > ") || fallback;
};
// Part of the serialized evaluate body above; cannot delegate to Node helpers.
// fallow-ignore-next-line complexity
return candidates.map((candidate, index) => {
const restore = restoreList[index];
const candidateObject = typeof candidate === "object" && candidate !== null ? candidate : {};
const elementValue =
typeof restore === "object" && restore !== null ? Reflect.get(restore, "el") : null;
const element = elementValue instanceof Element ? elementValue : null;
const fallback = Reflect.get(candidateObject, "selector");
const selector = selectorFor(
element,
typeof fallback === "string" ? fallback : "[data-composition-id]",
);
const dataAttributes: Record<string, string> = {};
for (const attribute of Array.from(element?.attributes ?? [])) {
if (attribute.name.startsWith("data-")) dataAttributes[attribute.name] = attribute.value;
}
const source = element
?.closest("[data-composition-file]")
?.getAttribute("data-composition-file");
return {
candidate: { ...candidateObject, selector },
anchor: {
selector,
dataAttributes,
sourceFile: source || "index.html",
bbox: Reflect.get(candidateObject, "bbox"),
time: sampleTime,
},
};
});
}, time);
}
async function finishContrast(
page: Page,
screenshot: string,
time: number,
candidates: unknown[],
): Promise<unknown[]> {
return page.evaluate(
async (payload: { screenshot: string; time: number; candidates: unknown[] }) => {
const finish = Reflect.get(window, "__contrastAuditFinish");
if (typeof finish !== "function") return [];
const result = await Reflect.apply(finish, window, [
payload.screenshot,
payload.time,
payload.candidates,
]);
return Array.isArray(result) ? result : [];
},
{ screenshot, time, candidates },
);
}
function parsePreparedContrast(raw: unknown[]): PreparedContrast[] {
return raw.flatMap((value) => {
if (!isRecord(value)) return [];
const raw = Reflect.get(value, "candidate");
const candidate = parseContrastCandidate(raw);
const anchor = parseAnchor(Reflect.get(value, "anchor"));
return candidate && anchor ? [{ raw, candidate, anchor }] : [];
});
}
function parseContrastCandidate(value: unknown): ContrastCandidate | null {
if (!isRecord(value)) return null;
const selector = stringValue(value, "selector");
const text = stringValue(value, "text");
const fg = rgbaValue(Reflect.get(value, "fg"));
const large = booleanValue(value, "large");
const bbox = parseBbox(Reflect.get(value, "bbox"));
return selector && text !== null && fg && large !== null && bbox
? { selector, text, fg, large, bbox }
: null;
}
function parseFinishedContrast(value: unknown): FinishedContrast[] {
if (!isRecord(value)) return [];
const selector = stringValue(value, "selector");
const text = stringValue(value, "text");
const ratio = numberValue(value, "ratio");
const wcagAA = booleanValue(value, "wcagAA");
const large = booleanValue(value, "large");
const fg = stringValue(value, "fg");
const bg = stringValue(value, "bg");
return selector &&
text !== null &&
ratio !== null &&
wcagAA !== null &&
large !== null &&
fg &&
bg
? [{ selector, text, ratio, wcagAA, large, fg, bg }]
: [];
}
function joinContrastEntries(
finished: FinishedContrast[],
prepared: PreparedContrast[],
): ContrastAuditEntry[] {
const remaining = [...prepared];
return finished.flatMap((entry) => {
const index = remaining.findIndex(
(candidate) =>
candidate.candidate.selector === entry.selector && candidate.candidate.text === entry.text,
);
const match = index >= 0 ? remaining.splice(index, 1)[0] : undefined;
return match ? [{ ...entry, ...match.anchor }] : [];
});
}
function parseLayoutIssue(value: unknown): LayoutIssue[] {
if (!isRecord(value)) return [];
const code = layoutCodeValue(Reflect.get(value, "code"));
const severity = severityValue(Reflect.get(value, "severity"));
const time = numberValue(value, "time");
const selector = stringValue(value, "selector");
const message = stringValue(value, "message");
const rect = parseRect(Reflect.get(value, "rect"));
if (!code || !severity || time === null || !selector || !message || !rect) return [];
const issue: LayoutIssue = { code, severity, time, selector, message, rect };
assignOptionalLayoutFields(issue, value);
return [issue];
}
function assignOptionalLayoutFields(issue: LayoutIssue, value: Record<string, unknown>): void {
assignOptionalString(issue, value, "containerSelector");
assignOptionalString(issue, value, "text");
assignOptionalString(issue, value, "fixHint");
const containerRect = parseRect(Reflect.get(value, "containerRect"));
if (containerRect) issue.containerRect = containerRect;
const overflow = parseOverflow(Reflect.get(value, "overflow"));
if (overflow) issue.overflow = overflow;
}
function recordField(value: unknown, key: string): Record<string, unknown> | null {
if (!isRecord(value)) return null;
const field = Reflect.get(value, key);
return isRecord(field) ? field : null;
}
function parseMotionFrame(
value: unknown,
time: number,
selectors: string[],
scopes: string[],
): MotionFrame {
const rawData = recordField(value, "data");
const rawLiveness = recordField(value, "liveness");
const data: MotionFrame["data"] = {};
for (const selector of selectors) {
data[selector] = rawData ? parseFrameSample(Reflect.get(rawData, selector)) : null;
}
const liveness: Record<string, string> = {};
for (const scope of scopes) {
const signature = rawLiveness ? Reflect.get(rawLiveness, scope) : "";
liveness[scope] = typeof signature === "string" ? signature : "";
}
return { time, data, liveness };
}
function parseFrameSample(value: unknown): MotionFrame["data"][string] {
if (!isRecord(value)) return null;
const rect = parseRect(Reflect.get(value, "rect"));
const opacity = numberValue(value, "opacity");
const visible = booleanValue(value, "visible");
return rect && opacity !== null && visible !== null ? { rect, opacity, visible } : null;
}
function parseAnchor(value: unknown): CheckAnchor | null {
if (!isRecord(value)) return null;
const selector = stringValue(value, "selector");
const sourceFile = stringValue(value, "sourceFile");
const time = numberValue(value, "time");
const bbox = parseBbox(Reflect.get(value, "bbox"));
const dataAttributes = stringRecord(Reflect.get(value, "dataAttributes"));
return selector && sourceFile && time !== null && bbox && dataAttributes
? { selector, sourceFile, time, bbox, dataAttributes }
: null;
}
function runtimeFinding(draft: RuntimeDraft, root: CheckAnchor): CheckFinding {
return {
code: draft.code,
severity: draft.severity,
message: draft.message,
selector: root.selector,
dataAttributes: root.dataAttributes,
sourceFile: root.sourceFile,
bbox: root.bbox,
time: draft.time,
url: draft.url,
line: draft.line,
};
}
function fallbackAnchor(request: AnchorRequest | undefined): CheckAnchor {
return {
selector: request?.selector ?? "[data-composition-id]",
dataAttributes: {},
sourceFile: "index.html",
bbox: request?.bbox ?? { x: 0, y: 0, width: 0, height: 0 },
time: request?.time ?? 0,
};
}
function 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<string, string> | null {
if (!isRecord(value)) return null;
const record: Record<string, string> = {};
for (const key of Object.keys(value)) {
const entry = Reflect.get(value, key);
if (typeof entry !== "string") return null;
record[key] = entry;
}
return record;
}
function assignOptionalString(
issue: LayoutIssue,
source: Record<string, unknown>,
key: "containerSelector" | "text" | "fixHint",
): void {
const value = stringValue(source, key);
if (value !== null) issue[key] = value;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function stringValue(value: Record<string, unknown>, key: string): string | null {
const entry = Reflect.get(value, key);
return typeof entry === "string" ? entry : null;
}
function numberValue(value: Record<string, unknown>, key: string): number | null {
const entry = Reflect.get(value, key);
return typeof entry === "number" && Number.isFinite(entry) ? entry : null;
}
function booleanValue(value: Record<string, unknown>, key: string): boolean | null {
const entry = Reflect.get(value, key);
return typeof entry === "boolean" ? entry : null;
}
function urlPath(url: string): string {
try {
return decodeURIComponent(new URL(url).pathname).replace(/^\//, "");
} catch {
return url;
}
}
+554
View File
@@ -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<SampleGrid> {
const duration = await driver.getDuration();
const baseSamples = buildLayoutSampleTimes({
duration,
samples: options.samples,
at: options.at,
});
const transitions = options.atTransitions
? buildTransitionSampleTimes({
duration,
boundaries: await driver.getTransitionBoundaries(),
cap: options.maxTransitionSamples,
})
: { times: [], dropped: 0 };
const 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<MotionPlan> {
if (motion.kind !== "valid") {
return { times: [], selectors: [], livenessScopes: [], preflightIssues: [] };
}
const targets = collectSamplingTargets(motion.spec.assertions);
const preflightIssues = await driver.findAmbiguousSelectors(targets.selectors);
const times =
preflightIssues.length === 0 ? buildMotionSampleTimes(motion.spec.duration ?? duration) : [];
return { times, ...targets, preflightIssues };
}
interface GridSamples {
layoutIssues: AnchoredLayoutIssue[];
motionFrames: MotionFrame[];
contrastEntries: ContrastAuditEntry[];
screenshots: CheckScreenshot[];
}
async function collectGridSamples(
driver: CheckAuditDriver,
options: CheckOptions,
grid: SampleGrid,
motion: MotionPlan,
): Promise<GridSamples> {
const layoutSet = new Set(grid.layoutSamples);
const motionSet = new Set(motion.times);
const contrastSet = new Set(grid.contrastSamples);
const 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<CheckBrowserResult> {
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<CheckReport> {
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<T extends CheckFinding>(findings: T[]): CheckSection<T> {
const errorCount = findings.filter((finding) => finding.severity === "error").length;
const warningCount = findings.filter((finding) => finding.severity === "warning").length;
const infoCount = findings.filter((finding) => finding.severity === "info").length;
return { ok: errorCount === 0, errorCount, warningCount, infoCount, findings };
}
function emptyBrowserResult(): CheckBrowserResult {
return {
duration: 0,
layoutSamples: [],
transitionSamples: [],
transitionSamplesDropped: 0,
runtimeFindings: [],
layoutIssues: [],
motionIssues: [],
motionSampleCount: 0,
contrastSamples: [],
contrastFindings: [],
contrastChecked: 0,
contrastPassed: 0,
screenshots: [],
};
}
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<string, string> {
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
return Object.keys(value).every((key) => typeof Reflect.get(value, key) === "string");
}
function resolveMotionSpec(projectDir: string): MotionSpecResolution {
const path = findMotionSpec(projectDir);
if (!path) return { kind: "none" };
const result = readMotionSpec(path);
return result.ok
? { kind: "valid", path, spec: result.spec }
: {
kind: "invalid",
path,
message: `Invalid motion spec ${path}: ${result.errors.join("; ")}`,
};
}
async function runBrowserCheck(
project: ProjectDir,
options: CheckOptions,
motion: MotionSpecResolution,
): Promise<CheckBrowserResult> {
const module = await import("./checkBrowser.js");
// runAuditGrid is handed over as a callback so checkBrowser never imports
// this module back (no import cycle).
return module.runBrowserCheck(project, options, motion, runAuditGrid);
}
async function writeSnapshot(
projectDir: string,
index: number,
time: number,
pngBase64: string,
): Promise<string> {
const snapshotDir = join(projectDir, "snapshots");
mkdirSync(snapshotDir, { recursive: true });
const filename = `frame-${String(index).padStart(2, "0")}-at-${time.toFixed(1)}s.png`;
const path = join(snapshotDir, filename);
writeFileSync(path, Buffer.from(pngBase64, "base64"));
return join("snapshots", filename);
}
const DEFAULT_DEPENDENCIES: CheckDependencies = {
lintProject,
resolveMotionSpec,
runBrowserCheck,
writeSnapshot,
};
+169
View File
@@ -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<string, string>;
sourceFile: string;
bbox: CheckBbox;
time: number;
}
export interface CheckFinding extends CheckAnchor {
code: string;
severity: CheckSeverity;
message: string;
text?: string;
fixHint?: string;
url?: string;
line?: number;
}
export interface AnchoredLayoutIssue extends LayoutIssue, CheckAnchor {}
export interface ContrastAuditEntry extends CheckAnchor {
text: string;
ratio: number;
wcagAA: boolean;
large: boolean;
fg: string;
bg: string;
}
export interface CheckContrastFinding extends CheckFinding {
fg: string;
bg: string;
ratio: number;
requiredRatio: number;
suggestedColor: string;
large: boolean;
}
export interface ContrastCapture {
entries: ContrastAuditEntry[];
pngBase64: string;
}
export type MotionSpecResolution =
| { kind: "none" }
| { kind: "valid"; path: string; spec: MotionSpec }
| { kind: "invalid"; path: string; message: string };
export interface CheckAuditDriver {
initialize(contrast: boolean): Promise<void>;
getDuration(): Promise<number>;
getTransitionBoundaries(): Promise<number[]>;
getCanvas(): Promise<Canvas>;
findAmbiguousSelectors(selectors: string[]): Promise<AnchoredLayoutIssue[]>;
seek(time: number): Promise<void>;
collectLayout(time: number, tolerance: number): Promise<AnchoredLayoutIssue[]>;
collectMotionFrame(
time: number,
selectors: string[],
livenessScopes: string[],
): Promise<MotionFrame>;
anchorMotionIssues(issues: LayoutIssue[]): Promise<AnchoredLayoutIssue[]>;
collectContrast(time: number): Promise<ContrastCapture>;
}
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<CheckBrowserResult>;
export interface CheckSection<T extends CheckFinding = CheckFinding> {
ok: boolean;
errorCount: number;
warningCount: number;
infoCount: number;
findings: T[];
}
export interface CheckReport {
ok: boolean;
strict: boolean;
lint: CheckSection & { filesScanned: number };
runtime: CheckSection;
layout: CheckSection<AnchoredLayoutIssue> & {
duration: number;
samples: number[];
transitionSamples: number[];
transitionSamplesDropped: number;
tolerance: number;
totalIssueCount: number;
truncated: boolean;
};
motion: CheckSection & { enabled: boolean; specPath?: string; samples: number };
contrast: CheckSection<CheckContrastFinding> & {
enabled: boolean;
samples: number[];
checked: number;
passed: number;
};
snapshots: { enabled: boolean; files: string[]; times: number[] };
}
export interface CheckDependencies {
lintProject(projectDir: string): Promise<ProjectLintResult>;
resolveMotionSpec(projectDir: string): MotionSpecResolution;
runBrowserCheck(
project: ProjectDir,
options: CheckOptions,
motion: MotionSpecResolution,
): Promise<CheckBrowserResult>;
writeSnapshot(
projectDir: string,
index: number,
time: number,
pngBase64: string,
): Promise<string>;
}