mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
Merge pull request #2138 from heygen-com/feat/check-command
feat(cli): hyperframes check — the single-session verification gate
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,409 @@
|
||||
import { defineCommand } from "citty";
|
||||
import type { Example } from "./_examples.js";
|
||||
import { parseAt } from "./layout.js";
|
||||
import { c } from "../ui/colors.js";
|
||||
import { normalizeErrorMessage } from "../utils/errorMessage.js";
|
||||
import { formatLayoutIssue } from "../utils/layoutAudit.js";
|
||||
import { resolveProject, type ProjectDir } from "../utils/project.js";
|
||||
import { withMeta } from "../utils/updateCheck.js";
|
||||
import {
|
||||
DEFAULT_CHECK_OPTIONS,
|
||||
checkExitCode,
|
||||
runCheckPipeline,
|
||||
type CheckFinding,
|
||||
type CheckOptions,
|
||||
type CheckReport,
|
||||
type CheckSection,
|
||||
} from "../utils/checkPipeline.js";
|
||||
import type { CaptionZoneOptions, FrameCheckOptions } from "../utils/checkTypes.js";
|
||||
|
||||
export const examples: Example[] = [
|
||||
["Run the full verification gate", "hyperframes check"],
|
||||
["Output one agent-readable envelope", "hyperframes check --json"],
|
||||
["Persist the five audited contrast frames", "hyperframes check --snapshots"],
|
||||
["Also fail on warnings", "hyperframes check --strict"],
|
||||
];
|
||||
|
||||
export interface CheckCommandDependencies {
|
||||
resolveProject(dir: string | undefined): ProjectDir;
|
||||
runPipeline(project: ProjectDir, options: CheckOptions): Promise<CheckReport>;
|
||||
withMeta(value: object): object;
|
||||
}
|
||||
|
||||
const DEFAULT_COMMAND_DEPENDENCIES: CheckCommandDependencies = {
|
||||
resolveProject,
|
||||
runPipeline: runCheckPipeline,
|
||||
withMeta,
|
||||
};
|
||||
|
||||
export function createCheckCommand(
|
||||
dependencies: CheckCommandDependencies = DEFAULT_COMMAND_DEPENDENCIES,
|
||||
) {
|
||||
return defineCommand({
|
||||
meta: {
|
||||
name: "check",
|
||||
description:
|
||||
"Run lint, runtime, layout, motion, and WCAG contrast verification in one browser session",
|
||||
},
|
||||
args: {
|
||||
dir: { type: "positional", description: "Project directory", required: false },
|
||||
json: { type: "boolean", description: "Output agent-readable JSON", default: false },
|
||||
samples: {
|
||||
type: "string",
|
||||
description: "Number of midpoint samples across the duration (default: 9)",
|
||||
default: "9",
|
||||
},
|
||||
at: {
|
||||
type: "string",
|
||||
description: "Comma-separated timestamps in seconds (e.g., --at 1.5,4,7.25)",
|
||||
},
|
||||
"at-transitions": {
|
||||
type: "boolean",
|
||||
description:
|
||||
"Also sample at every tween start/end boundary (plus segment midpoints) to catch transient overlaps at transition seams",
|
||||
default: false,
|
||||
},
|
||||
"max-transition-samples": {
|
||||
type: "string",
|
||||
description:
|
||||
"Optional cap on transition-derived samples; when it truncates, the omitted count is reported (default: unlimited)",
|
||||
},
|
||||
"max-issues": {
|
||||
type: "string",
|
||||
description: "Maximum issues to print or return after static collapse (default: 80)",
|
||||
default: "80",
|
||||
},
|
||||
"collapse-static": {
|
||||
type: "boolean",
|
||||
description: "Collapse repeated static issues across samples (default: true)",
|
||||
default: true,
|
||||
},
|
||||
tolerance: {
|
||||
type: "string",
|
||||
description: "Allowed pixel overflow before reporting an issue (default: 2)",
|
||||
default: "2",
|
||||
},
|
||||
timeout: {
|
||||
type: "string",
|
||||
description: "Ms to wait for scripts and media to settle initially (default: 3000)",
|
||||
default: "3000",
|
||||
},
|
||||
contrast: {
|
||||
type: "boolean",
|
||||
description: "Run the WCAG AA contrast pass (enabled by default)",
|
||||
default: true,
|
||||
},
|
||||
strict: {
|
||||
type: "boolean",
|
||||
description: "Exit non-zero on warnings too",
|
||||
default: false,
|
||||
},
|
||||
snapshots: {
|
||||
type: "boolean",
|
||||
description: "Save the five contrast-pass PNGs under snapshots/",
|
||||
default: false,
|
||||
},
|
||||
"caption-zone": {
|
||||
type: "string",
|
||||
description:
|
||||
'Caption band "x0=0;y0=.82;x1=1;y1=1[;severity=warning|error][;seek=.5,1]" (fractions 0-1; defaults: warning, seek=1)',
|
||||
},
|
||||
"frame-check": {
|
||||
type: "string",
|
||||
description:
|
||||
'Bare --frame-check uses defaults (tol=2px, severity=warning, seek=.5; breach floor=max(120px, 6% of shorter canvas edge)); or pass "severity=error;seek=.25,.75;tol=4" to tune',
|
||||
},
|
||||
},
|
||||
async run({ args }) {
|
||||
const asJson = args.json === true;
|
||||
|
||||
try {
|
||||
const project = dependencies.resolveProject(args.dir);
|
||||
const options = parseCheckOptions(args);
|
||||
if (!asJson) {
|
||||
console.log(`${c.accent("◆")} Checking ${c.accent(project.name)}`);
|
||||
}
|
||||
const report = await dependencies.runPipeline(project, options);
|
||||
if (asJson) {
|
||||
console.log(JSON.stringify(dependencies.withMeta(report), null, 2));
|
||||
} else {
|
||||
printHumanReport(report);
|
||||
}
|
||||
process.exitCode = checkExitCode(report);
|
||||
} catch (error) {
|
||||
const message = normalizeErrorMessage(error);
|
||||
if (asJson) {
|
||||
console.log(
|
||||
JSON.stringify(dependencies.withMeta({ ok: false, error: message }), null, 2),
|
||||
);
|
||||
} else {
|
||||
console.error(`${c.error("✗")} Check failed: ${message}`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function parseCheckOptions(args: Record<string, unknown>): CheckOptions {
|
||||
const maxTransitionSamples = positiveInteger(args["max-transition-samples"], 0);
|
||||
return {
|
||||
samples: positiveInteger(args.samples, DEFAULT_CHECK_OPTIONS.samples),
|
||||
at: parseAt(args.at),
|
||||
atTransitions: args["at-transitions"] === true,
|
||||
maxTransitionSamples: maxTransitionSamples > 0 ? maxTransitionSamples : undefined,
|
||||
maxIssues: positiveInteger(args["max-issues"], DEFAULT_CHECK_OPTIONS.maxIssues),
|
||||
collapseStatic: args["collapse-static"] !== false,
|
||||
tolerance: nonNegativeNumber(args.tolerance, DEFAULT_CHECK_OPTIONS.tolerance),
|
||||
timeout: Math.max(500, positiveInteger(args.timeout, DEFAULT_CHECK_OPTIONS.timeout)),
|
||||
contrast: args.contrast !== false,
|
||||
strict: args.strict === true,
|
||||
snapshots: args.snapshots === true,
|
||||
captionZone: parseCaptionZone(args["caption-zone"]),
|
||||
frameCheck: parseFrameCheck(args["frame-check"]),
|
||||
};
|
||||
}
|
||||
|
||||
const CAPTION_ZONE_FIELDS = new Set(["x0", "y0", "x1", "y1", "severity", "seek"]);
|
||||
|
||||
const FRAME_CHECK_FIELDS = new Set(["severity", "seek", "tol"]);
|
||||
|
||||
// Mirrors --caption-zone's spec grammar so the EF bridge's severity/seek/tol
|
||||
// options survive the migration instead of being silently dropped by a
|
||||
// boolean flag (bare --frame-check keeps today's defaults).
|
||||
export function parseFrameCheck(value: unknown): FrameCheckOptions | undefined {
|
||||
if (value === undefined || value === null || value === false) return undefined;
|
||||
if (value === true || value === "") return {};
|
||||
if (typeof value !== "string") throw frameCheckError();
|
||||
const fields = parseFrameCheckFields(value);
|
||||
const severity = captionSeverity(fields.get("severity"));
|
||||
const seek = captionSeeks(fields.get("seek"));
|
||||
const tol = parseFrameCheckTolerance(fields.get("tol"));
|
||||
return {
|
||||
...(severity ? { severity } : {}),
|
||||
...(seek ? { seek } : {}),
|
||||
...(tol !== undefined ? { tol } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function parseFrameCheckFields(value: string): Map<string, string> {
|
||||
const fields = new Map<string, string>();
|
||||
for (const part of value.split(";")) {
|
||||
const { key, entry } = parseCaptionField(part);
|
||||
if (!FRAME_CHECK_FIELDS.has(key) || fields.has(key)) throw frameCheckError();
|
||||
fields.set(key, entry);
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
function parseFrameCheckTolerance(raw: string | undefined): number | undefined {
|
||||
if (raw === undefined) return undefined;
|
||||
const tol = Number.parseFloat(raw);
|
||||
if (!Number.isFinite(tol) || tol < 0) throw frameCheckError();
|
||||
return tol;
|
||||
}
|
||||
|
||||
function frameCheckError(): Error {
|
||||
return new Error(
|
||||
'Invalid --frame-check: use bare --frame-check or "severity=warning|error;seek=.25,.75;tol=4" (all fields optional)',
|
||||
);
|
||||
}
|
||||
|
||||
function parseCaptionZone(value: unknown): CaptionZoneOptions | undefined {
|
||||
if (value === undefined || value === null) return undefined;
|
||||
const fields = parseCaptionFields(captionZoneString(value));
|
||||
const { x0, y0, x1, y1 } = parseCaptionBounds(fields);
|
||||
const severity = captionSeverity(fields.get("severity"));
|
||||
const seek = captionSeeks(fields.get("seek"));
|
||||
return {
|
||||
x0,
|
||||
y0,
|
||||
x1,
|
||||
y1,
|
||||
...(severity ? { severity } : {}),
|
||||
...(seek ? { seek } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function captionZoneString(value: unknown): string {
|
||||
if (typeof value !== "string" || value.trim() === "") throw captionZoneError();
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseCaptionFields(value: string): Map<string, string> {
|
||||
const fields = new Map<string, string>();
|
||||
for (const part of value.split(";")) {
|
||||
const { key, entry } = parseCaptionField(part);
|
||||
if (!CAPTION_ZONE_FIELDS.has(key) || fields.has(key)) throw captionZoneError();
|
||||
fields.set(key, entry);
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
function parseCaptionField(part: string): { key: string; entry: string } {
|
||||
const separator = part.indexOf("=");
|
||||
if (separator <= 0) throw captionZoneError();
|
||||
return {
|
||||
key: part.slice(0, separator).trim(),
|
||||
entry: part.slice(separator + 1).trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function parseCaptionBounds(fields: Map<string, string>): {
|
||||
x0: number;
|
||||
y0: number;
|
||||
x1: number;
|
||||
y1: number;
|
||||
} {
|
||||
const x0 = requiredCaptionFraction(fields, "x0");
|
||||
const y0 = requiredCaptionFraction(fields, "y0");
|
||||
const x1 = requiredCaptionFraction(fields, "x1");
|
||||
const y1 = requiredCaptionFraction(fields, "y1");
|
||||
if (x0 > x1 || y0 > y1) throw captionZoneError();
|
||||
return { x0, y0, x1, y1 };
|
||||
}
|
||||
|
||||
function requiredCaptionFraction(fields: Map<string, string>, key: string): number {
|
||||
const value = captionFraction(fields.get(key));
|
||||
if (value === null) throw captionZoneError();
|
||||
return value;
|
||||
}
|
||||
|
||||
function captionFraction(value: string | undefined): number | null {
|
||||
if (value === undefined || value === "") return null;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed >= 0 && parsed <= 1 ? parsed : null;
|
||||
}
|
||||
|
||||
function captionSeverity(value: string | undefined): "error" | "warning" | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (value === "error" || value === "warning") return value;
|
||||
throw captionZoneError();
|
||||
}
|
||||
|
||||
function captionSeeks(value: string | undefined): number[] | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (value === "") return [];
|
||||
const values = value.split(",").map(captionFraction);
|
||||
if (values.some((entry) => entry === null)) throw captionZoneError();
|
||||
return values.flatMap((entry) => (entry === null ? [] : entry));
|
||||
}
|
||||
|
||||
function captionZoneError(): Error {
|
||||
return new Error(
|
||||
'Invalid --caption-zone; use "x0=0;y0=.82;x1=1;y1=1[;severity=warning|error][;seek=.5,1]" with fractions from 0 to 1.',
|
||||
);
|
||||
}
|
||||
|
||||
function positiveInteger(value: unknown, fallback: number): number {
|
||||
const parsed = parseInt(String(value ?? ""), 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function nonNegativeNumber(value: unknown, fallback: number): number {
|
||||
const parsed = parseFloat(String(value ?? ""));
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function printHumanReport(report: CheckReport): void {
|
||||
printSection("Lint", report.lint);
|
||||
printSection("Runtime", report.runtime);
|
||||
printLayoutSection("Layout", report.layout);
|
||||
printSection("Motion", report.motion);
|
||||
printContrastSection(report);
|
||||
printSnapshotSection(report);
|
||||
console.log();
|
||||
const label = report.ok ? c.success("Check passed") : c.error("Check failed");
|
||||
console.log(`${report.ok ? c.success("◇") : c.error("◇")} ${label}`);
|
||||
}
|
||||
|
||||
function printSection(title: string, section: CheckSection): void {
|
||||
console.log();
|
||||
console.log(c.bold(title));
|
||||
if (section.findings.length === 0) {
|
||||
console.log(` ${c.success("◇")} 0 errors, 0 warnings`);
|
||||
return;
|
||||
}
|
||||
for (const finding of section.findings) printFinding(finding);
|
||||
printCounts(section);
|
||||
}
|
||||
|
||||
function printLayoutSection(title: string, section: CheckReport["layout"]): void {
|
||||
console.log();
|
||||
console.log(c.bold(title));
|
||||
if (section.findings.length === 0) {
|
||||
console.log(` ${c.success("◇")} 0 issues across ${section.samples.length} sample(s)`);
|
||||
} else {
|
||||
for (const finding of section.findings) {
|
||||
const formatted = formatLayoutIssue(finding).replace(/\n/g, "\n ");
|
||||
console.log(` ${findingIcon(finding)} ${formatted}`);
|
||||
}
|
||||
printCounts(section);
|
||||
}
|
||||
if (section.transitionSamplesDropped > 0) {
|
||||
console.log(
|
||||
` ${c.warn("⚠")} ${section.transitionSamplesDropped} transition sample(s) omitted`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function printContrastSection(report: CheckReport): void {
|
||||
const section = report.contrast;
|
||||
console.log();
|
||||
console.log(c.bold("Contrast"));
|
||||
if (!section.enabled) {
|
||||
console.log(` ${c.dim("◇")} skipped`);
|
||||
return;
|
||||
}
|
||||
if (section.findings.length === 0) {
|
||||
console.log(
|
||||
` ${c.success("◇")} ${section.passed}/${section.checked} text checks pass WCAG AA`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
for (const finding of section.findings) {
|
||||
console.log(
|
||||
` ${c.error("✗")} ${finding.selector} ${finding.ratio}:1 (need ${finding.requiredRatio}:1, t=${finding.time}s)`,
|
||||
);
|
||||
console.log(` ${c.dim(`Try ${finding.suggestedColor}; source ${finding.sourceFile}`)}`);
|
||||
}
|
||||
printCounts(section);
|
||||
}
|
||||
|
||||
function printSnapshotSection(report: CheckReport): void {
|
||||
console.log();
|
||||
console.log(c.bold("Snapshots"));
|
||||
if (!report.snapshots.enabled) {
|
||||
console.log(` ${c.dim("◇")} disabled`);
|
||||
} else {
|
||||
console.log(` ${c.success("◇")} ${report.snapshots.files.length} PNG(s) saved`);
|
||||
for (const file of report.snapshots.files) console.log(` ${c.dim(file)}`);
|
||||
if (report.snapshots.findingFiles.length > 0) {
|
||||
console.log(
|
||||
` ${c.success("◇")} ${report.snapshots.findingFiles.length} finding crop(s) saved`,
|
||||
);
|
||||
for (const file of report.snapshots.findingFiles) console.log(` ${c.dim(file)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function printFinding(finding: CheckFinding): void {
|
||||
const where = `${finding.sourceFile} ${finding.selector} t=${finding.time}s`;
|
||||
console.log(` ${findingIcon(finding)} ${finding.code}: ${finding.message}`);
|
||||
console.log(` ${c.dim(where)}`);
|
||||
if (finding.fixHint) console.log(` ${c.dim(`Fix: ${finding.fixHint}`)}`);
|
||||
}
|
||||
|
||||
function findingIcon(finding: CheckFinding): string {
|
||||
if (finding.severity === "error") return c.error("✗");
|
||||
if (finding.severity === "warning") return c.warn("⚠");
|
||||
return c.dim("ℹ");
|
||||
}
|
||||
|
||||
function printCounts(section: CheckSection): void {
|
||||
console.log(
|
||||
` ${c.dim(`${section.errorCount} error(s), ${section.warningCount} warning(s), ${section.infoCount} info(s)`)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export default createCheckCommand();
|
||||
@@ -158,6 +158,22 @@ window.__contrastAuditPrepare = function () {
|
||||
}
|
||||
if (!hasText) continue;
|
||||
|
||||
// Same decorative opt-out the layout audit honors: text marked (or inside)
|
||||
// data-layout-ignore is set dressing, not copy a viewer must read —
|
||||
// deliberately dim rail labels, ghost typography, texture text.
|
||||
if (el.closest && el.closest("[data-layout-ignore]")) continue;
|
||||
|
||||
// Text that has (nearly) left the canvas — a cursor exiting the frame, an
|
||||
// element parked off-screen — is not readable content, and sampling its
|
||||
// clamped edge reads whatever pixels happen to sit at the border (the
|
||||
// classic false "white-on-white"). Require a minimally-visible on-canvas
|
||||
// intersection before judging contrast; the layout audit separately owns
|
||||
// off-canvas detection as its own finding class.
|
||||
var vis = el.getBoundingClientRect();
|
||||
var onX = Math.min(vis.right, window.innerWidth) - Math.max(vis.left, 0);
|
||||
var onY = Math.min(vis.bottom, window.innerHeight) - Math.max(vis.top, 0);
|
||||
if (onX < 8 || onY < 8) continue;
|
||||
|
||||
var cs = getComputedStyle(el);
|
||||
if (cs.visibility === "hidden" || cs.display === "none") continue;
|
||||
if (parseFloat(cs.opacity) <= 0.01) continue;
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,59 @@
|
||||
export type Rgb = [number, number, number];
|
||||
export type Rgba = [number, number, number, number];
|
||||
|
||||
/** WCAG relative luminance for an sRGB color. Mirrors contrast-audit.browser.js. */
|
||||
export function relativeLuminance(color: Rgb): number {
|
||||
const channel = (value: number) => {
|
||||
const srgb = value / 255;
|
||||
return srgb <= 0.03928 ? srgb / 12.92 : ((srgb + 0.055) / 1.055) ** 2.4;
|
||||
};
|
||||
|
||||
return 0.2126 * channel(color[0]) + 0.7152 * channel(color[1]) + 0.0722 * channel(color[2]);
|
||||
}
|
||||
|
||||
/** WCAG contrast ratio between two opaque sRGB colors. */
|
||||
export function contrastRatio(first: Rgb, second: Rgb): number {
|
||||
const firstLuminance = relativeLuminance(first);
|
||||
const secondLuminance = relativeLuminance(second);
|
||||
const lighter = Math.max(firstLuminance, secondLuminance);
|
||||
const darker = Math.min(firstLuminance, secondLuminance);
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
}
|
||||
|
||||
/** WCAG AA minimum contrast for body or large text. */
|
||||
export function requiredContrastRatio(large: boolean): number {
|
||||
return large ? 3 : 4.5;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the nearest passing foreground on the line toward the higher-contrast
|
||||
* pole: white for a dark background, black for a light background.
|
||||
*/
|
||||
export function suggestCompliantForegroundColor(
|
||||
foreground: Rgb,
|
||||
background: Rgb,
|
||||
requiredRatio: number,
|
||||
): Rgb {
|
||||
if (contrastRatio(foreground, background) >= requiredRatio) return [...foreground];
|
||||
|
||||
const black: Rgb = [0, 0, 0];
|
||||
const white: Rgb = [255, 255, 255];
|
||||
const target =
|
||||
contrastRatio(white, background) >= contrastRatio(black, background) ? white : black;
|
||||
|
||||
for (let step = 1; step <= 255; step += 1) {
|
||||
const amount = step / 255;
|
||||
const candidate: Rgb = [
|
||||
Math.round(foreground[0] + (target[0] - foreground[0]) * amount),
|
||||
Math.round(foreground[1] + (target[1] - foreground[1]) * amount),
|
||||
Math.round(foreground[2] + (target[2] - foreground[2]) * amount),
|
||||
];
|
||||
if (contrastRatio(candidate, background) >= requiredRatio) return candidate;
|
||||
}
|
||||
|
||||
return [...target];
|
||||
}
|
||||
|
||||
/** Parse a CSS `rgb()`/`rgba()` string. Returns null if it is not rgb(a). */
|
||||
export function parseColorRGBA(color: string | null | undefined): Rgba | null {
|
||||
const body = /rgba?\(([^)]+)\)/.exec(color ?? "")?.[1];
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
// Shared scaffolding for the U5 deprecation tests in inspect.test.ts,
|
||||
// layout.test.ts, and validate.test.ts: those commands all fail fast (via a
|
||||
// mocked dynamic import) so the tests can assert the shared deprecation
|
||||
// envelope (stderr notice, JSON `_meta.deprecated`) without needing a real
|
||||
// project or headless Chrome.
|
||||
//
|
||||
// vi.mock factories are hoisted above imports, so each test file keeps its
|
||||
// own thin `vi.mock("<path>", () => someFactory())` call (mocking a module
|
||||
// path can't itself be shared across files) but delegates the factory body
|
||||
// here.
|
||||
import type { ArgsDef, CommandDef } from "citty";
|
||||
import { runCommand } from "citty";
|
||||
import { expect, vi } from "vitest";
|
||||
|
||||
const FAKE_PROJECT = {
|
||||
dir: "/fake-project",
|
||||
name: "fake-project",
|
||||
indexPath: "/fake-project/index.html",
|
||||
};
|
||||
|
||||
export function resolveProjectMock() {
|
||||
return { resolveProject: vi.fn(() => FAKE_PROJECT) };
|
||||
}
|
||||
|
||||
export function bundleToSingleHtmlFailureMock() {
|
||||
return {
|
||||
bundleToSingleHtml: vi.fn(async () => {
|
||||
throw new Error("bundling failed (test double)");
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function lintProjectFailureMock() {
|
||||
return {
|
||||
lintProject: vi.fn(async () => {
|
||||
throw new Error("lint failed (test double)");
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* citty's `meta` is `Resolvable<CommandMeta>` (object | promise | thunk).
|
||||
* These test files always define it as a synchronous object literal, so
|
||||
* narrow to that shape instead of asserting it with `as`.
|
||||
*/
|
||||
export function metaDescription<T extends ArgsDef = ArgsDef>(command: CommandDef<T>): string {
|
||||
const meta = command.meta;
|
||||
if (meta && typeof meta === "object" && "description" in meta) {
|
||||
return String(meta.description ?? "");
|
||||
}
|
||||
throw new Error("expected a synchronous meta object");
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a command with stdout/stderr writes captured (and process.exit /
|
||||
* console.log stubbed so the run stays silent and non-terminating), and
|
||||
* return the captured text for the caller to assert on.
|
||||
*/
|
||||
export async function runAndCaptureStdio<T extends ArgsDef = ArgsDef>(
|
||||
command: CommandDef<T>,
|
||||
rawArgs: string[] = ["--json"],
|
||||
): Promise<{ stderrText: string; stdoutText: string }> {
|
||||
const stderrWrites: string[] = [];
|
||||
const stdoutWrites: string[] = [];
|
||||
vi.spyOn(process.stderr, "write").mockImplementation((chunk: unknown) => {
|
||||
stderrWrites.push(String(chunk));
|
||||
return true;
|
||||
});
|
||||
vi.spyOn(process.stdout, "write").mockImplementation((chunk: unknown) => {
|
||||
stdoutWrites.push(String(chunk));
|
||||
return true;
|
||||
});
|
||||
vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
await runCommand(command, { rawArgs });
|
||||
|
||||
return { stderrText: stderrWrites.join(""), stdoutText: stdoutWrites.join("") };
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a command with process.exit stubbed and console.log spied, returning
|
||||
* the first console.log call that looks like a JSON object (the `--json`
|
||||
* failure envelope). Callers assert on definedness/shape themselves, since
|
||||
* that differs slightly per call site.
|
||||
*/
|
||||
export async function runAndFindJsonLogCall<T extends ArgsDef = ArgsDef>(
|
||||
command: CommandDef<T>,
|
||||
rawArgs: string[] = ["--json"],
|
||||
): Promise<unknown[] | undefined> {
|
||||
vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
||||
vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
await runCommand(command, { rawArgs });
|
||||
|
||||
return logSpy.mock.calls.find(([arg]) => typeof arg === "string" && arg.trim().startsWith("{"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience wrapper: parse the JSON envelope found by runAndFindJsonLogCall.
|
||||
* `parsed` is intentionally left as JSON.parse's inferred `any` (matching
|
||||
* every call site's prior inline `JSON.parse(...)` usage) rather than
|
||||
* annotated `unknown`, since callers assert directly into its shape
|
||||
* (`.ok`, `._meta.deprecated`) the same way the original inline tests did.
|
||||
*/
|
||||
export async function runAndParseJsonEnvelope<T extends ArgsDef = ArgsDef>(
|
||||
command: CommandDef<T>,
|
||||
rawArgs: string[] = ["--json"],
|
||||
) {
|
||||
const jsonCall = await runAndFindJsonLogCall(command, rawArgs);
|
||||
expect(jsonCall).toBeDefined();
|
||||
const parsed = JSON.parse(String(jsonCall?.[0]));
|
||||
return { jsonCall, parsed };
|
||||
}
|
||||
@@ -29,6 +29,19 @@ function runInit(args: string[]): { status: number; stdout: string; stderr: stri
|
||||
};
|
||||
}
|
||||
|
||||
function expectScaffoldedScripts(target: string): void {
|
||||
const pkg = JSON.parse(readFileSync(join(target, "package.json"), "utf-8")) as {
|
||||
scripts?: Record<string, string>;
|
||||
};
|
||||
expect(pkg.scripts).toMatchObject({
|
||||
dev: "npx --yes hyperframes preview",
|
||||
check: "npx --yes hyperframes check",
|
||||
render: "npx --yes hyperframes render",
|
||||
publish: "npx --yes hyperframes publish",
|
||||
});
|
||||
expect(Object.keys(pkg.scripts ?? {}).sort()).toEqual(["check", "dev", "publish", "render"]);
|
||||
}
|
||||
|
||||
describe("hyperframes init flag rename", () => {
|
||||
it("--example blank scaffolds a bundled project with npm scripts", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "hf-init-test-"));
|
||||
@@ -44,18 +57,10 @@ describe("hyperframes init flag rename", () => {
|
||||
const pkg = JSON.parse(readFileSync(join(target, "package.json"), "utf-8")) as {
|
||||
private?: boolean;
|
||||
type?: string;
|
||||
scripts?: Record<string, string>;
|
||||
};
|
||||
expect(pkg.private).toBe(true);
|
||||
expect(pkg.type).toBe("module");
|
||||
expect(pkg.scripts).toMatchObject({
|
||||
dev: "npx --yes hyperframes preview",
|
||||
check:
|
||||
"npx --yes hyperframes lint && npx --yes hyperframes validate && npx --yes hyperframes inspect",
|
||||
render: "npx --yes hyperframes render",
|
||||
publish: "npx --yes hyperframes publish",
|
||||
});
|
||||
expect(Object.keys(pkg.scripts ?? {}).sort()).toEqual(["check", "dev", "publish", "render"]);
|
||||
expectScaffoldedScripts(target);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -79,17 +84,7 @@ describe("hyperframes init flag rename", () => {
|
||||
expect(html).toContain(tailwindScript);
|
||||
expect(html).toContain("window.__tailwindReady");
|
||||
|
||||
const pkg = JSON.parse(readFileSync(join(target, "package.json"), "utf-8")) as {
|
||||
scripts?: Record<string, string>;
|
||||
};
|
||||
expect(pkg.scripts).toMatchObject({
|
||||
dev: "npx --yes hyperframes preview",
|
||||
check:
|
||||
"npx --yes hyperframes lint && npx --yes hyperframes validate && npx --yes hyperframes inspect",
|
||||
render: "npx --yes hyperframes render",
|
||||
publish: "npx --yes hyperframes publish",
|
||||
});
|
||||
expect(Object.keys(pkg.scripts ?? {}).sort()).toEqual(["check", "dev", "publish", "render"]);
|
||||
expectScaffoldedScripts(target);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// The scaffolding command predates the complexity gate: run(), probeVideo,
|
||||
// handleVideoFile, and applyResolutionPreset carry its interactive branching.
|
||||
// This branch only repointed the scaffolded npm scripts; the refactor is its
|
||||
// own task.
|
||||
// fallow-ignore-file complexity
|
||||
import { defineCommand, runCommand } from "citty";
|
||||
import type { Example } from "./_examples.js";
|
||||
|
||||
@@ -224,9 +229,7 @@ function hyperframesScript(command: string): string {
|
||||
function buildPackageScripts(): Record<string, string> {
|
||||
return {
|
||||
dev: hyperframesScript("preview"),
|
||||
check:
|
||||
`${hyperframesScript("lint")} && ${hyperframesScript("validate")} && ` +
|
||||
`${hyperframesScript("inspect")}`,
|
||||
check: hyperframesScript("check"),
|
||||
render: hyperframesScript("render"),
|
||||
publish: hyperframesScript("publish"),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
bundleToSingleHtmlFailureMock,
|
||||
metaDescription,
|
||||
resolveProjectMock,
|
||||
runAndCaptureStdio,
|
||||
} from "./deprecationTestHarness.js";
|
||||
|
||||
// See layout.test.ts for why these two dynamic-import targets are mocked:
|
||||
// resolveProject skips real filesystem resolution, and bundleToSingleHtml
|
||||
// gives a fast, deterministic failure that exercises run()'s outer catch
|
||||
// (the JSON failure envelope) without needing headless Chrome.
|
||||
vi.mock("../utils/project.js", () => resolveProjectMock());
|
||||
vi.mock("@hyperframes/core/compiler", () => bundleToSingleHtmlFailureMock());
|
||||
|
||||
import inspectCommand from "./inspect.js";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("inspect command deprecation (U5)", () => {
|
||||
it("is the compatibility alias for layout, sharing its deprecated description", () => {
|
||||
expect(metaDescription(inspectCommand)).toContain("(deprecated, use check)");
|
||||
});
|
||||
|
||||
it("prints a one-line deprecation notice naming 'inspect' on stderr, never stdout", async () => {
|
||||
const { stderrText, stdoutText } = await runAndCaptureStdio(inspectCommand);
|
||||
expect(stderrText).toContain("hyperframes inspect");
|
||||
expect(stderrText).toContain("hyperframes check");
|
||||
expect(stdoutText).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -81,6 +81,22 @@
|
||||
return `${selectorFor(parent)} > ${element.tagName.toLowerCase()}:nth-of-type(${index})`;
|
||||
}
|
||||
|
||||
function uniqueSelectorFor(element) {
|
||||
const preferred = selectorFor(element);
|
||||
try {
|
||||
if (document.querySelectorAll(preferred).length === 1) return preferred;
|
||||
} catch {
|
||||
// Fall through to a structural selector.
|
||||
}
|
||||
const parent = element.parentElement;
|
||||
if (!parent) return preferred;
|
||||
const siblings = Array.from(parent.children).filter(
|
||||
(child) => child.tagName === element.tagName,
|
||||
);
|
||||
const index = siblings.indexOf(element) + 1;
|
||||
return `${uniqueSelectorFor(parent)} > ${element.tagName.toLowerCase()}:nth-of-type(${index})`;
|
||||
}
|
||||
|
||||
function hasIgnoreFlag(element) {
|
||||
return !!element.closest("[data-layout-ignore], [data-layout-check='ignore']");
|
||||
}
|
||||
@@ -98,6 +114,14 @@
|
||||
return opacity;
|
||||
}
|
||||
|
||||
function hasOpacityBelow(element, floor) {
|
||||
for (let current = element; current; current = current.parentElement) {
|
||||
const parsed = Number.parseFloat(getComputedStyle(current).opacity || "1");
|
||||
if (Number.isFinite(parsed) && parsed < floor) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// A clip-path can shrink an element's painted region to nothing (e.g. a
|
||||
// typewriter span pre-reveal at `inset(0 100% 0 0)`, or `circle(0px)`) while
|
||||
// its layout box, opacity, visibility and display all still read as present.
|
||||
@@ -142,9 +166,20 @@
|
||||
return !paintsAnyProbePoint(element, rect);
|
||||
}
|
||||
|
||||
function isVisibleElement(element) {
|
||||
function isVisibleElement(element, opacityFloor, probeClipPath) {
|
||||
if (IGNORE_TAGS.has(element.tagName)) return false;
|
||||
if (hasIgnoreFlag(element)) return false;
|
||||
if (
|
||||
opacityFloor != null &&
|
||||
typeof element.checkVisibility === "function" &&
|
||||
!element.checkVisibility({
|
||||
opacityProperty: true,
|
||||
visibilityProperty: true,
|
||||
contentVisibilityAuto: true,
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const style = getComputedStyle(element);
|
||||
if (
|
||||
style.display === "none" ||
|
||||
@@ -153,32 +188,57 @@
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (opacityChain(element) < 0.2) return false;
|
||||
if (
|
||||
opacityFloor == null ? opacityChain(element) < 0.2 : hasOpacityBelow(element, opacityFloor)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (rect.width <= 0.5 || rect.height <= 0.5) return false;
|
||||
return !isClippedAway(element);
|
||||
return probeClipPath === false || !isClippedAway(element);
|
||||
}
|
||||
|
||||
function textContentFor(element) {
|
||||
return (element.innerText || element.textContent || "").replace(/\s+/g, " ").trim();
|
||||
function directTextNodes(element) {
|
||||
return Array.from(element.childNodes).filter((node) => node.nodeType === 3);
|
||||
}
|
||||
|
||||
function hasOwnTextCandidate(element) {
|
||||
const text = textContentFor(element);
|
||||
function textContentFor(element, ownTextOnly) {
|
||||
const content = ownTextOnly
|
||||
? directTextNodes(element)
|
||||
.map((node) => node.textContent || "")
|
||||
.join("")
|
||||
: element.innerText || element.textContent || "";
|
||||
return content.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function hasOwnTextCandidate(element, directOnly) {
|
||||
const text = textContentFor(element, directOnly);
|
||||
if (!text) return false;
|
||||
if (directOnly) return true;
|
||||
for (const child of Array.from(element.children)) {
|
||||
if (isVisibleElement(child) && textContentFor(child)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function textRectFor(element) {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(element);
|
||||
const rects = Array.from(range.getClientRects()).filter(
|
||||
(rect) => rect.width > 0.5 && rect.height > 0.5,
|
||||
);
|
||||
range.detach();
|
||||
function textClientRects(element, directOnly) {
|
||||
const subjects = directOnly ? directTextNodes(element) : [element];
|
||||
const rects = [];
|
||||
for (const subject of subjects) {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(subject);
|
||||
rects.push(
|
||||
...Array.from(range.getClientRects()).filter(
|
||||
(rect) => rect.width > 0.5 && rect.height > 0.5,
|
||||
),
|
||||
);
|
||||
range.detach();
|
||||
}
|
||||
return rects;
|
||||
}
|
||||
|
||||
function textRectFor(element, directOnly) {
|
||||
const rects = textClientRects(element, directOnly);
|
||||
if (rects.length === 0) return null;
|
||||
|
||||
const union = rects.reduce(
|
||||
@@ -567,9 +627,12 @@
|
||||
const area = intersectionArea(a.rect, b.rect);
|
||||
if (area <= Math.min(rectArea(a.rect), rectArea(b.rect)) * 0.2) return null;
|
||||
return {
|
||||
// Warning, not error: must not fail the exit code (ok = errorCount === 0)
|
||||
// for compositions that intentionally layer text. Re-promote once the
|
||||
// data-layout-allow-overlap opt-out is widely adopted.
|
||||
// Warning at the per-sample level: a single-sample overlap is usually an
|
||||
// entrance/exit transient (two blocks crossing mid-animation), not a real
|
||||
// collision. `collapseStaticLayoutIssues` (utils/layoutAudit.ts) re-promotes
|
||||
// this to error once the SAME overlap is held across >= 2 adjacent samples
|
||||
// (or ~500ms of timeline) — a persistence-tiered replacement for the old
|
||||
// "re-promote once data-layout-allow-overlap is widely adopted" plan (#U10).
|
||||
code: "content_overlap",
|
||||
severity: "warning",
|
||||
time,
|
||||
@@ -602,6 +665,7 @@
|
||||
}
|
||||
|
||||
const RASTER_TAGS = new Set(["IMG", "VIDEO", "CANVAS"]);
|
||||
const FRAME_MEDIA_TAGS = new Set([...RASTER_TAGS, "SVG"]);
|
||||
|
||||
// An element hides text beneath it when it paints opaque pixels at near-full
|
||||
// opacity: raster content (img/video/canvas), a background image, or a solid
|
||||
@@ -667,42 +731,135 @@
|
||||
return hit;
|
||||
}
|
||||
|
||||
const OCCLUSION_PROBE_Y_FRACTIONS = [0.25, 0.5, 0.75];
|
||||
const OCCLUSION_PROBE_X_FRACTIONS = [0.03, 0.1, 0.2, 0.35, 0.5, 0.65, 0.8, 0.9, 0.97];
|
||||
const OCCLUSION_GRID_POINTS =
|
||||
OCCLUSION_PROBE_Y_FRACTIONS.length * OCCLUSION_PROBE_X_FRACTIONS.length;
|
||||
|
||||
// Short, atomic text (a label/button/word, no whitespace) reads as a single
|
||||
// unit — ANY covered probe point changes what it says, so flag at any hit
|
||||
// (the pre-#U10 behaviour). Longer prose survives a nibbled edge; only flag
|
||||
// once a real share of it is covered — see `occludedTextIssue`.
|
||||
const ATOMIC_LABEL_MAX_CHARS = 16;
|
||||
const PROSE_COVERAGE_FLOOR = 0.15;
|
||||
|
||||
function isAtomicLabel(text) {
|
||||
return text.length > 0 && text.length <= ATOMIC_LABEL_MAX_CHARS && !/\s/.test(text);
|
||||
}
|
||||
|
||||
// Sweep a grid across the text box (three rows, not just the mid-line, so
|
||||
// overlays covering only part of a multi-line block are caught) and return
|
||||
// the first opaque element painted over any sample point.
|
||||
function firstOccluder(element, textRect) {
|
||||
for (const yFraction of [0.25, 0.5, 0.75]) {
|
||||
// overlays covering only part of a multi-line block are caught). Unlike a
|
||||
// first-hit scan, this keeps sampling every point so it can report what
|
||||
// fraction of the box is actually covered — a corner nibble on a paragraph
|
||||
// reads very differently from a label buried under an overlay. Still
|
||||
// returns the first opaque element found, for `containerSelector`.
|
||||
function occlusionCoverage(element, textRect) {
|
||||
let occluder = null;
|
||||
let hits = 0;
|
||||
for (const yFraction of OCCLUSION_PROBE_Y_FRACTIONS) {
|
||||
const y = textRect.top + textRect.height * yFraction;
|
||||
for (const xFraction of [0.03, 0.1, 0.2, 0.35, 0.5, 0.65, 0.8, 0.9, 0.97]) {
|
||||
const occluder = occluderAt(element, textRect.left + textRect.width * xFraction, y);
|
||||
if (occluder) return occluder;
|
||||
for (const xFraction of OCCLUSION_PROBE_X_FRACTIONS) {
|
||||
const hit = occluderAt(element, textRect.left + textRect.width * xFraction, y);
|
||||
if (!hit) continue;
|
||||
hits += 1;
|
||||
if (!occluder) occluder = hit;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return { occluder, coveredFraction: round(hits / OCCLUSION_GRID_POINTS) };
|
||||
}
|
||||
|
||||
// Catches the blind spot the overflow checks miss: text that fits its box
|
||||
// perfectly but is covered by a later sibling/overlay.
|
||||
// perfectly but is covered by a later sibling/overlay. An atomic label
|
||||
// (short, no whitespace) flags at any coverage; ordinary prose only flags
|
||||
// once coveredFraction clears PROSE_COVERAGE_FLOOR, since a sliver of edge
|
||||
// cover on a paragraph is usually a styling artifact, not a reading defect.
|
||||
function occludedTextIssue(element, time) {
|
||||
if (hasAllowOcclusionFlag(element)) return null;
|
||||
const textRect = textRectFor(element);
|
||||
if (!textRect) return null;
|
||||
const occluder = firstOccluder(element, textRect);
|
||||
const text = textContentFor(element);
|
||||
const { occluder, coveredFraction } = occlusionCoverage(element, textRect);
|
||||
if (!occluder) return null;
|
||||
if (!isAtomicLabel(text) && coveredFraction < PROSE_COVERAGE_FLOOR) return null;
|
||||
return {
|
||||
code: "text_occluded",
|
||||
severity: "error",
|
||||
time,
|
||||
selector: selectorFor(element),
|
||||
containerSelector: selectorFor(occluder),
|
||||
text: textContentFor(element),
|
||||
text,
|
||||
message: "Text is hidden beneath an opaque element.",
|
||||
rect: textRect,
|
||||
coveredFraction,
|
||||
fixHint:
|
||||
"Give the text its own zone, raise its stacking order above the covering element, or mark intentional layering with data-layout-allow-occlusion.",
|
||||
};
|
||||
}
|
||||
|
||||
function candidateAnchor(element) {
|
||||
const dataAttributes = {};
|
||||
for (const attribute of Array.from(element.attributes)) {
|
||||
if (attribute.name.startsWith("data-")) dataAttributes[attribute.name] = attribute.value;
|
||||
}
|
||||
const source = element
|
||||
.closest("[data-composition-file]")
|
||||
?.getAttribute("data-composition-file");
|
||||
return {
|
||||
selector: uniqueSelectorFor(element),
|
||||
dataAttributes,
|
||||
sourceFile: source || "index.html",
|
||||
};
|
||||
}
|
||||
|
||||
function geometryCandidate(element, kind, rect, elementRect, rootRect, tolerance) {
|
||||
const tag = element.tagName.toLowerCase();
|
||||
const text = kind === "text" ? textContentFor(element, true) : tag;
|
||||
const overflow = kind === "media" ? overflowFor(elementRect, rootRect, tolerance) : null;
|
||||
return {
|
||||
kind,
|
||||
tag,
|
||||
text,
|
||||
rect,
|
||||
elementRect,
|
||||
...candidateAnchor(element),
|
||||
...(overflow ? { overflow } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
window.__hyperframesGeometryCandidates = function collectGeometryCandidates(options) {
|
||||
const includeText = options?.text === true;
|
||||
const includeMedia = options?.media === true;
|
||||
if (!includeText && !includeMedia) return [];
|
||||
const tolerance = typeof options?.tolerance === "number" ? options.tolerance : 2;
|
||||
const root =
|
||||
document.querySelector("[data-composition-id][data-width][data-height]") ||
|
||||
document.querySelector("[data-composition-id]") ||
|
||||
document.body;
|
||||
const rootRect = rootRectFor(root);
|
||||
const candidates = [];
|
||||
for (const element of Array.from(document.querySelectorAll("body *"))) {
|
||||
if (element.closest('[data-composition-id="captions"], .caption-layer, #caption-stage')) {
|
||||
continue;
|
||||
}
|
||||
if (!isVisibleElement(element, 0.05, false)) continue;
|
||||
const elementRect = toRect(element.getBoundingClientRect());
|
||||
if (includeText && hasOwnTextCandidate(element, true)) {
|
||||
const rect = textRectFor(element, true);
|
||||
if (rect) {
|
||||
candidates.push(
|
||||
geometryCandidate(element, "text", rect, elementRect, rootRect, tolerance),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (includeMedia && FRAME_MEDIA_TAGS.has(element.tagName.toUpperCase())) {
|
||||
candidates.push(
|
||||
geometryCandidate(element, "media", elementRect, elementRect, rootRect, tolerance),
|
||||
);
|
||||
}
|
||||
}
|
||||
return candidates;
|
||||
};
|
||||
|
||||
window.__hyperframesLayoutAudit = function auditLayout(options) {
|
||||
const time = options && typeof options.time === "number" ? options.time : 0;
|
||||
const tolerance =
|
||||
@@ -712,7 +869,9 @@
|
||||
document.querySelector("[data-composition-id]") ||
|
||||
document.body;
|
||||
const rootRect = rootRectFor(root);
|
||||
const elements = Array.from(root.querySelectorAll("*")).filter(isVisibleElement);
|
||||
const elements = Array.from(root.querySelectorAll("*")).filter((element) =>
|
||||
isVisibleElement(element),
|
||||
);
|
||||
const issues = [];
|
||||
|
||||
for (const element of elements) {
|
||||
@@ -728,4 +887,28 @@
|
||||
issues.push(...contentOverlapIssues(root, time));
|
||||
return issues;
|
||||
};
|
||||
|
||||
// Frozen-sweep guard (#U10, checkPipeline.ts): a compact per-sample
|
||||
// fingerprint of every visible element's box + opacity, in DOM order. Node
|
||||
// calls this once per seeked grid point and compares the strings across the
|
||||
// whole run — if every sample produces the identical string, the seek never
|
||||
// actually moved anything and the whole audit run is unreliable. Deliberately
|
||||
// a single opaque string (not a structured array) since Node only ever needs
|
||||
// equality, not per-element diffing.
|
||||
window.__hyperframesLayoutGeometry = function collectLayoutGeometry() {
|
||||
const root =
|
||||
document.querySelector("[data-composition-id][data-width][data-height]") ||
|
||||
document.querySelector("[data-composition-id]") ||
|
||||
document.body;
|
||||
const elements = Array.from(root.querySelectorAll("*")).filter((element) =>
|
||||
isVisibleElement(element),
|
||||
);
|
||||
return elements
|
||||
.map((element) => {
|
||||
const rect = toRect(element.getBoundingClientRect());
|
||||
const opacity = round(opacityChain(element));
|
||||
return `${rect.left},${rect.top},${rect.width},${rect.height},${opacity}`;
|
||||
})
|
||||
.join("|");
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -15,11 +15,20 @@ interface RectInput {
|
||||
height: number;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
document.body.innerHTML = "";
|
||||
Reflect.deleteProperty(document, "elementFromPoint");
|
||||
Reflect.deleteProperty(window, "__hyperframesLayoutAudit");
|
||||
clearGeometryCollector();
|
||||
});
|
||||
|
||||
describe("layout-audit.browser", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
document.body.innerHTML = "";
|
||||
delete (window as unknown as { __hyperframesLayoutAudit?: unknown }).__hyperframesLayoutAudit;
|
||||
clearGeometryCollector();
|
||||
});
|
||||
|
||||
it("uses authored canvas dimensions when the root bounding rect is degenerate", () => {
|
||||
@@ -135,6 +144,206 @@ describe("layout-audit.browser", () => {
|
||||
|
||||
expect(runAudit().some((issue) => issue.code === "text_box_overflow")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps auditing visible descendants beyond the second element", () => {
|
||||
document.body.innerHTML = `
|
||||
<div id="root" data-composition-id="main" data-width="640" data-height="360">
|
||||
<div id="first"></div>
|
||||
<div id="second"></div>
|
||||
<div id="third"></div>
|
||||
<div id="late">Late visible copy</div>
|
||||
</div>
|
||||
`;
|
||||
installGeometry({
|
||||
root: rect({ left: 0, top: 0, width: 640, height: 360 }),
|
||||
late: rect({ left: 700, top: 100, width: 140, height: 40 }),
|
||||
text: rect({ left: 700, top: 100, width: 140, height: 40 }),
|
||||
});
|
||||
installAuditScript();
|
||||
|
||||
expect(runAudit()).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ code: "canvas_overflow", selector: "#late" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("is inert unless text or media candidates are explicitly requested", () => {
|
||||
document.body.innerHTML = `
|
||||
<div id="root" data-composition-id="main" data-width="640" data-height="360">
|
||||
<div id="copy">Visible copy</div>
|
||||
</div>
|
||||
`;
|
||||
installGeometry({
|
||||
root: rect({ left: 0, top: 0, width: 640, height: 360 }),
|
||||
copy: rect({ left: 100, top: 100, width: 200, height: 40 }),
|
||||
text: rect({ left: 100, top: 100, width: 200, height: 40 }),
|
||||
});
|
||||
installAuditScript();
|
||||
|
||||
expect(runGeometryCandidates({ text: false, media: false, tolerance: 2 })).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns own-text rects and media overflow while excluding caption layers", () => {
|
||||
document.body.innerHTML = `
|
||||
<div id="root" data-composition-id="main" data-width="640" data-height="360">
|
||||
<section data-composition-file="scenes/hero.html">
|
||||
<div id="copy" data-layout-name="copy">Own copy <span id="nested">Nested</span></div>
|
||||
<img id="image" src="data:image/png;base64,AA==" />
|
||||
<svg id="vector"></svg>
|
||||
</section>
|
||||
<div class="caption-layer"><p id="caption">Authored captions</p></div>
|
||||
</div>
|
||||
`;
|
||||
installGeometry({
|
||||
root: rect({ left: 0, top: 0, width: 640, height: 360 }),
|
||||
copy: rect({ left: 100, top: 260, width: 180, height: 40 }),
|
||||
headline: rect({ left: 100, top: 260, width: 180, height: 40 }),
|
||||
nested: rect({ left: 220, top: 260, width: 60, height: 40 }),
|
||||
image: rect({ left: 600, top: 40, width: 200, height: 100 }),
|
||||
vector: rect({ left: -130, top: 160, width: 100, height: 100 }),
|
||||
caption: rect({ left: 200, top: 300, width: 240, height: 40 }),
|
||||
text: rect({ left: 100, top: 260, width: 100, height: 40 }),
|
||||
});
|
||||
installAuditScript();
|
||||
|
||||
const candidates = runGeometryCandidates({ text: true, media: true, tolerance: 2 });
|
||||
|
||||
expect(candidates).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
kind: "text",
|
||||
tag: "div",
|
||||
text: "Own copy",
|
||||
selector: "#copy",
|
||||
sourceFile: "scenes/hero.html",
|
||||
rect: { left: 100, top: 260, right: 200, bottom: 300, width: 100, height: 40 },
|
||||
elementRect: { left: 100, top: 260, right: 280, bottom: 300, width: 180, height: 40 },
|
||||
}),
|
||||
expect.objectContaining({
|
||||
kind: "media",
|
||||
tag: "img",
|
||||
selector: "#image",
|
||||
overflow: { right: 160 },
|
||||
}),
|
||||
expect.objectContaining({
|
||||
kind: "media",
|
||||
tag: "svg",
|
||||
selector: "#vector",
|
||||
overflow: { left: 130 },
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(candidates.some((candidate) => candidate.selector === "#caption")).toBe(false);
|
||||
});
|
||||
|
||||
it("scans body-level composition siblings and includes a media boundary root", () => {
|
||||
document.body.innerHTML = `
|
||||
<canvas id="boundary" data-composition-id="background" data-width="640" data-height="360"></canvas>
|
||||
<div id="root" data-composition-id="main" data-width="640" data-height="360">
|
||||
<p id="portal-copy">Portal copy</p>
|
||||
</div>
|
||||
<img id="portal-image" src="data:image/png;base64,AA==" />
|
||||
`;
|
||||
installGeometry({
|
||||
root: rect({ left: 0, top: 0, width: 640, height: 360 }),
|
||||
"portal-copy": rect({ left: 100, top: 260, width: 180, height: 40 }),
|
||||
"portal-image": rect({ left: 600, top: 80, width: 180, height: 100 }),
|
||||
text: rect({ left: 100, top: 260, width: 180, height: 40 }),
|
||||
});
|
||||
installAuditScript();
|
||||
|
||||
const candidates = runGeometryCandidates({ text: true, media: true, tolerance: 2 });
|
||||
|
||||
expect(candidates.map((candidate) => candidate.selector)).toEqual(
|
||||
expect.arrayContaining(["#boundary", "#portal-copy", "#portal-image"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns unique structural selectors for repeated class-only media", () => {
|
||||
document.body.innerHTML = `
|
||||
<div id="root" data-composition-id="main" data-width="640" data-height="360">
|
||||
<img class="tile" src="data:image/png;base64,AA==" />
|
||||
<img class="tile" src="data:image/png;base64,AA==" />
|
||||
</div>
|
||||
`;
|
||||
installGeometry({
|
||||
root: rect({ left: 0, top: 0, width: 640, height: 360 }),
|
||||
"": rect({ left: 100, top: 100, width: 100, height: 100 }),
|
||||
});
|
||||
installAuditScript();
|
||||
|
||||
const candidates = runGeometryCandidates({ text: false, media: true, tolerance: 2 });
|
||||
const images = Array.from(document.querySelectorAll("img"));
|
||||
|
||||
expect(candidates).toHaveLength(2);
|
||||
expect(new Set(candidates.map((candidate) => candidate.selector)).size).toBe(2);
|
||||
expect(document.querySelector(candidates[0]?.selector ?? "")).toBe(images[0]);
|
||||
expect(document.querySelector(candidates[1]?.selector ?? "")).toBe(images[1]);
|
||||
});
|
||||
|
||||
it("keeps visible clip-path text when pointer events do not participate in hit testing", () => {
|
||||
document.body.innerHTML = `
|
||||
<div id="root" data-composition-id="main" data-width="640" data-height="360">
|
||||
<p id="clipped-copy">Visible clipped copy</p>
|
||||
</div>
|
||||
`;
|
||||
installGeometry(
|
||||
{
|
||||
root: rect({ left: 0, top: 0, width: 640, height: 360 }),
|
||||
"clipped-copy": rect({ left: 100, top: 100, width: 200, height: 40 }),
|
||||
text: rect({ left: 100, top: 100, width: 200, height: 40 }),
|
||||
},
|
||||
{ "clipped-copy": { clipPath: "inset(0 10% 0 0)", pointerEvents: "none" } },
|
||||
);
|
||||
Reflect.set(
|
||||
document,
|
||||
"elementFromPoint",
|
||||
vi.fn(() => document.getElementById("root")),
|
||||
);
|
||||
installAuditScript();
|
||||
|
||||
const candidates = runGeometryCandidates({ text: true, media: false, tolerance: 2 });
|
||||
|
||||
expect(candidates).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ selector: "#clipped-copy" })]),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the bridge opacity floor across the ancestor chain", () => {
|
||||
document.body.innerHTML = `
|
||||
<div id="root" data-composition-id="main" data-width="640" data-height="360">
|
||||
<div id="faint-parent"><p id="hidden-copy">Hidden copy</p></div>
|
||||
<div id="soft-parent"><p id="visible-copy">Visible copy</p></div>
|
||||
<div id="stacked-parent"><p id="stacked-copy">Stacked opacity copy</p></div>
|
||||
</div>
|
||||
`;
|
||||
installGeometry(
|
||||
{
|
||||
root: rect({ left: 0, top: 0, width: 640, height: 360 }),
|
||||
"faint-parent": rect({ left: 40, top: 40, width: 200, height: 40 }),
|
||||
"hidden-copy": rect({ left: 40, top: 40, width: 200, height: 40 }),
|
||||
"soft-parent": rect({ left: 40, top: 120, width: 200, height: 40 }),
|
||||
"visible-copy": rect({ left: 40, top: 120, width: 200, height: 40 }),
|
||||
"stacked-parent": rect({ left: 40, top: 200, width: 200, height: 40 }),
|
||||
"stacked-copy": rect({ left: 40, top: 200, width: 200, height: 40 }),
|
||||
text: rect({ left: 40, top: 120, width: 200, height: 40 }),
|
||||
},
|
||||
{
|
||||
"faint-parent": { opacity: "0.04" },
|
||||
"soft-parent": { opacity: "0.1" },
|
||||
"stacked-parent": { opacity: "0.2" },
|
||||
"stacked-copy": { opacity: "0.2" },
|
||||
},
|
||||
);
|
||||
installAuditScript();
|
||||
|
||||
const candidates = runGeometryCandidates({ text: true, media: false, tolerance: 2 });
|
||||
|
||||
expect(candidates.some((candidate) => candidate.selector === "#hidden-copy")).toBe(false);
|
||||
expect(candidates.some((candidate) => candidate.selector === "#visible-copy")).toBe(true);
|
||||
expect(candidates.some((candidate) => candidate.selector === "#stacked-copy")).toBe(true);
|
||||
});
|
||||
|
||||
describe("layout-audit.browser content overlap", () => {
|
||||
@@ -143,6 +352,7 @@ describe("layout-audit.browser content overlap", () => {
|
||||
document.body.innerHTML = "";
|
||||
delete (document as unknown as { elementFromPoint?: unknown }).elementFromPoint;
|
||||
delete (window as unknown as { __hyperframesLayoutAudit?: unknown }).__hyperframesLayoutAudit;
|
||||
clearGeometryCollector();
|
||||
});
|
||||
|
||||
it("flags two solid text blocks that overlap", () => {
|
||||
@@ -246,6 +456,84 @@ describe("contrast-audit.browser clip-path visibility", () => {
|
||||
|
||||
expect(await runContrastAudit()).toEqual([]);
|
||||
});
|
||||
|
||||
it("excludes data-layout-ignore set dressing from contrast reports", async () => {
|
||||
document.body.innerHTML = `
|
||||
<div id="root" data-composition-id="main" data-width="640" data-height="360">
|
||||
<div data-layout-ignore>
|
||||
<div id="rail-label">SHAPE</div>
|
||||
</div>
|
||||
<div id="headline">Readable copy</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
vi.spyOn(window, "getComputedStyle").mockImplementation(
|
||||
() =>
|
||||
({
|
||||
display: "block",
|
||||
visibility: "visible",
|
||||
opacity: "1",
|
||||
color: "rgb(30, 30, 42)",
|
||||
fontSize: "32px",
|
||||
fontWeight: "400",
|
||||
clipPath: "none",
|
||||
}) as unknown as CSSStyleDeclaration,
|
||||
);
|
||||
for (const id of ["rail-label", "headline"]) {
|
||||
vi.spyOn(document.getElementById(id)!, "getBoundingClientRect").mockReturnValue(
|
||||
rect({ left: 100, top: id === "headline" ? 200 : 100, width: 400, height: 40 }),
|
||||
);
|
||||
}
|
||||
(document as unknown as { elementFromPoint: () => Element | null }).elementFromPoint = () =>
|
||||
null;
|
||||
|
||||
installContrastScript();
|
||||
|
||||
const entries = await runContrastAudit();
|
||||
const selectors = entries.map((entry) => entry.selector);
|
||||
expect(selectors).toContain("#headline");
|
||||
expect(selectors).not.toContain("#rail-label");
|
||||
});
|
||||
|
||||
it("excludes text that has left the canvas from contrast reports", async () => {
|
||||
document.body.innerHTML = `
|
||||
<div id="root" data-composition-id="main" data-width="640" data-height="360">
|
||||
<div id="exited">You</div>
|
||||
<div id="headline">Readable copy</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
vi.spyOn(window, "getComputedStyle").mockImplementation(
|
||||
() =>
|
||||
({
|
||||
display: "block",
|
||||
visibility: "visible",
|
||||
opacity: "1",
|
||||
color: "rgb(255, 255, 255)",
|
||||
fontSize: "32px",
|
||||
fontWeight: "400",
|
||||
clipPath: "none",
|
||||
}) as unknown as CSSStyleDeclaration,
|
||||
);
|
||||
Object.defineProperty(window, "innerWidth", { configurable: true, value: 640 });
|
||||
Object.defineProperty(window, "innerHeight", { configurable: true, value: 360 });
|
||||
// The cursor-exit shape: element parked far past the top-left corner.
|
||||
vi.spyOn(document.getElementById("exited")!, "getBoundingClientRect").mockReturnValue(
|
||||
rect({ left: -1420, top: -500, width: 60, height: 24 }),
|
||||
);
|
||||
vi.spyOn(document.getElementById("headline")!, "getBoundingClientRect").mockReturnValue(
|
||||
rect({ left: 100, top: 200, width: 400, height: 40 }),
|
||||
);
|
||||
(document as unknown as { elementFromPoint: () => Element | null }).elementFromPoint = () =>
|
||||
null;
|
||||
|
||||
installContrastScript();
|
||||
|
||||
const entries = await runContrastAudit();
|
||||
const selectors = entries.map((entry) => entry.selector);
|
||||
expect(selectors).toContain("#headline");
|
||||
expect(selectors).not.toContain("#exited");
|
||||
});
|
||||
});
|
||||
|
||||
describe("contrast-audit.browser background sampling", () => {
|
||||
@@ -404,6 +692,7 @@ describe("layout-audit.browser occlusion", () => {
|
||||
document.body.innerHTML = "";
|
||||
delete (document as unknown as { elementFromPoint?: unknown }).elementFromPoint;
|
||||
delete (window as unknown as { __hyperframesLayoutAudit?: unknown }).__hyperframesLayoutAudit;
|
||||
clearGeometryCollector();
|
||||
});
|
||||
|
||||
it("flags text painted over by an opaque sibling overlay", () => {
|
||||
@@ -440,8 +729,92 @@ describe("layout-audit.browser occlusion", () => {
|
||||
});
|
||||
expect(issues.some((issue) => issue.code === "text_occluded")).toBe(false);
|
||||
});
|
||||
|
||||
it("carries the fully-covered fraction when the occluder hits every probe point", () => {
|
||||
const occluded = auditOcclusionScene({
|
||||
overlayStyle: { backgroundColor: "rgb(10, 10, 10)" },
|
||||
topmostId: "overlay",
|
||||
}).find((issue) => issue.code === "text_occluded");
|
||||
expect(occluded?.coveredFraction).toBe(1);
|
||||
});
|
||||
|
||||
// #U10: a 2-point hit on the 27-point probe grid (3 rows x 9 columns) is a
|
||||
// sliver of edge cover — reports ~0.07 coverage either way, but only GATES
|
||||
// (produces a finding) for short atomic labels; ordinary prose survives it.
|
||||
it("reports ~0.07 coverage for a 2-of-27 grid hit and flags an atomic label at that coverage", () => {
|
||||
const issues = auditCoverageScene({ text: "SUBSCRIBE", hitCount: 2 });
|
||||
const occluded = issues.find((issue) => issue.code === "text_occluded");
|
||||
expect(occluded).toBeDefined();
|
||||
expect(occluded?.coveredFraction).toBe(0.07);
|
||||
});
|
||||
|
||||
it("does not flag ordinary prose at the same ~0.07 coverage a label would flag at", () => {
|
||||
const issues = auditCoverageScene({
|
||||
text: "This paragraph is long enough to read as ordinary prose, not a label.",
|
||||
hitCount: 2,
|
||||
});
|
||||
expect(issues.some((issue) => issue.code === "text_occluded")).toBe(false);
|
||||
});
|
||||
|
||||
it("flags prose once coverage clears the 0.15 floor", () => {
|
||||
// 5/27 ≈ 0.185, comfortably over the ~0.15 prose floor.
|
||||
const issues = auditCoverageScene({
|
||||
text: "This paragraph is long enough to read as ordinary prose, not a label.",
|
||||
hitCount: 5,
|
||||
});
|
||||
expect(issues.some((issue) => issue.code === "text_occluded")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// Mirrors OCCLUSION_PROBE_Y_FRACTIONS / OCCLUSION_PROBE_X_FRACTIONS in
|
||||
// layout-audit.browser.js, so a test can force an exact number of grid hits
|
||||
// against the same probe coordinates the audit itself sweeps.
|
||||
const OCCLUSION_PROBE_Y_FRACTIONS = [0.25, 0.5, 0.75];
|
||||
const OCCLUSION_PROBE_X_FRACTIONS = [0.03, 0.1, 0.2, 0.35, 0.5, 0.65, 0.8, 0.9, 0.97];
|
||||
|
||||
function occlusionProbePoints(textRect: RectInput): Array<{ x: number; y: number }> {
|
||||
const points: Array<{ x: number; y: number }> = [];
|
||||
for (const yFraction of OCCLUSION_PROBE_Y_FRACTIONS) {
|
||||
const y = textRect.top + textRect.height * yFraction;
|
||||
for (const xFraction of OCCLUSION_PROBE_X_FRACTIONS) {
|
||||
points.push({ x: textRect.left + textRect.width * xFraction, y });
|
||||
}
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
// Builds an occlusion scene where exactly `hitCount` of the 27 probe points
|
||||
// are covered by an opaque overlay and the rest hit the headline itself
|
||||
// (self-hit — not foreign, so not counted as occluded).
|
||||
function auditCoverageScene(options: {
|
||||
text: string;
|
||||
hitCount: number;
|
||||
}): ReturnType<typeof runAudit> {
|
||||
const textRect = { left: 200, top: 500, width: 600, height: 80 };
|
||||
document.body.innerHTML = `
|
||||
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
|
||||
<div id="headline">${options.text}</div>
|
||||
<div id="overlay"></div>
|
||||
</div>
|
||||
`;
|
||||
installOcclusionGeometry({
|
||||
styleOverrides: { overlay: { backgroundColor: "rgb(10, 10, 10)" } },
|
||||
headlineTextRect: rect(textRect),
|
||||
topmostId: "headline",
|
||||
});
|
||||
const hitPoints = occlusionProbePoints(textRect).slice(0, options.hitCount);
|
||||
(
|
||||
document as unknown as { elementFromPoint: (x: number, y: number) => Element | null }
|
||||
).elementFromPoint = (x, y) => {
|
||||
const isHit = hitPoints.some(
|
||||
(point) => Math.abs(point.x - x) < 0.01 && Math.abs(point.y - y) < 0.01,
|
||||
);
|
||||
return document.getElementById(isHit ? "overlay" : "headline");
|
||||
};
|
||||
installAuditScript();
|
||||
return runAudit();
|
||||
}
|
||||
|
||||
function auditOcclusionScene(options: {
|
||||
headlineAttrs?: string;
|
||||
overlayStyle: Partial<Record<string, string>>;
|
||||
@@ -601,28 +974,31 @@ async function runContrastAudit(): Promise<Array<Record<string, unknown>>> {
|
||||
return w.__contrastAuditFinish("stub", 0, candidates);
|
||||
}
|
||||
|
||||
function runAudit(): Array<{
|
||||
interface AuditIssue {
|
||||
code: string;
|
||||
selector: string;
|
||||
containerSelector?: string;
|
||||
overflow?: Record<string, number>;
|
||||
message?: string;
|
||||
}> {
|
||||
coveredFraction?: number;
|
||||
}
|
||||
|
||||
function runAudit(): AuditIssue[] {
|
||||
const audit = (
|
||||
window as unknown as {
|
||||
__hyperframesLayoutAudit: (options: { time: number; tolerance: number }) => Array<{
|
||||
code: string;
|
||||
selector: string;
|
||||
containerSelector?: string;
|
||||
overflow?: Record<string, number>;
|
||||
message?: string;
|
||||
}>;
|
||||
__hyperframesLayoutAudit: (options: { time: number; tolerance: number }) => AuditIssue[];
|
||||
}
|
||||
).__hyperframesLayoutAudit;
|
||||
return audit({ time: 1, tolerance: 2 });
|
||||
}
|
||||
|
||||
function installGeometry(rects: Record<string, DOMRect>): void {
|
||||
function installGeometry(
|
||||
rects: Record<string, DOMRect>,
|
||||
styleOverrides: Record<string, Partial<CSSStyleDeclaration>> = {},
|
||||
): void {
|
||||
// Style-fixture branching mirrors the audit's per-property reads; splitting
|
||||
// it would scatter one mock across helpers.
|
||||
// fallow-ignore-next-line complexity
|
||||
vi.spyOn(window, "getComputedStyle").mockImplementation((element) => {
|
||||
const el = element as Element;
|
||||
const isBubble = el.id === "bubble";
|
||||
@@ -648,6 +1024,7 @@ function installGeometry(rects: Record<string, DOMRect>): void {
|
||||
paddingBottom: isBubble ? "16px" : "0px",
|
||||
paddingLeft: isBubble ? "16px" : "0px",
|
||||
fontSize: "36px",
|
||||
...styleOverrides[el.id],
|
||||
} as unknown as CSSStyleDeclaration;
|
||||
});
|
||||
|
||||
@@ -678,6 +1055,41 @@ function installGeometry(rects: Record<string, DOMRect>): void {
|
||||
});
|
||||
}
|
||||
|
||||
interface GeometryCandidateResult {
|
||||
kind: "text" | "media";
|
||||
tag: string;
|
||||
text: string;
|
||||
selector: string;
|
||||
sourceFile: string;
|
||||
rect: Record<string, number>;
|
||||
elementRect: Record<string, number>;
|
||||
overflow?: Record<string, number>;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__hyperframesGeometryCandidates?: (options: {
|
||||
text: boolean;
|
||||
media: boolean;
|
||||
tolerance: number;
|
||||
}) => GeometryCandidateResult[];
|
||||
}
|
||||
}
|
||||
|
||||
function runGeometryCandidates(options: {
|
||||
text: boolean;
|
||||
media: boolean;
|
||||
tolerance: number;
|
||||
}): GeometryCandidateResult[] {
|
||||
const collector = window.__hyperframesGeometryCandidates;
|
||||
if (!collector) throw new Error("Geometry collector was not installed");
|
||||
return collector(options);
|
||||
}
|
||||
|
||||
function clearGeometryCollector(): void {
|
||||
delete window.__hyperframesGeometryCandidates;
|
||||
}
|
||||
|
||||
function rect({ left, top, width, height }: RectInput): DOMRect {
|
||||
return {
|
||||
left,
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
bundleToSingleHtmlFailureMock,
|
||||
metaDescription,
|
||||
resolveProjectMock,
|
||||
runAndCaptureStdio,
|
||||
runAndFindJsonLogCall,
|
||||
runAndParseJsonEnvelope,
|
||||
} from "./deprecationTestHarness.js";
|
||||
|
||||
// resolveProject and bundleToSingleHtml are both reached via a dynamic
|
||||
// `await import(...)` inside layout.ts's run() / runLayoutAudit(), so
|
||||
// vi.mock intercepts them the same way it would a static import. Mocking
|
||||
// resolveProject skips real filesystem project resolution; mocking
|
||||
// bundleToSingleHtml gives a deterministic, fast failure well before any
|
||||
// real browser or network work — exercising run()'s outer catch (the JSON
|
||||
// failure envelope) without needing headless Chrome.
|
||||
vi.mock("../utils/project.js", () => resolveProjectMock());
|
||||
vi.mock("@hyperframes/core/compiler", () => bundleToSingleHtmlFailureMock());
|
||||
|
||||
import { createInspectCommand } from "./layout.js";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("layout command deprecation (U5)", () => {
|
||||
it("marks both the layout and inspect command names' shared description as deprecated", () => {
|
||||
expect(metaDescription(createInspectCommand("layout"))).toContain("(deprecated, use check)");
|
||||
expect(metaDescription(createInspectCommand("inspect"))).toContain("(deprecated, use check)");
|
||||
});
|
||||
|
||||
it("prints a one-line deprecation notice to stderr and never to stdout", async () => {
|
||||
const { stderrText, stdoutText } = await runAndCaptureStdio(createInspectCommand("layout"));
|
||||
expect(stderrText).toContain("hyperframes layout");
|
||||
expect(stderrText).toContain("hyperframes check");
|
||||
expect(stdoutText).toBe("");
|
||||
});
|
||||
|
||||
it("--json output is valid JSON with _meta.deprecated === true on failure", async () => {
|
||||
const { parsed } = await runAndParseJsonEnvelope(createInspectCommand("layout"));
|
||||
expect(parsed.ok).toBe(false);
|
||||
expect(parsed._meta.deprecated).toBe(true);
|
||||
});
|
||||
|
||||
it("the inspect command name produces the same _meta.deprecated === true envelope", async () => {
|
||||
const jsonCall = await runAndFindJsonLogCall(createInspectCommand("inspect"));
|
||||
const parsed = JSON.parse(String(jsonCall?.[0]));
|
||||
expect(parsed._meta.deprecated).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,7 @@ import { c } from "../ui/colors.js";
|
||||
import { resolveProject } from "../utils/project.js";
|
||||
import { normalizeErrorMessage } from "../utils/errorMessage.js";
|
||||
import { serveStaticProjectHtml } from "../utils/staticProjectServer.js";
|
||||
import { withMeta } from "../utils/updateCheck.js";
|
||||
import { printDeprecationNotice, withMeta } from "../utils/updateCheck.js";
|
||||
import {
|
||||
buildLayoutSampleTimes,
|
||||
buildTransitionSampleTimes,
|
||||
@@ -26,10 +26,17 @@ import {
|
||||
type MotionFrame,
|
||||
} from "../utils/motionAudit.js";
|
||||
import { findMotionSpec, readMotionSpec, type MotionSpec } from "../utils/motionSpec.js";
|
||||
import {
|
||||
AUDIT_SEEK_OPTIONS,
|
||||
installPageFunctionGuard,
|
||||
seekCompositionTimeline,
|
||||
waitForCompositionFonts,
|
||||
type SeekCompositionTimelineOptions,
|
||||
} from "../capture/captureCompositionFrame.js";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const SEEK_SETTLE_MS = 120;
|
||||
const LAYOUT_SEEK_OPTIONS: SeekCompositionTimelineOptions = AUDIT_SEEK_OPTIONS;
|
||||
// All new envelope fields are optional (?); additive changes don't bump this.
|
||||
const INSPECT_SCHEMA_VERSION = 1;
|
||||
// Motion verification (#1437): dense sampling grid for the seeked-timeline checks.
|
||||
@@ -68,6 +75,8 @@ function buildMotionSampleTimes(duration: number): number[] {
|
||||
}
|
||||
|
||||
async function getCompositionDuration(page: import("puppeteer-core").Page): Promise<number> {
|
||||
// Serialized into the page; the duration-source cascade cannot be split.
|
||||
// fallow-ignore-next-line complexity
|
||||
return page.evaluate(() => {
|
||||
const win = window as unknown as {
|
||||
__hf?: { duration?: number };
|
||||
@@ -96,52 +105,6 @@ async function getCompositionDuration(page: import("puppeteer-core").Page): Prom
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForFonts(page: import("puppeteer-core").Page, timeoutMs: number): Promise<void> {
|
||||
await page
|
||||
.evaluate((ms: number) => {
|
||||
const fonts = (document as Document & { fonts?: FontFaceSet }).fonts;
|
||||
if (!fonts?.ready) return Promise.resolve();
|
||||
return Promise.race([
|
||||
fonts.ready.then(() => undefined),
|
||||
new Promise<void>((resolve) => setTimeout(resolve, ms)),
|
||||
]);
|
||||
}, timeoutMs)
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
async function seekTo(page: import("puppeteer-core").Page, time: number): Promise<void> {
|
||||
await page.evaluate((t: number) => {
|
||||
const win = window as unknown as {
|
||||
__hf?: { seek?: (time: number) => void };
|
||||
__player?: { seek?: (time: number) => void };
|
||||
__timelines?: Record<string, { pause?: () => void; seek?: (time: number) => void }>;
|
||||
};
|
||||
if (typeof win.__hf?.seek === "function") {
|
||||
win.__hf.seek(t);
|
||||
return;
|
||||
}
|
||||
if (typeof win.__player?.seek === "function") {
|
||||
win.__player.seek(t);
|
||||
return;
|
||||
}
|
||||
const timelines = win.__timelines;
|
||||
if (timelines) {
|
||||
for (const timeline of Object.values(timelines)) {
|
||||
if (typeof timeline.pause === "function") timeline.pause();
|
||||
if (typeof timeline.seek === "function") timeline.seek(t);
|
||||
}
|
||||
}
|
||||
}, time);
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise<void>((resolveFrame) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolveFrame())),
|
||||
),
|
||||
);
|
||||
await waitForFonts(page, 500);
|
||||
await new Promise((resolveSettle) => setTimeout(resolveSettle, SEEK_SETTLE_MS));
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect every tween start/end boundary from the registered timelines,
|
||||
* expressed in the registered timeline's own time (what seekTo consumes).
|
||||
@@ -234,6 +197,7 @@ async function runLayoutAudit(
|
||||
): Promise<LayoutAuditResult> {
|
||||
const { ensureBrowser } = await import("../browser/manager.js");
|
||||
const puppeteer = await import("puppeteer-core");
|
||||
const { buildChromeArgs } = await import("@hyperframes/engine");
|
||||
const html = await bundleProjectHtml(projectDir);
|
||||
const server = await serveStaticProjectHtml(
|
||||
projectDir,
|
||||
@@ -247,17 +211,11 @@ async function runLayoutAudit(
|
||||
chromeBrowser = await puppeteer.default.launch({
|
||||
headless: true,
|
||||
executablePath: browser.executablePath,
|
||||
args: [
|
||||
"--no-sandbox",
|
||||
"--disable-gpu",
|
||||
"--disable-dev-shm-usage",
|
||||
"--enable-webgl",
|
||||
"--use-gl=angle",
|
||||
"--use-angle=swiftshader",
|
||||
],
|
||||
args: buildChromeArgs({ width: 1920, height: 1080, captureMode: "screenshot" }),
|
||||
});
|
||||
|
||||
const page = await chromeBrowser.newPage();
|
||||
await installPageFunctionGuard(page);
|
||||
await page.setViewport({ width: 1920, height: 1080 });
|
||||
await page.goto(server.url, { waitUntil: "domcontentloaded", timeout: 10000 });
|
||||
await alignViewportToComposition(page, server.url);
|
||||
@@ -266,7 +224,7 @@ async function runLayoutAudit(
|
||||
timeout: opts.timeout,
|
||||
})
|
||||
.catch(() => {});
|
||||
await waitForFonts(page, 750);
|
||||
await waitForCompositionFonts(page, 750);
|
||||
await new Promise((resolveSettle) => setTimeout(resolveSettle, 250));
|
||||
|
||||
const duration = await getCompositionDuration(page);
|
||||
@@ -308,7 +266,7 @@ async function runLayoutAudit(
|
||||
}
|
||||
}
|
||||
|
||||
function loadBrowserScript(name: string): string {
|
||||
export function loadBrowserScript(name: string): string {
|
||||
const candidates = [join(__dirname, name), join(__dirname, "commands", name)];
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) return readFileSync(candidate, "utf-8");
|
||||
@@ -330,7 +288,7 @@ async function collectLayoutIssues(
|
||||
|
||||
const issues: LayoutIssue[] = [];
|
||||
for (const time of samples) {
|
||||
await seekTo(page, time);
|
||||
await seekCompositionTimeline(page, time, LAYOUT_SEEK_OPTIONS);
|
||||
const sampleIssues = await page.evaluate(
|
||||
(auditOptions: { time: number; tolerance: number }) => {
|
||||
const win = window as unknown as {
|
||||
@@ -373,7 +331,7 @@ async function collectMotionFrames(
|
||||
): Promise<MotionFrame[]> {
|
||||
const frames: MotionFrame[] = [];
|
||||
for (const time of times) {
|
||||
await seekTo(page, time);
|
||||
await seekCompositionTimeline(page, time, LAYOUT_SEEK_OPTIONS);
|
||||
const sample = await page.evaluate(
|
||||
(options: { selectors: string[]; livenessScopes: string[] }) => {
|
||||
const win = window as unknown as {
|
||||
@@ -427,16 +385,19 @@ function resolveMotionSpec(specPath: string, json: boolean): MotionSpec {
|
||||
if (json) {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
withMeta({
|
||||
schemaVersion: INSPECT_SCHEMA_VERSION,
|
||||
ok: false,
|
||||
error: message,
|
||||
issues: [],
|
||||
errorCount: 0,
|
||||
warningCount: 0,
|
||||
infoCount: 0,
|
||||
issueCount: 0,
|
||||
}),
|
||||
withMeta(
|
||||
{
|
||||
schemaVersion: INSPECT_SCHEMA_VERSION,
|
||||
ok: false,
|
||||
error: message,
|
||||
issues: [],
|
||||
errorCount: 0,
|
||||
warningCount: 0,
|
||||
infoCount: 0,
|
||||
issueCount: 0,
|
||||
},
|
||||
{ deprecated: true },
|
||||
),
|
||||
null,
|
||||
2,
|
||||
),
|
||||
@@ -447,7 +408,7 @@ function resolveMotionSpec(specPath: string, json: boolean): MotionSpec {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function parseAt(value: unknown): number[] | undefined {
|
||||
export function parseAt(value: unknown): number[] | undefined {
|
||||
if (!value) return undefined;
|
||||
const times = String(value)
|
||||
.split(",")
|
||||
@@ -461,7 +422,7 @@ export function createInspectCommand(commandName: "inspect" | "layout") {
|
||||
meta: {
|
||||
name: commandName,
|
||||
description:
|
||||
"Inspect rendered composition layout for text/container overflow, plus optional motion verification via a *.motion.json sidecar",
|
||||
"Inspect rendered composition layout for text/container overflow, plus optional motion verification via a *.motion.json sidecar (deprecated, use check)",
|
||||
},
|
||||
args: {
|
||||
dir: { type: "positional", description: "Project directory", required: false },
|
||||
@@ -512,7 +473,10 @@ export function createInspectCommand(commandName: "inspect" | "layout") {
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
// Pre-existing command-run branching; U1 only swapped the seek internals.
|
||||
// fallow-ignore-next-line complexity
|
||||
async run({ args }) {
|
||||
printDeprecationNotice(commandName);
|
||||
const project = resolveProject(args.dir);
|
||||
const samples = Math.max(1, parseInt(args.samples as string, 10) || 9);
|
||||
const tolerance = Math.max(0, parseFloat(args.tolerance as string) || 2);
|
||||
@@ -562,7 +526,7 @@ export function createInspectCommand(commandName: "inspect" | "layout") {
|
||||
);
|
||||
}
|
||||
const allIssues = collapseStatic
|
||||
? collapseStaticLayoutIssues(result.rawIssues)
|
||||
? collapseStaticLayoutIssues(result.rawIssues, result.samples.length)
|
||||
: result.rawIssues;
|
||||
const limited = limitLayoutIssues(allIssues, maxIssues);
|
||||
const summary = summarizeLayoutIssues(allIssues);
|
||||
@@ -571,25 +535,28 @@ export function createInspectCommand(commandName: "inspect" | "layout") {
|
||||
if (args.json) {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
withMeta({
|
||||
schemaVersion: INSPECT_SCHEMA_VERSION,
|
||||
duration: result.duration,
|
||||
samples: result.samples,
|
||||
transitionSamples: atTransitions ? result.transitionSamples : undefined,
|
||||
transitionSamplesDropped: atTransitions
|
||||
? result.transitionSamplesDropped
|
||||
: undefined,
|
||||
tolerance,
|
||||
strict,
|
||||
collapseStatic,
|
||||
motionSpec: motionSpec ? motionSpecPath : undefined,
|
||||
motionSamples: motionSpec ? result.motionSamples : undefined,
|
||||
...summary,
|
||||
totalIssueCount: limited.totalIssueCount,
|
||||
truncated: limited.truncated,
|
||||
ok,
|
||||
issues: limited.issues,
|
||||
}),
|
||||
withMeta(
|
||||
{
|
||||
schemaVersion: INSPECT_SCHEMA_VERSION,
|
||||
duration: result.duration,
|
||||
samples: result.samples,
|
||||
transitionSamples: atTransitions ? result.transitionSamples : undefined,
|
||||
transitionSamplesDropped: atTransitions
|
||||
? result.transitionSamplesDropped
|
||||
: undefined,
|
||||
tolerance,
|
||||
strict,
|
||||
collapseStatic,
|
||||
motionSpec: motionSpec ? motionSpecPath : undefined,
|
||||
motionSamples: motionSpec ? result.motionSamples : undefined,
|
||||
...summary,
|
||||
totalIssueCount: limited.totalIssueCount,
|
||||
truncated: limited.truncated,
|
||||
ok,
|
||||
issues: limited.issues,
|
||||
},
|
||||
{ deprecated: true },
|
||||
),
|
||||
null,
|
||||
2,
|
||||
),
|
||||
@@ -639,16 +606,19 @@ export function createInspectCommand(commandName: "inspect" | "layout") {
|
||||
if (args.json) {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
withMeta({
|
||||
schemaVersion: INSPECT_SCHEMA_VERSION,
|
||||
ok: false,
|
||||
error: message,
|
||||
issues: [],
|
||||
errorCount: 0,
|
||||
warningCount: 0,
|
||||
infoCount: 0,
|
||||
issueCount: 0,
|
||||
}),
|
||||
withMeta(
|
||||
{
|
||||
schemaVersion: INSPECT_SCHEMA_VERSION,
|
||||
ok: false,
|
||||
error: message,
|
||||
issues: [],
|
||||
errorCount: 0,
|
||||
warningCount: 0,
|
||||
infoCount: 0,
|
||||
issueCount: 0,
|
||||
},
|
||||
{ deprecated: true },
|
||||
),
|
||||
null,
|
||||
2,
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,9 +4,14 @@ import { existsSync, mkdtempSync, readFileSync, mkdirSync, rmSync, writeFileSync
|
||||
import { tmpdir } from "node:os";
|
||||
import { resolve, join, relative, isAbsolute, basename } from "node:path";
|
||||
import {
|
||||
DEFAULT_ZOOM_SCALE,
|
||||
captureRegionCrop,
|
||||
openSettledCompositionPage,
|
||||
parseZoomTarget,
|
||||
resolveCropRegion,
|
||||
runFfmpegOnce,
|
||||
seekCompositionTimeline,
|
||||
type ZoomTarget,
|
||||
} from "../capture/captureCompositionFrame.js";
|
||||
import { resolveProject } from "../utils/project.js";
|
||||
import { normalizeErrorMessage } from "../utils/errorMessage.js";
|
||||
@@ -104,8 +109,20 @@ export const examples: Example[] = [
|
||||
["Capture 5 key frames from a composition", "snapshot capture"],
|
||||
["Capture 10 evenly-spaced frames", "snapshot capture --frames 10"],
|
||||
["View the 3D stage from an isometric angle", "snapshot capture --angle iso"],
|
||||
["Zoom into an element for a high-density crop", "snapshot --zoom '#headline'"],
|
||||
[
|
||||
"Zoom into an exact pixel region at 2x density",
|
||||
"snapshot --zoom 100,50,400,300 --zoom-scale 2",
|
||||
],
|
||||
];
|
||||
|
||||
/** `--zoom-scale`: the deviceScaleFactor used for zoomed crops. Defaults to 3;
|
||||
* falls back to the default for anything that doesn't parse as a positive number. */
|
||||
export function parseZoomScale(value: unknown): number {
|
||||
const parsed = parseFloat(String(value ?? ""));
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_ZOOM_SCALE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seeking the timeline to EXACTLY `data-duration` renders blank — the runtime
|
||||
* treats t >= clip-end as past-end and unmounts the clip (verified on a V4 3D
|
||||
@@ -172,6 +189,8 @@ async function captureSnapshots(
|
||||
outputDir?: string;
|
||||
angle?: Camera;
|
||||
includeEnd?: boolean;
|
||||
zoom?: ZoomTarget;
|
||||
zoomScale?: number;
|
||||
},
|
||||
): Promise<string[]> {
|
||||
const { bundleToSingleHtml } = await import("@hyperframes/core/compiler");
|
||||
@@ -425,7 +444,29 @@ async function captureSnapshots(
|
||||
const filename = `frame-${String(i).padStart(2, "0")}-at-${timeLabel}.png`;
|
||||
const framePath = join(snapshotDir, filename);
|
||||
|
||||
await page.screenshot({ path: framePath, type: "png" });
|
||||
if (opts.zoom) {
|
||||
// Clip screenshot at a raised deviceScaleFactor — never CSS zoom or
|
||||
// viewport resizing — so the composition's own layout is untouched.
|
||||
const canvas = await page.evaluate(() => ({
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
}));
|
||||
const region = await resolveCropRegion(page, opts.zoom, canvas);
|
||||
if (!region) {
|
||||
console.error(
|
||||
` ${c.warn("⚠")} --zoom target has no visible box at ${time.toFixed(1)}s — frame skipped`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const buffer = await captureRegionCrop(
|
||||
page,
|
||||
region,
|
||||
opts.zoomScale ?? DEFAULT_ZOOM_SCALE,
|
||||
);
|
||||
writeFileSync(framePath, buffer);
|
||||
} else {
|
||||
await page.screenshot({ path: framePath, type: "png" });
|
||||
}
|
||||
const rel = relative(projectDir, framePath);
|
||||
savedPaths.push(rel.startsWith("..") || isAbsolute(rel) ? framePath : rel);
|
||||
}
|
||||
@@ -480,6 +521,16 @@ export default defineCommand({
|
||||
"Always include a readable end-of-timeline frame (default: true). Pass --no-end to capture only your exact --at times.",
|
||||
default: true,
|
||||
},
|
||||
zoom: {
|
||||
type: "string",
|
||||
description:
|
||||
"Zoom into a CSS selector or an exact pixel region 'x,y,w,h'. Crops a high-density screenshot instead of the full frame — a raised deviceScaleFactor, never CSS zoom or viewport resizing, so layout stays identical. A selector matching nothing is an error, not a silent full-frame shot.",
|
||||
},
|
||||
"zoom-scale": {
|
||||
type: "string",
|
||||
description: "Device-scale-factor density for --zoom crops (default: 3)",
|
||||
default: "3",
|
||||
},
|
||||
describe: {
|
||||
type: "string",
|
||||
description:
|
||||
@@ -508,6 +559,8 @@ export default defineCommand({
|
||||
: String(args.describe);
|
||||
|
||||
const camera = args.angle ? parseAngle(String(args.angle)) : undefined;
|
||||
const zoomTarget = args.zoom ? parseZoomTarget(String(args.zoom)) : undefined;
|
||||
const zoomScale = parseZoomScale(args["zoom-scale"]);
|
||||
|
||||
const label = atTimestamps
|
||||
? `${atTimestamps.length} frames at [${atTimestamps.map((t) => t.toFixed(1) + "s").join(", ")}]`
|
||||
@@ -529,6 +582,8 @@ export default defineCommand({
|
||||
outputDir: snapshotDir,
|
||||
angle: camera,
|
||||
includeEnd: args.end !== false,
|
||||
zoom: zoomTarget,
|
||||
zoomScale,
|
||||
});
|
||||
|
||||
if (paths.length === 0) {
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
// Imported before "./validate.js" below: validate.js's own static import of
|
||||
// ../utils/project.js triggers that mocked module's factory as soon as
|
||||
// validate.js loads, so resolveProjectMock/lintProjectFailureMock must
|
||||
// already be bound by then (see the vi.mock calls a few lines down).
|
||||
import {
|
||||
lintProjectFailureMock,
|
||||
metaDescription,
|
||||
resolveProjectMock,
|
||||
runAndCaptureStdio,
|
||||
runAndParseJsonEnvelope,
|
||||
} from "./deprecationTestHarness.js";
|
||||
import {
|
||||
extractCompositionErrorsFromLint,
|
||||
navigationTimeoutHint,
|
||||
raceMediaReady,
|
||||
resolveNavigationTimeoutMs,
|
||||
shouldIgnoreRequestFailure,
|
||||
waitForPreferredSeekTarget,
|
||||
} from "./validate.js";
|
||||
import { waitForPreferredSeekTarget } from "../capture/captureCompositionFrame.js";
|
||||
import type { ProjectLintResult } from "../utils/lintProject.js";
|
||||
|
||||
// validateInBrowser lazy-loads the producer localize helpers via loadProducer;
|
||||
@@ -28,6 +39,16 @@ vi.mock("../utils/producer.js", () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
// U5 deprecation tests: resolveProject and lintProject are both reached via a
|
||||
// dynamic `await import(...)` inside validate.ts's run() / validateInBrowser(),
|
||||
// so vi.mock intercepts them the same way it would a static import. Mocking
|
||||
// resolveProject skips real filesystem project resolution; mocking lintProject
|
||||
// (the first await inside validateInBrowser) gives a fast, deterministic
|
||||
// failure well before any real browser or network work — exercising run()'s
|
||||
// outer catch (the JSON failure envelope) without needing headless Chrome.
|
||||
vi.mock("../utils/project.js", () => resolveProjectMock());
|
||||
vi.mock("../utils/lintProject.js", () => lintProjectFailureMock());
|
||||
|
||||
// Regression for the validate audio-duration-probe timeout: a slow-loading
|
||||
// media element's duration was snapshotted once, at a fixed point in time,
|
||||
// and any element still mid-load was permanently misreported as unreadable.
|
||||
@@ -131,6 +152,16 @@ describe("waitForPreferredSeekTarget", () => {
|
||||
|
||||
await expect(waitForPreferredSeekTarget(page, 1)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not fail validation when the page stub throws synchronously", async () => {
|
||||
const page = {
|
||||
waitForFunction: vi.fn(() => {
|
||||
throw new Error("waiting failed synchronously");
|
||||
}),
|
||||
};
|
||||
|
||||
await expect(waitForPreferredSeekTarget(page, 1)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractCompositionErrorsFromLint", () => {
|
||||
@@ -272,3 +303,29 @@ describe("navigationTimeoutHint", () => {
|
||||
expect(navigationTimeoutHint("some string failure", 10000)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("validate command deprecation (U5)", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("marks the command description as deprecated", async () => {
|
||||
const { default: validateCommand } = await import("./validate.js");
|
||||
expect(metaDescription(validateCommand)).toContain("(deprecated, use check)");
|
||||
});
|
||||
|
||||
it("prints a one-line deprecation notice to stderr and never to stdout", async () => {
|
||||
const { default: validateCommand } = await import("./validate.js");
|
||||
const { stderrText, stdoutText } = await runAndCaptureStdio(validateCommand);
|
||||
expect(stderrText).toContain("hyperframes validate");
|
||||
expect(stderrText).toContain("hyperframes check");
|
||||
expect(stdoutText).toBe("");
|
||||
});
|
||||
|
||||
it("--json output is valid JSON with _meta.deprecated === true on failure", async () => {
|
||||
const { default: validateCommand } = await import("./validate.js");
|
||||
const { parsed } = await runAndParseJsonEnvelope(validateCommand);
|
||||
expect(parsed.ok).toBe(false);
|
||||
expect(parsed._meta.deprecated).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// The media-metadata wait exists twice on purpose: once Node-side and once
|
||||
// inside a page.evaluate() body, which is serialized into the browser and
|
||||
// cannot import the Node helper. Line-level markers don't survive the clone
|
||||
// window drifting as the file is edited, hence the file-level suppression.
|
||||
// fallow-ignore-file code-duplication
|
||||
import { defineCommand } from "citty";
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -8,7 +13,12 @@ import { normalizeErrorMessage } from "../utils/errorMessage.js";
|
||||
import type { ProjectLintResult } from "../utils/lintProject.js";
|
||||
import { resolveCompositionViewportFromHtml } from "../utils/compositionViewport.js";
|
||||
import { c } from "../ui/colors.js";
|
||||
import { withMeta } from "../utils/updateCheck.js";
|
||||
import { printDeprecationNotice, withMeta } from "../utils/updateCheck.js";
|
||||
import {
|
||||
installPageFunctionGuard,
|
||||
resolveCliChromeGpuMode,
|
||||
seekCompositionTimeline,
|
||||
} from "../capture/captureCompositionFrame.js";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
@@ -85,67 +95,6 @@ async function getCompositionDuration(page: import("puppeteer-core").Page): Prom
|
||||
});
|
||||
}
|
||||
|
||||
async function seekTo(page: import("puppeteer-core").Page, time: number): Promise<void> {
|
||||
await waitForPreferredSeekTarget(page);
|
||||
await page.evaluate((t: number) => {
|
||||
// window.__player.renderSeek is exposed directly by the composition
|
||||
// runtime (packages/core/src/runtime/init.ts) on every page load, and
|
||||
// — unlike raw timeline.seek() — it also runs the runtime's own
|
||||
// [data-start]/[data-duration] visibility sync, hiding clips outside
|
||||
// their timeline window. window.__hf.seek only exists when the
|
||||
// producer's render-pipeline bridge script has been injected, which
|
||||
// validate's static preview server never does, so it was always
|
||||
// falling through to the raw __timelines seek below and skipping that
|
||||
// sync — leaving off-window elements looking fully visible to any
|
||||
// check (e.g. the contrast audit) that reads computed style afterward.
|
||||
const player = (window as unknown as { __player?: { renderSeek?: (t: number) => void } })
|
||||
.__player;
|
||||
if (player && typeof player.renderSeek === "function") {
|
||||
player.renderSeek(t);
|
||||
return;
|
||||
}
|
||||
if (window.__hf && typeof window.__hf.seek === "function") {
|
||||
window.__hf.seek(t);
|
||||
return;
|
||||
}
|
||||
const timelines = (window as unknown as Record<string, unknown>).__timelines as
|
||||
| Record<string, { seek: (t: number) => void }>
|
||||
| undefined;
|
||||
if (timelines) {
|
||||
for (const tl of Object.values(timelines)) {
|
||||
if (typeof tl.seek === "function") tl.seek(t);
|
||||
}
|
||||
}
|
||||
}, time);
|
||||
await new Promise((r) => setTimeout(r, SEEK_SETTLE_MS));
|
||||
}
|
||||
|
||||
interface WaitForFunctionPage {
|
||||
waitForFunction: (pageFunction: () => boolean, options: { timeout: number }) => Promise<unknown>;
|
||||
}
|
||||
|
||||
export async function waitForPreferredSeekTarget(
|
||||
page: WaitForFunctionPage,
|
||||
timeoutMs = PREFERRED_SEEK_TARGET_WAIT_MS,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const w = window as unknown as {
|
||||
__hf?: { seek?: unknown };
|
||||
__player?: { renderSeek?: unknown };
|
||||
};
|
||||
return typeof w.__player?.renderSeek === "function" || typeof w.__hf?.seek === "function";
|
||||
},
|
||||
{ timeout: timeoutMs },
|
||||
);
|
||||
} catch {
|
||||
// Older/static pages may only expose raw window.__timelines. Keep the
|
||||
// legacy fallback path rather than turning a missing player API into a
|
||||
// validate failure.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Race a media element's `loadedmetadata`/`error` event against a deadline,
|
||||
* whichever comes first. Already-ready elements resolve immediately.
|
||||
@@ -183,7 +132,7 @@ export function raceMediaReady(
|
||||
* the live page to read each element's intrinsic `.duration`, which static lint
|
||||
* can't see.
|
||||
*/
|
||||
async function auditClipDurations(
|
||||
export async function auditClipDurations(
|
||||
page: import("puppeteer-core").Page,
|
||||
analyzeClipMediaFit: typeof import("@hyperframes/engine").analyzeClipMediaFit,
|
||||
extraWaitMs: number,
|
||||
@@ -209,7 +158,6 @@ async function auditClipDurations(
|
||||
nodes.map((el) => {
|
||||
if (Number.isFinite(el.duration) && el.duration > 0) return Promise.resolve();
|
||||
return new Promise<void>((resolve) => {
|
||||
// fallow-ignore-next-line code-duplication
|
||||
const cleanup = () => {
|
||||
el.removeEventListener("loadedmetadata", onReady);
|
||||
el.removeEventListener("error", onReady);
|
||||
@@ -300,7 +248,12 @@ async function runContrastAudit(page: import("puppeteer-core").Page): Promise<Co
|
||||
const results: ContrastEntry[] = [];
|
||||
for (let i = 0; i < CONTRAST_SAMPLES; i++) {
|
||||
const t = +(((i + 0.5) / CONTRAST_SAMPLES) * duration).toFixed(3);
|
||||
await seekTo(page, t);
|
||||
await seekCompositionTimeline(page, t, {
|
||||
fallbackToBridgeAndTimelines: true,
|
||||
waitForPreferredSeekTargetMs: PREFERRED_SEEK_TARGET_WAIT_MS,
|
||||
animationFrameSettle: "none",
|
||||
settleMs: SEEK_SETTLE_MS,
|
||||
});
|
||||
|
||||
try {
|
||||
// __contrastAuditPrepare() hides each candidate text element's own
|
||||
@@ -459,15 +412,17 @@ async function validateInBrowser(
|
||||
const browser = await ensureBrowser();
|
||||
const puppeteer = await import("puppeteer-core");
|
||||
const { buildChromeArgs, analyzeClipMediaFit } = await import("@hyperframes/engine");
|
||||
const browserGpuMode =
|
||||
process.env.PRODUCER_BROWSER_GPU_MODE === "software" ? "software" : "hardware";
|
||||
const chromeBrowser = await puppeteer.default.launch({
|
||||
headless: true,
|
||||
executablePath: browser.executablePath,
|
||||
args: buildChromeArgs({ ...viewport, captureMode: "screenshot" }, { browserGpuMode }),
|
||||
args: buildChromeArgs(
|
||||
{ ...viewport, captureMode: "screenshot" },
|
||||
{ browserGpuMode: resolveCliChromeGpuMode() },
|
||||
),
|
||||
});
|
||||
|
||||
const page = await chromeBrowser.newPage();
|
||||
await installPageFunctionGuard(page);
|
||||
await page.setViewport(viewport);
|
||||
|
||||
page.on("console", (msg) => {
|
||||
@@ -559,13 +514,16 @@ function emitJsonReport(
|
||||
): void {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
withMeta({
|
||||
ok: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
contrast,
|
||||
contrastFailures: contrastFailures.length,
|
||||
}),
|
||||
withMeta(
|
||||
{
|
||||
ok: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
contrast,
|
||||
contrastFailures: contrastFailures.length,
|
||||
},
|
||||
{ deprecated: true },
|
||||
),
|
||||
null,
|
||||
2,
|
||||
),
|
||||
@@ -612,7 +570,11 @@ function emitTextReport(
|
||||
function emitFailureReport(message: string, asJson: boolean): void {
|
||||
if (asJson) {
|
||||
console.log(
|
||||
JSON.stringify(withMeta({ ok: false, error: message, errors: [], warnings: [] }), null, 2),
|
||||
JSON.stringify(
|
||||
withMeta({ ok: false, error: message, errors: [], warnings: [] }, { deprecated: true }),
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -622,7 +584,7 @@ function emitFailureReport(message: string, asJson: boolean): void {
|
||||
export default defineCommand({
|
||||
meta: {
|
||||
name: "validate",
|
||||
description: `Load a composition in headless Chrome and report console errors
|
||||
description: `Load a composition in headless Chrome and report console errors (deprecated, use check)
|
||||
|
||||
Examples:
|
||||
hyperframes validate
|
||||
@@ -647,6 +609,7 @@ Examples:
|
||||
},
|
||||
},
|
||||
async run({ args }) {
|
||||
printDeprecationNotice("validate");
|
||||
const project = resolveProject(args.dir);
|
||||
const timeout = parseInt(args.timeout as string, 10) || 3000;
|
||||
const useContrast = args.contrast ?? true;
|
||||
|
||||
Reference in New Issue
Block a user