From 7ab6c2b7a2611a25a613fa41ee4d3b2efbf3be53 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 10 Jul 2026 01:05:01 -0400 Subject: [PATCH] feat(cli): caption-zone and frame-check gates on check Ports the EF bridge's captionZone and frameCheck semantics as opt-in flags so the bespoke bridge can be retired: --caption-zone takes fractional band geometry (x0;y0;x1;y1) with optional severity routing and seek points, defaults matching the bridge (caption seek [1], frame seek [0.5], 2px tolerance, 0.05 opacity floor, 4px minimum size, 0.95 full-frame exclusion, center-in-band comparison, tag|text dedup). --frame-check adds media bounds detection (img/svg/video/canvas) the always-on text canvas_overflow never covered, reusing overflowFor. Breach floor: max(120px, 6% of min canvas dimension). Band math derives from the composition's own canvas, portrait included. Both gates off by default; plain check output unchanged. --- packages/cli/src/commands/check.test.ts | 458 +++++++++++++++++- packages/cli/src/commands/check.ts | 112 ++++- .../cli/src/commands/layout-audit.browser.js | 157 +++++- .../src/commands/layout-audit.browser.test.ts | 252 +++++++++- packages/cli/src/utils/checkBrowser.test.ts | 110 +++++ packages/cli/src/utils/checkBrowser.ts | 71 +++ packages/cli/src/utils/checkPipeline.ts | 195 +++++++- packages/cli/src/utils/checkTypes.ts | 38 +- packages/cli/src/utils/layoutAudit.ts | 10 + 9 files changed, 1376 insertions(+), 27 deletions(-) create mode 100644 packages/cli/src/utils/checkBrowser.test.ts diff --git a/packages/cli/src/commands/check.test.ts b/packages/cli/src/commands/check.test.ts index 18dc433a1..8e1fb3cbe 100644 --- a/packages/cli/src/commands/check.test.ts +++ b/packages/cli/src/commands/check.test.ts @@ -20,8 +20,9 @@ import { type ContrastAuditEntry, type MotionSpecResolution, } from "../utils/checkPipeline.js"; +import { resolveCompositionViewportFromHtml } from "../utils/compositionViewport.js"; import type { ProjectLintResult } from "../utils/lintProject.js"; -import type { LayoutIssue } from "../utils/layoutAudit.js"; +import type { LayoutIssue, LayoutOverflow, LayoutRect } from "../utils/layoutAudit.js"; import type { ProjectDir } from "../utils/project.js"; const PROJECT: ProjectDir = { @@ -30,6 +31,12 @@ const PROJECT: ProjectDir = { indexPath: "/project/index.html", }; const PNG_BASE64 = Buffer.from("png-bytes").toString("base64"); +const ORIGINAL_EXIT_CODE = process.exitCode; + +afterEach(() => { + process.exitCode = ORIGINAL_EXIT_CODE; + vi.restoreAllMocks(); +}); function cleanLint(): ProjectLintResult { return { @@ -118,6 +125,7 @@ function fakeDriver(overrides: Partial = {}): CheckAuditDriver findAmbiguousSelectors: vi.fn(async (_selectors: string[]) => []), seek: vi.fn(async (_time: number) => undefined), collectLayout: vi.fn(async (_time: number, _tolerance: number) => []), + collectGeometryCandidates: vi.fn(async () => []), collectMotionFrame: vi.fn(async (time: number) => ({ time, data: {}, liveness: {} })), anchorMotionIssues: vi.fn(async (issues: LayoutIssue[]) => issues.map((issue) => ({ @@ -130,6 +138,76 @@ function fakeDriver(overrides: Partial = {}): CheckAuditDriver }; } +interface GeometryFixture { + kind: "text" | "media"; + tag: string; + text: string; + selector: string; + rect: LayoutRect; + elementRect?: LayoutRect; + time: number; + overflow?: LayoutOverflow; +} + +function geometryCandidate(fixture: GeometryFixture) { + return { + ...anchor(fixture.selector, fixture.time), + kind: fixture.kind, + tag: fixture.tag, + text: fixture.text, + rect: fixture.rect, + elementRect: fixture.elementRect ?? fixture.rect, + bbox: { + x: fixture.rect.left, + y: fixture.rect.top, + width: fixture.rect.width, + height: fixture.rect.height, + }, + overflow: fixture.overflow, + }; +} + +function fixtureRect(left: number, top: number, width: number, height: number): LayoutRect { + return { left, top, right: left + width, bottom: top + height, width, height }; +} + +function checkBrowserSource(): string { + return readFileSync(new URL("../utils/checkBrowser.ts", import.meta.url), "utf8"); +} + +async function gateCandidates( + time: number, + request: { text: boolean; media: boolean; tolerance: number }, +) { + const candidates = []; + if (request.text) { + candidates.push( + geometryCandidate({ + kind: "text", + tag: "h2", + text: "Repeated heading", + selector: time === 2 ? "#first-heading" : "#later-heading", + rect: fixtureRect(800, 880, 320, 60), + time, + }), + ); + } + if (request.media) { + candidates.push( + geometryCandidate({ + kind: "media", + tag: "video", + text: "video", + selector: "#midpoint-video", + rect: fixtureRect(-140, 100, 100, 100), + overflow: { left: 140 }, + time, + }), + ); + } + return candidates; +} + function noMotion(): MotionSpecResolution { return { kind: "none" }; } @@ -200,6 +278,361 @@ describe("contrast sample selection", () => { }); }); +it("parses the caption-zone grammar and enables the frame gate", async () => { + const { report } = await runScenario(fakeDriver()); + const runPipeline = vi.fn(async (_project: ProjectDir, _options: CheckOptions) => report); + vi.spyOn(console, "log").mockImplementation(() => undefined); + const command = createCheckCommand({ + resolveProject: () => PROJECT, + runPipeline, + withMeta: (value) => value, + }); + + await runCommand(command, { + rawArgs: [ + "--json", + "--caption-zone", + "x0=0;y0=.82;x1=1;y1=1;severity=error;seek=.25,1", + "--frame-check", + ], + }); + + expect(runPipeline).toHaveBeenCalledWith( + PROJECT, + expect.objectContaining({ + captionZone: { + x0: 0, + y0: 0.82, + x1: 1, + y1: 1, + severity: "error", + seek: [0.25, 1], + }, + frameCheck: {}, + }), + ); +}); + +it("rejects malformed caption-zone specs instead of silently disabling the gate", async () => { + const { report } = await runScenario(fakeDriver()); + const runPipeline = vi.fn(async () => report); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + const command = createCheckCommand({ + resolveProject: () => PROJECT, + runPipeline, + withMeta: (value) => ({ ...value, _meta: { version: "test" } }), + }); + + await runCommand(command, { + rawArgs: ["--json", "--caption-zone", "x0=0;y0=.8;x1=1;y1=1.2"], + }); + + expect(runPipeline).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + expect(log).toHaveBeenCalledTimes(1); + expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual({ + ok: false, + error: expect.stringContaining("Invalid --caption-zone"), + _meta: { version: "test" }, + }); +}); + +it("flags only text whose center is inside the caption band at the default end seek", async () => { + const collectGeometryCandidates = vi.fn(async (time: number) => [ + geometryCandidate({ + kind: "text", + tag: "div", + text: "Centered title", + selector: "#centered", + rect: fixtureRect(860, 870, 200, 60), + time, + }), + geometryCandidate({ + kind: "text", + tag: "div", + text: "Overlap only", + selector: "#overlap-only", + rect: fixtureRect(860, 830, 200, 60), + time, + }), + ]); + const { report } = await runScenario( + fakeDriver({ + getDuration: vi.fn(async () => 10), + collectGeometryCandidates, + }), + { + samples: 1, + contrast: false, + captionZone: { x0: 0, y0: 0.8, x1: 1, y1: 0.9 }, + }, + ); + + expect(collectGeometryCandidates).toHaveBeenCalledTimes(1); + expect(collectGeometryCandidates).toHaveBeenCalledWith(10, { + text: true, + media: false, + tolerance: 2, + }); + expect(report.layout.samples).toEqual([5, 10]); + expect(report.layout.findings).toEqual([ + expect.objectContaining({ + code: "caption_zone_collision", + severity: "warning", + selector: "#centered", + text: "Centered title", + time: 10, + }), + ]); + expect(report.ok).toBe(true); +}); + +it("filters caption candidates by the element box while centering the text rect", async () => { + const collectGeometryCandidates = vi.fn(async (time: number) => [ + geometryCandidate({ + kind: "text", + tag: "div", + text: "Full-frame wrapper copy", + selector: "#full-frame-wrapper", + rect: fixtureRect(860, 870, 200, 60), + elementRect: fixtureRect(0, 0, 1920, 1080), + time, + }), + geometryCandidate({ + kind: "text", + tag: "span", + text: "Tiny wrapper copy", + selector: "#tiny-wrapper", + rect: fixtureRect(860, 870, 200, 60), + elementRect: fixtureRect(860, 870, 3, 3), + time, + }), + ]); + const { report } = await runScenario(fakeDriver({ collectGeometryCandidates }), { + contrast: false, + captionZone: { x0: 0, y0: 0.8, x1: 1, y1: 0.9 }, + }); + + expect(report.layout.findings).toEqual([]); +}); + +it("checks media overflow at the default midpoint and applies warning severity", async () => { + const collectGeometryCandidates = vi.fn(async (time: number) => [ + geometryCandidate({ + kind: "media", + tag: "img", + text: "img", + selector: "#hero-image", + rect: fixtureRect(1840, 100, 220, 200), + overflow: { right: 140 }, + time, + }), + ]); + const { report } = await runScenario( + fakeDriver({ getDuration: vi.fn(async () => 10), collectGeometryCandidates }), + { samples: 1, contrast: false, frameCheck: {} }, + ); + + expect(collectGeometryCandidates).toHaveBeenCalledWith(5, { + text: false, + media: true, + tolerance: 2, + }); + expect(report.layout.findings[0]).toMatchObject({ + code: "frame_out_of_frame", + severity: "warning", + selector: "#hero-image", + overflow: { right: 140 }, + time: 5, + }); +}); + +it("converts progress seeks to time, gates each collector, and keeps the first caption hit", async () => { + const collectGeometryCandidates = vi.fn(gateCandidates); + const { report } = await runScenario( + fakeDriver({ + getDuration: vi.fn(async () => 8), + collectGeometryCandidates, + }), + { + samples: 1, + contrast: false, + captionZone: { + x0: 0, + y0: 0.8, + x1: 1, + y1: 0.9, + severity: "error", + seek: [0.25, 0.75], + }, + frameCheck: { severity: "error" }, + }, + ); + + expect(report.layout.samples).toEqual([2, 4, 6]); + expect(collectGeometryCandidates.mock.calls).toEqual([ + [2, { text: true, media: false, tolerance: 2 }], + [4, { text: false, media: true, tolerance: 2 }], + [6, { text: true, media: false, tolerance: 2 }], + ]); + expect(report.layout.findings).toEqual([ + expect.objectContaining({ + code: "caption_zone_collision", + severity: "error", + selector: "#first-heading", + time: 2, + }), + expect.objectContaining({ code: "frame_out_of_frame", severity: "error", time: 4 }), + ]); + expect(report.ok).toBe(false); +}); + +it("does not collect or emit opt-in geometry findings when both flags are off", async () => { + const collectGeometryCandidates = vi.fn(async () => [ + geometryCandidate({ + kind: "media", + tag: "video", + text: "video", + selector: "#video", + rect: fixtureRect(1900, 0, 200, 200), + overflow: { right: 180 }, + time: 4.5, + }), + ]); + const { report } = await runScenario(fakeDriver({ collectGeometryCandidates }), { + contrast: false, + }); + + expect(collectGeometryCandidates).not.toHaveBeenCalled(); + expect(JSON.stringify(report)).not.toContain("caption_zone_collision"); + expect(JSON.stringify(report)).not.toContain("frame_out_of_frame"); +}); + +it("computes caption bands from a portrait composition viewport", async () => { + const viewport = resolveCompositionViewportFromHtml( + '
', + ); + const collectGeometryCandidates = vi.fn(async (time: number) => [ + geometryCandidate({ + kind: "text", + tag: "p", + text: "Portrait caption collision", + selector: "#portrait-copy", + rect: fixtureRect(480, 1570, 120, 60), + time, + }), + ]); + const getCanvas = vi.fn(async () => viewport); + const { report } = await runScenario( + fakeDriver({ + getDuration: vi.fn(async () => 4), + getCanvas, + collectGeometryCandidates, + }), + { + samples: 1, + contrast: false, + captionZone: { x0: 0.4, y0: 0.8, x1: 0.6, y1: 0.9 }, + }, + ); + + expect(viewport).toEqual({ width: 1080, height: 1920 }); + expect(getCanvas).toHaveBeenCalled(); + expect(report.layout.findings).toEqual([ + expect.objectContaining({ code: "caption_zone_collision", selector: "#portrait-copy" }), + ]); +}); + +it("suppresses frame breaches below the per-canvas floor and reports those above it", async () => { + const collectGeometryCandidates = vi.fn(async (time: number) => [ + geometryCandidate({ + kind: "media", + tag: "canvas", + text: "canvas", + selector: "#under-floor", + rect: fixtureRect(3980, 100, 199, 100), + overflow: { right: 179 }, + time, + }), + geometryCandidate({ + kind: "media", + tag: "canvas", + text: "canvas", + selector: "#over-floor", + rect: fixtureRect(3980, 300, 201, 100), + overflow: { right: 181 }, + time, + }), + ]); + const { report } = await runScenario( + fakeDriver({ + getCanvas: vi.fn(async () => ({ width: 4000, height: 3000 })), + collectGeometryCandidates, + }), + { + contrast: false, + frameCheck: {}, + }, + ); + + expect(report.layout.findings).toEqual([ + expect.objectContaining({ + code: "frame_out_of_frame", + selector: "#over-floor", + overflow: { right: 181 }, + }), + ]); +}); + +it("keeps frame findings at distinct rounded positions across requested seeks", async () => { + const collectGeometryCandidates = vi.fn(async (time: number) => [ + geometryCandidate({ + kind: "media", + tag: "img", + text: "img", + selector: "#moving-image", + rect: fixtureRect(1920, time === 2 ? 100 : 300, 130, 100), + overflow: { right: 130 }, + time, + }), + ]); + const { report } = await runScenario( + fakeDriver({ + getDuration: vi.fn(async () => 8), + collectGeometryCandidates, + }), + { + samples: 1, + contrast: false, + frameCheck: { seek: [0.25, 0.75] }, + }, + ); + + expect(report.layout.findings).toEqual([ + expect.objectContaining({ code: "frame_out_of_frame", time: 2 }), + expect.objectContaining({ code: "frame_out_of_frame", time: 6 }), + ]); +}); + +it("keeps contrast and snapshot sampling on the pre-gate layout grid", async () => { + const collectContrast = vi.fn(async () => ({ entries: [], pngBase64: PNG_BASE64 })); + const { report } = await runScenario( + fakeDriver({ + getDuration: vi.fn(async () => 10), + collectContrast, + }), + { + samples: 1, + captionZone: { x0: 0, y0: 0.8, x1: 1, y1: 1 }, + }, + ); + + expect(report.layout.samples).toEqual([5, 10]); + expect(report.contrast.samples).toEqual([5]); + expect(collectContrast).toHaveBeenCalledTimes(1); + expect(collectContrast).toHaveBeenCalledWith(5); +}); + describe("check pipeline", () => { const originalExitCode = process.exitCode; @@ -403,6 +836,16 @@ describe("check pipeline", () => { await expect(runAuditGrid(driver, DEFAULT_CHECK_OPTIONS, noMotion())).rejects.toThrow( "Could not determine composition duration — no layout samples run", ); + await expect( + runAuditGrid( + driver, + { + ...DEFAULT_CHECK_OPTIONS, + captionZone: { x0: 0, y0: 0.8, x1: 1, y1: 1 }, + }, + noMotion(), + ), + ).rejects.toThrow("Could not determine composition duration — no layout samples run"); const { report, browser } = await runScenario(driver); expect(browser).toHaveBeenCalledTimes(1); @@ -415,7 +858,7 @@ describe("check pipeline", () => { describe("contrast candidate round-trip", () => { it("passes the browser script's raw candidates back to finish, never the normalized copies", () => { - const source = readFileSync(new URL("../utils/checkBrowser.ts", import.meta.url), "utf8"); + const source = checkBrowserSource(); // __contrastAuditFinish samples pixels via the page script's own bbox // shape ({x, y, w, h}); sending the Node-normalized candidate @@ -426,3 +869,14 @@ describe("contrast candidate round-trip", () => { expect(source).not.toMatch(/prepared\.map\(\(entry\) => entry\.candidate\)/); }); }); + +describe("geometry candidate plumbing", () => { + it("wires the opt-in browser primitive without round-tripping normalized candidates", () => { + const source = checkBrowserSource(); + + expect(source).toMatch(/collectGeometryCandidates: \(time, request\) =>/); + expect(source).toMatch(/__hyperframesGeometryCandidates/); + expect(source).toMatch(/raw\.flatMap\(\(value\) => parseGeometryCandidate\(value, time\)\)/); + expect(source).not.toMatch(/resolveAnchors\(page, raw/); + }); +}); diff --git a/packages/cli/src/commands/check.ts b/packages/cli/src/commands/check.ts index 78e183377..36c48aa64 100644 --- a/packages/cli/src/commands/check.ts +++ b/packages/cli/src/commands/check.ts @@ -15,6 +15,7 @@ import { type CheckReport, type CheckSection, } from "../utils/checkPipeline.js"; +import type { CaptionZoneOptions } from "../utils/checkTypes.js"; export const examples: Example[] = [ ["Run the full verification gate", "hyperframes check"], @@ -102,16 +103,27 @@ export function createCheckCommand( description: "Save the five contrast-pass PNGs under snapshots/", default: false, }, + "caption-zone": { + type: "string", + description: + 'Caption band "x0=0;y0=.82;x1=1;y1=1[;severity=warning|error][;seek=.5,1]" (fractions 0-1; defaults: warning, seek=1)', + }, + "frame-check": { + type: "boolean", + description: + "Use as --frame-check (boolean/no value; tol=2px, severity=warning, seek=.5; breach floor=max(120px, 6% of shorter canvas edge))", + default: false, + }, }, async run({ args }) { - const project = dependencies.resolveProject(args.dir); - const options = parseCheckOptions(args); const asJson = args.json === true; - if (!asJson) { - console.log(`${c.accent("◆")} Checking ${c.accent(project.name)}`); - } try { + const project = dependencies.resolveProject(args.dir); + const options = parseCheckOptions(args); + if (!asJson) { + console.log(`${c.accent("◆")} Checking ${c.accent(project.name)}`); + } const report = await dependencies.runPipeline(project, options); if (asJson) { console.log(JSON.stringify(dependencies.withMeta(report), null, 2)); @@ -151,9 +163,99 @@ function parseCheckOptions(args: Record): CheckOptions { contrast: args.contrast !== false, strict: args.strict === true, snapshots: args.snapshots === true, + captionZone: parseCaptionZone(args["caption-zone"]), + frameCheck: args["frame-check"] === true ? {} : undefined, }; } +const CAPTION_ZONE_FIELDS = new Set(["x0", "y0", "x1", "y1", "severity", "seek"]); + +function parseCaptionZone(value: unknown): CaptionZoneOptions | undefined { + if (value === undefined || value === null) return undefined; + const fields = parseCaptionFields(captionZoneString(value)); + const { x0, y0, x1, y1 } = parseCaptionBounds(fields); + const severity = captionSeverity(fields.get("severity")); + const seek = captionSeeks(fields.get("seek")); + return { + x0, + y0, + x1, + y1, + ...(severity ? { severity } : {}), + ...(seek ? { seek } : {}), + }; +} + +function captionZoneString(value: unknown): string { + if (typeof value !== "string" || value.trim() === "") throw captionZoneError(); + return value; +} + +function parseCaptionFields(value: string): Map { + const fields = new Map(); + for (const part of value.split(";")) { + const { key, entry } = parseCaptionField(part); + if (!CAPTION_ZONE_FIELDS.has(key) || fields.has(key)) throw captionZoneError(); + fields.set(key, entry); + } + return fields; +} + +function parseCaptionField(part: string): { key: string; entry: string } { + const separator = part.indexOf("="); + if (separator <= 0) throw captionZoneError(); + return { + key: part.slice(0, separator).trim(), + entry: part.slice(separator + 1).trim(), + }; +} + +function parseCaptionBounds(fields: Map): { + x0: number; + y0: number; + x1: number; + y1: number; +} { + const x0 = requiredCaptionFraction(fields, "x0"); + const y0 = requiredCaptionFraction(fields, "y0"); + const x1 = requiredCaptionFraction(fields, "x1"); + const y1 = requiredCaptionFraction(fields, "y1"); + if (x0 > x1 || y0 > y1) throw captionZoneError(); + return { x0, y0, x1, y1 }; +} + +function requiredCaptionFraction(fields: Map, key: string): number { + const value = captionFraction(fields.get(key)); + if (value === null) throw captionZoneError(); + return value; +} + +function captionFraction(value: string | undefined): number | null { + if (value === undefined || value === "") return null; + const parsed = Number(value); + return Number.isFinite(parsed) && parsed >= 0 && parsed <= 1 ? parsed : null; +} + +function captionSeverity(value: string | undefined): "error" | "warning" | undefined { + if (value === undefined) return undefined; + if (value === "error" || value === "warning") return value; + throw captionZoneError(); +} + +function captionSeeks(value: string | undefined): number[] | undefined { + if (value === undefined) return undefined; + if (value === "") return []; + const values = value.split(",").map(captionFraction); + if (values.some((entry) => entry === null)) throw captionZoneError(); + return values.flatMap((entry) => (entry === null ? [] : entry)); +} + +function captionZoneError(): Error { + return new Error( + 'Invalid --caption-zone; use "x0=0;y0=.82;x1=1;y1=1[;severity=warning|error][;seek=.5,1]" with fractions from 0 to 1.', + ); +} + function positiveInteger(value: unknown, fallback: number): number { const parsed = parseInt(String(value ?? ""), 10); return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index c67ed809e..568ccfbae 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -81,6 +81,22 @@ return `${selectorFor(parent)} > ${element.tagName.toLowerCase()}:nth-of-type(${index})`; } + function uniqueSelectorFor(element) { + const preferred = selectorFor(element); + try { + if (document.querySelectorAll(preferred).length === 1) return preferred; + } catch { + // Fall through to a structural selector. + } + const parent = element.parentElement; + if (!parent) return preferred; + const siblings = Array.from(parent.children).filter( + (child) => child.tagName === element.tagName, + ); + const index = siblings.indexOf(element) + 1; + return `${uniqueSelectorFor(parent)} > ${element.tagName.toLowerCase()}:nth-of-type(${index})`; + } + function hasIgnoreFlag(element) { return !!element.closest("[data-layout-ignore], [data-layout-check='ignore']"); } @@ -98,6 +114,14 @@ return opacity; } + function hasOpacityBelow(element, floor) { + for (let current = element; current; current = current.parentElement) { + const parsed = Number.parseFloat(getComputedStyle(current).opacity || "1"); + if (Number.isFinite(parsed) && parsed < floor) return true; + } + return false; + } + // A clip-path can shrink an element's painted region to nothing (e.g. a // typewriter span pre-reveal at `inset(0 100% 0 0)`, or `circle(0px)`) while // its layout box, opacity, visibility and display all still read as present. @@ -142,9 +166,20 @@ return !paintsAnyProbePoint(element, rect); } - function isVisibleElement(element) { + function isVisibleElement(element, opacityFloor, probeClipPath) { if (IGNORE_TAGS.has(element.tagName)) return false; if (hasIgnoreFlag(element)) return false; + if ( + opacityFloor != null && + typeof element.checkVisibility === "function" && + !element.checkVisibility({ + opacityProperty: true, + visibilityProperty: true, + contentVisibilityAuto: true, + }) + ) { + return false; + } const style = getComputedStyle(element); if ( style.display === "none" || @@ -153,32 +188,57 @@ ) { return false; } - if (opacityChain(element) < 0.2) return false; + if ( + opacityFloor == null ? opacityChain(element) < 0.2 : hasOpacityBelow(element, opacityFloor) + ) { + return false; + } const rect = element.getBoundingClientRect(); if (rect.width <= 0.5 || rect.height <= 0.5) return false; - return !isClippedAway(element); + return probeClipPath === false || !isClippedAway(element); } - function textContentFor(element) { - return (element.innerText || element.textContent || "").replace(/\s+/g, " ").trim(); + function directTextNodes(element) { + return Array.from(element.childNodes).filter((node) => node.nodeType === 3); } - function hasOwnTextCandidate(element) { - const text = textContentFor(element); + function textContentFor(element, ownTextOnly) { + const content = ownTextOnly + ? directTextNodes(element) + .map((node) => node.textContent || "") + .join("") + : element.innerText || element.textContent || ""; + return content.replace(/\s+/g, " ").trim(); + } + + function hasOwnTextCandidate(element, directOnly) { + const text = textContentFor(element, directOnly); if (!text) return false; + if (directOnly) return true; for (const child of Array.from(element.children)) { if (isVisibleElement(child) && textContentFor(child)) return false; } return true; } - function textRectFor(element) { - const range = document.createRange(); - range.selectNodeContents(element); - const rects = Array.from(range.getClientRects()).filter( - (rect) => rect.width > 0.5 && rect.height > 0.5, - ); - range.detach(); + function textClientRects(element, directOnly) { + const subjects = directOnly ? directTextNodes(element) : [element]; + const rects = []; + for (const subject of subjects) { + const range = document.createRange(); + range.selectNodeContents(subject); + rects.push( + ...Array.from(range.getClientRects()).filter( + (rect) => rect.width > 0.5 && rect.height > 0.5, + ), + ); + range.detach(); + } + return rects; + } + + function textRectFor(element, directOnly) { + const rects = textClientRects(element, directOnly); if (rects.length === 0) return null; const union = rects.reduce( @@ -602,6 +662,7 @@ } const RASTER_TAGS = new Set(["IMG", "VIDEO", "CANVAS"]); + const FRAME_MEDIA_TAGS = new Set([...RASTER_TAGS, "SVG"]); // An element hides text beneath it when it paints opaque pixels at near-full // opacity: raster content (img/video/canvas), a background image, or a solid @@ -703,6 +764,70 @@ }; } + function candidateAnchor(element) { + const dataAttributes = {}; + for (const attribute of Array.from(element.attributes)) { + if (attribute.name.startsWith("data-")) dataAttributes[attribute.name] = attribute.value; + } + const source = element + .closest("[data-composition-file]") + ?.getAttribute("data-composition-file"); + return { + selector: uniqueSelectorFor(element), + dataAttributes, + sourceFile: source || "index.html", + }; + } + + function geometryCandidate(element, kind, rect, elementRect, rootRect, tolerance) { + const tag = element.tagName.toLowerCase(); + const text = kind === "text" ? textContentFor(element, true) : tag; + const overflow = kind === "media" ? overflowFor(elementRect, rootRect, tolerance) : null; + return { + kind, + tag, + text, + rect, + elementRect, + ...candidateAnchor(element), + ...(overflow ? { overflow } : {}), + }; + } + + window.__hyperframesGeometryCandidates = function collectGeometryCandidates(options) { + const includeText = options?.text === true; + const includeMedia = options?.media === true; + if (!includeText && !includeMedia) return []; + const tolerance = typeof options?.tolerance === "number" ? options.tolerance : 2; + const root = + document.querySelector("[data-composition-id][data-width][data-height]") || + document.querySelector("[data-composition-id]") || + document.body; + const rootRect = rootRectFor(root); + const candidates = []; + for (const element of Array.from(document.querySelectorAll("body *"))) { + if (element.closest('[data-composition-id="captions"], .caption-layer, #caption-stage')) { + continue; + } + if (!isVisibleElement(element, 0.05, false)) continue; + const elementRect = toRect(element.getBoundingClientRect()); + if (includeText && hasOwnTextCandidate(element, true)) { + const rect = textRectFor(element, true); + if (rect) { + candidates.push( + geometryCandidate(element, "text", rect, elementRect, rootRect, tolerance), + ); + } + } + if (includeMedia && FRAME_MEDIA_TAGS.has(element.tagName.toUpperCase())) { + candidates.push( + geometryCandidate(element, "media", elementRect, elementRect, rootRect, tolerance), + ); + } + } + return candidates; + }; + window.__hyperframesLayoutAudit = function auditLayout(options) { const time = options && typeof options.time === "number" ? options.time : 0; const tolerance = @@ -712,7 +837,9 @@ document.querySelector("[data-composition-id]") || document.body; const rootRect = rootRectFor(root); - const elements = Array.from(root.querySelectorAll("*")).filter(isVisibleElement); + const elements = Array.from(root.querySelectorAll("*")).filter((element) => + isVisibleElement(element), + ); const issues = []; for (const element of elements) { diff --git a/packages/cli/src/commands/layout-audit.browser.test.ts b/packages/cli/src/commands/layout-audit.browser.test.ts index 061e18da9..01871c5a6 100644 --- a/packages/cli/src/commands/layout-audit.browser.test.ts +++ b/packages/cli/src/commands/layout-audit.browser.test.ts @@ -15,11 +15,20 @@ interface RectInput { height: number; } +afterEach(() => { + vi.restoreAllMocks(); + document.body.innerHTML = ""; + Reflect.deleteProperty(document, "elementFromPoint"); + Reflect.deleteProperty(window, "__hyperframesLayoutAudit"); + clearGeometryCollector(); +}); + describe("layout-audit.browser", () => { afterEach(() => { vi.restoreAllMocks(); document.body.innerHTML = ""; delete (window as unknown as { __hyperframesLayoutAudit?: unknown }).__hyperframesLayoutAudit; + clearGeometryCollector(); }); it("uses authored canvas dimensions when the root bounding rect is degenerate", () => { @@ -135,6 +144,206 @@ describe("layout-audit.browser", () => { expect(runAudit().some((issue) => issue.code === "text_box_overflow")).toBe(true); }); + + it("keeps auditing visible descendants beyond the second element", () => { + document.body.innerHTML = ` +
+
+
+
+
Late visible copy
+
+ `; + installGeometry({ + root: rect({ left: 0, top: 0, width: 640, height: 360 }), + late: rect({ left: 700, top: 100, width: 140, height: 40 }), + text: rect({ left: 700, top: 100, width: 140, height: 40 }), + }); + installAuditScript(); + + expect(runAudit()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: "canvas_overflow", selector: "#late" }), + ]), + ); + }); +}); + +it("is inert unless text or media candidates are explicitly requested", () => { + document.body.innerHTML = ` +
+
Visible copy
+
+ `; + installGeometry({ + root: rect({ left: 0, top: 0, width: 640, height: 360 }), + copy: rect({ left: 100, top: 100, width: 200, height: 40 }), + text: rect({ left: 100, top: 100, width: 200, height: 40 }), + }); + installAuditScript(); + + expect(runGeometryCandidates({ text: false, media: false, tolerance: 2 })).toEqual([]); +}); + +it("returns own-text rects and media overflow while excluding caption layers", () => { + document.body.innerHTML = ` +
+
+
Own copy Nested
+ + +
+

Authored captions

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

Portal copy

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

Visible clipped copy

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

Hidden copy

+

Visible copy

+

Stacked opacity copy

+
+ `; + installGeometry( + { + root: rect({ left: 0, top: 0, width: 640, height: 360 }), + "faint-parent": rect({ left: 40, top: 40, width: 200, height: 40 }), + "hidden-copy": rect({ left: 40, top: 40, width: 200, height: 40 }), + "soft-parent": rect({ left: 40, top: 120, width: 200, height: 40 }), + "visible-copy": rect({ left: 40, top: 120, width: 200, height: 40 }), + "stacked-parent": rect({ left: 40, top: 200, width: 200, height: 40 }), + "stacked-copy": rect({ left: 40, top: 200, width: 200, height: 40 }), + text: rect({ left: 40, top: 120, width: 200, height: 40 }), + }, + { + "faint-parent": { opacity: "0.04" }, + "soft-parent": { opacity: "0.1" }, + "stacked-parent": { opacity: "0.2" }, + "stacked-copy": { opacity: "0.2" }, + }, + ); + installAuditScript(); + + const candidates = runGeometryCandidates({ text: true, media: false, tolerance: 2 }); + + expect(candidates.some((candidate) => candidate.selector === "#hidden-copy")).toBe(false); + expect(candidates.some((candidate) => candidate.selector === "#visible-copy")).toBe(true); + expect(candidates.some((candidate) => candidate.selector === "#stacked-copy")).toBe(true); }); describe("layout-audit.browser content overlap", () => { @@ -143,6 +352,7 @@ describe("layout-audit.browser content overlap", () => { document.body.innerHTML = ""; delete (document as unknown as { elementFromPoint?: unknown }).elementFromPoint; delete (window as unknown as { __hyperframesLayoutAudit?: unknown }).__hyperframesLayoutAudit; + clearGeometryCollector(); }); it("flags two solid text blocks that overlap", () => { @@ -404,6 +614,7 @@ describe("layout-audit.browser occlusion", () => { document.body.innerHTML = ""; delete (document as unknown as { elementFromPoint?: unknown }).elementFromPoint; delete (window as unknown as { __hyperframesLayoutAudit?: unknown }).__hyperframesLayoutAudit; + clearGeometryCollector(); }); it("flags text painted over by an opaque sibling overlay", () => { @@ -622,7 +833,10 @@ function runAudit(): Array<{ return audit({ time: 1, tolerance: 2 }); } -function installGeometry(rects: Record): void { +function installGeometry( + rects: Record, + styleOverrides: Record> = {}, +): void { vi.spyOn(window, "getComputedStyle").mockImplementation((element) => { const el = element as Element; const isBubble = el.id === "bubble"; @@ -648,6 +862,7 @@ function installGeometry(rects: Record): void { paddingBottom: isBubble ? "16px" : "0px", paddingLeft: isBubble ? "16px" : "0px", fontSize: "36px", + ...styleOverrides[el.id], } as unknown as CSSStyleDeclaration; }); @@ -678,6 +893,41 @@ function installGeometry(rects: Record): void { }); } +interface GeometryCandidateResult { + kind: "text" | "media"; + tag: string; + text: string; + selector: string; + sourceFile: string; + rect: Record; + elementRect: Record; + overflow?: Record; +} + +declare global { + interface Window { + __hyperframesGeometryCandidates?: (options: { + text: boolean; + media: boolean; + tolerance: number; + }) => GeometryCandidateResult[]; + } +} + +function runGeometryCandidates(options: { + text: boolean; + media: boolean; + tolerance: number; +}): GeometryCandidateResult[] { + const collector = window.__hyperframesGeometryCandidates; + if (!collector) throw new Error("Geometry collector was not installed"); + return collector(options); +} + +function clearGeometryCollector(): void { + delete window.__hyperframesGeometryCandidates; +} + function rect({ left, top, width, height }: RectInput): DOMRect { return { left, diff --git a/packages/cli/src/utils/checkBrowser.test.ts b/packages/cli/src/utils/checkBrowser.test.ts new file mode 100644 index 000000000..1306a30c3 --- /dev/null +++ b/packages/cli/src/utils/checkBrowser.test.ts @@ -0,0 +1,110 @@ +// @vitest-environment happy-dom +import { afterEach, expect, it, vi } from "vitest"; +import { + openSettledCompositionPage, + type OpenSettledCompositionPageOptions, +} from "../capture/captureCompositionFrame.js"; +import { DEFAULT_CHECK_OPTIONS, runAuditGrid } from "./checkPipeline.js"; +import { runBrowserCheck } from "./checkBrowser.js"; +import type { ProjectDir } from "./project.js"; + +const mocks = vi.hoisted(() => ({ + serverClose: vi.fn(async () => undefined), +})); + +vi.mock("@hyperframes/core/compiler", () => ({ + bundleToSingleHtml: vi.fn(async () => ""), +})); + +vi.mock("../capture/captureCompositionFrame.js", () => ({ + openSettledCompositionPage: vi.fn(), + resolveCliChromeGpuMode: vi.fn(() => "hardware"), + seekCompositionTimeline: vi.fn(async () => undefined), + waitForPreferredSeekTarget: vi.fn(async () => undefined), +})); + +vi.mock("./staticProjectServer.js", () => ({ + serveStaticProjectHtml: vi.fn(async () => ({ + url: "http://127.0.0.1:3000", + close: mocks.serverClose, + })), +})); + +const PROJECT: ProjectDir = { + dir: "/project", + name: "project", + indexPath: "/project/index.html", +}; + +afterEach(() => { + vi.restoreAllMocks(); + document.body.innerHTML = ""; + Reflect.deleteProperty(window, "__hyperframesGeometryCandidates"); + Reflect.deleteProperty(window, "__hyperframesLayoutAudit"); +}); + +it("carries raw browser geometry through the page driver and pipeline", async () => { + document.body.innerHTML = ` +
+
+ +
+
+ `; + Object.defineProperty(window, "innerWidth", { configurable: true, value: 640 }); + Object.defineProperty(window, "innerHeight", { configurable: true, value: 360 }); + installRects(); + const page = fakePage(); + const browser = Object.assign(Object.create(null), { + close: vi.fn(async () => undefined), + }); + vi.mocked(openSettledCompositionPage).mockImplementation( + async (_html: string, _url: string, options: OpenSettledCompositionPageOptions) => { + await options.beforeNavigate?.(page); + return { page, browser, renderReadyTimedOut: false }; + }, + ); + + const result = await runBrowserCheck( + PROJECT, + { ...DEFAULT_CHECK_OPTIONS, samples: 1, contrast: false, frameCheck: {} }, + { kind: "none" }, + runAuditGrid, + ); + + expect(result.layoutIssues).toEqual([ + expect.objectContaining({ + code: "frame_out_of_frame", + severity: "warning", + selector: "#hero-image", + sourceFile: "scenes/hero.html", + dataAttributes: { "data-layout-name": "hero" }, + bbox: { x: 600, y: 80, width: 200, height: 100 }, + rect: { left: 600, top: 80, right: 800, bottom: 180, width: 200, height: 100 }, + overflow: { right: 160 }, + time: 5, + }), + ]); + expect(mocks.serverClose).toHaveBeenCalledOnce(); +}); + +function installRects(): void { + const root = document.querySelector("[data-composition-id]"); + const image = document.querySelector("#hero-image"); + if (!root || !image) throw new Error("Geometry fixture failed to mount"); + vi.spyOn(root, "getBoundingClientRect").mockReturnValue(new DOMRect(0, 0, 640, 360)); + vi.spyOn(image, "getBoundingClientRect").mockReturnValue(new DOMRect(600, 80, 200, 100)); +} + +function fakePage() { + return Object.assign(Object.create(null), { + on: vi.fn(), + addScriptTag: vi.fn(async ({ content }: { content: string }) => { + window.eval(content); + }), + evaluate: vi.fn(async (callback: unknown, ...args: unknown[]) => { + if (typeof callback !== "function") throw new Error("Expected an evaluate callback"); + return Reflect.apply(callback, window, args); + }), + }); +} diff --git a/packages/cli/src/utils/checkBrowser.ts b/packages/cli/src/utils/checkBrowser.ts index 098d97a22..ca8152d0d 100644 --- a/packages/cli/src/utils/checkBrowser.ts +++ b/packages/cli/src/utils/checkBrowser.ts @@ -18,10 +18,12 @@ import type { CheckBbox, CheckBrowserResult, CheckFinding, + CheckGeometryCandidate, CheckOptions, CheckSeverity, ContrastAuditEntry, ContrastCapture, + GeometryCandidateRequest, MotionSpecResolution, RunAuditGrid, } from "./checkTypes.js"; @@ -193,6 +195,7 @@ function createPageDriver(page: Page, setTime: (time: number) => void): CheckAud await seekCompositionTimeline(page, time, SEEK_OPTIONS); }, collectLayout: (time, tolerance) => collectLayout(page, time, tolerance), + collectGeometryCandidates: (time, request) => collectGeometryCandidates(page, time, request), collectMotionFrame: (time, selectors, scopes) => collectMotionFrame(page, time, selectors, scopes), anchorMotionIssues: (issues) => anchorLayoutIssues(page, issues), @@ -304,6 +307,24 @@ async function collectLayout( return anchorLayoutIssues(page, raw.flatMap(parseLayoutIssue)); } +async function collectGeometryCandidates( + page: Page, + time: number, + request: GeometryCandidateRequest, +): Promise { + try { + const raw = await page.evaluate((options: GeometryCandidateRequest) => { + const collect = Reflect.get(window, "__hyperframesGeometryCandidates"); + if (typeof collect !== "function") return []; + const result = Reflect.apply(collect, window, [options]); + return Array.isArray(result) ? result : []; + }, request); + return raw.flatMap((value) => parseGeometryCandidate(value, time)); + } catch { + return []; + } +} + async function findAmbiguousSelectors( page: Page, selectors: string[], @@ -590,6 +611,54 @@ function parseLayoutIssue(value: unknown): LayoutIssue[] { return [issue]; } +function parseGeometryCandidate(value: unknown, time: number): CheckGeometryCandidate[] { + if (!isRecord(value)) return []; + const rect = parseRect(Reflect.get(value, "rect")); + const elementRect = parseRect(Reflect.get(value, "elementRect")); + if (!rect || !elementRect) return []; + const identity = parseGeometryIdentity(value); + if (!identity) return []; + const anchor = parseGeometryAnchor(value, rect, time); + if (!anchor) return []; + const candidate: CheckGeometryCandidate = { ...identity, ...anchor, rect, elementRect }; + const overflow = parseOverflow(Reflect.get(value, "overflow")); + if (overflow) candidate.overflow = overflow; + return [candidate]; +} + +function parseGeometryIdentity( + value: Record, +): Pick | null { + const kindValue = Reflect.get(value, "kind"); + const kind = kindValue === "text" || kindValue === "media" ? kindValue : null; + if (!kind) return null; + const tag = stringValue(value, "tag"); + if (!tag) return null; + const text = stringValue(value, "text"); + return text === null ? null : { kind, tag, text }; +} + +function parseGeometryAnchor( + value: Record, + rect: LayoutRect, + time: number, +): CheckAnchor | null { + const selector = stringValue(value, "selector"); + if (!selector) return null; + const sourceFile = stringValue(value, "sourceFile"); + if (!sourceFile) return null; + const dataAttributes = stringRecord(Reflect.get(value, "dataAttributes")); + return dataAttributes + ? { + selector, + sourceFile, + dataAttributes, + bbox: rectToBbox(rect), + time, + } + : null; +} + function assignOptionalLayoutFields(issue: LayoutIssue, value: Record): void { assignOptionalString(issue, value, "containerSelector"); assignOptionalString(issue, value, "text"); @@ -721,6 +790,8 @@ const LAYOUT_ISSUE_CODES: readonly LayoutIssueCode[] = [ "container_overflow", "content_overlap", "text_occluded", + "caption_zone_collision", + "frame_out_of_frame", "motion_appears_late", "motion_out_of_order", "motion_off_frame", diff --git a/packages/cli/src/utils/checkPipeline.ts b/packages/cli/src/utils/checkPipeline.ts index 854cb72c4..07b65f2bb 100644 --- a/packages/cli/src/utils/checkPipeline.ts +++ b/packages/cli/src/utils/checkPipeline.ts @@ -12,7 +12,12 @@ import { type LayoutIssue, type LayoutRect, } from "./layoutAudit.js"; -import { collectSamplingTargets, evaluateMotion, type MotionFrame } from "./motionAudit.js"; +import { + collectSamplingTargets, + evaluateMotion, + type Canvas, + type MotionFrame, +} from "./motionAudit.js"; import { findMotionSpec, readMotionSpec } from "./motionSpec.js"; import { normalizeErrorMessage } from "./errorMessage.js"; import { @@ -29,12 +34,14 @@ import type { CheckContrastFinding, CheckDependencies, CheckFinding, + CheckGeometryCandidate, CheckOptions, CheckReport, CheckScreenshot, CheckSection, CheckSeverity, ContrastAuditEntry, + GeometryCandidateRequest, MotionSpecResolution, } from "./checkTypes.js"; @@ -55,6 +62,9 @@ export type { const MOTION_FPS = 20; const MOTION_MAX_SAMPLES = 300; const ZERO_BBOX: CheckBbox = { x: 0, y: 0, width: 0, height: 0 }; +// Ignore normal in/out slide travel; only substantive frame breaches are actionable. +const FRAME_BREACH_FLOOR_PX = 120; +const FRAME_BREACH_FLOOR_FRACTION = 0.06; export const DEFAULT_CHECK_OPTIONS: CheckOptions = { samples: 9, @@ -87,11 +97,23 @@ function buildMotionSampleTimes(duration: number): number[] { interface SampleGrid { duration: number; layoutSamples: number[]; + captionSamples: number[]; + frameSamples: number[]; transitionSamples: number[]; transitionSamplesDropped: number; contrastSamples: number[]; } +function gateSampleTimes( + duration: number, + seeks: number[] | undefined, + fallback: number, +): number[] { + if (!Number.isFinite(duration) || duration <= 0) return []; + const fractions = seeks && seeks.length > 0 ? seeks : [fallback]; + return mergeSampleTimes(fractions.map((fraction) => fraction * duration)); +} + async function buildSampleGrid( driver: CheckAuditDriver, options: CheckOptions, @@ -109,16 +131,25 @@ async function buildSampleGrid( cap: options.maxTransitionSamples, }) : { times: [], dropped: 0 }; - const layoutSamples = mergeSampleTimes(baseSamples, transitions.times); + const captionSamples = options.captionZone + ? gateSampleTimes(duration, options.captionZone.seek, 1) + : []; + const frameSamples = options.frameCheck + ? gateSampleTimes(duration, options.frameCheck.seek, 0.5) + : []; + const auditSamples = mergeSampleTimes(baseSamples, transitions.times); + const layoutSamples = mergeSampleTimes(auditSamples, captionSamples, frameSamples); if (layoutSamples.length === 0) { throw new Error("Could not determine composition duration — no layout samples run"); } return { duration, layoutSamples, + captionSamples, + frameSamples, transitionSamples: transitions.times, transitionSamplesDropped: transitions.dropped, - contrastSamples: options.contrast ? selectContrastTimes(layoutSamples) : [], + contrastSamples: options.contrast ? selectContrastTimes(auditSamples) : [], }; } @@ -151,6 +182,156 @@ interface GridSamples { screenshots: CheckScreenshot[]; } +interface GeometrySeen { + caption: Set; + frame: Set; +} + +function geometryRequest( + time: number, + grid: SampleGrid, + options: CheckOptions, +): GeometryCandidateRequest | null { + const text = grid.captionSamples.includes(time); + const media = grid.frameSamples.includes(time); + if (!text && !media) return null; + const configuredTolerance = options.frameCheck?.tol; + const tolerance = typeof configuredTolerance === "number" ? configuredTolerance : 2; + return { text, media, tolerance }; +} + +function candidateIsSized(candidate: CheckGeometryCandidate, canvas: Canvas): boolean { + if (candidate.elementRect.width < 4 || candidate.elementRect.height < 4) return false; + return !( + candidate.elementRect.width >= 0.95 * canvas.width && + candidate.elementRect.height >= 0.95 * canvas.height + ); +} + +function geometryIssueAnchor(candidate: CheckGeometryCandidate, time: number) { + return { + selector: candidate.selector, + dataAttributes: candidate.dataAttributes, + sourceFile: candidate.sourceFile, + bbox: candidate.bbox, + time, + rect: candidate.rect, + }; +} + +function captionFinding( + candidate: CheckGeometryCandidate, + options: CheckOptions, + canvas: Canvas, + time: number, +): { key: string; issue: AnchoredLayoutIssue } | null { + const zone = options.captionZone; + if (!zone || candidate.kind !== "text" || !candidateIsSized(candidate, canvas)) return null; + const cx = candidate.rect.left + candidate.rect.width / 2; + const cy = candidate.rect.top + candidate.rect.height / 2; + const inside = + cx >= zone.x0 * canvas.width && + cx <= zone.x1 * canvas.width && + cy >= zone.y0 * canvas.height && + cy <= zone.y1 * canvas.height; + if (!inside) return null; + const text = candidate.text.slice(0, 48); + const pctFromBottom = Math.round(((canvas.height - cy) / canvas.height) * 100); + return { + key: `${candidate.tag}|${text}`, + issue: { + ...geometryIssueAnchor(candidate, time), + code: "caption_zone_collision", + severity: zone.severity === "error" ? "error" : "warning", + text, + message: `<${candidate.tag}> "${text}" is centred in the reserved caption band (~${pctFromBottom}% up from the bottom).`, + fixHint: "Keep main content outside the configured caption band.", + }, + }; +} + +function maxOverflow(candidate: CheckGeometryCandidate): number { + if (!candidate.overflow) return 0; + return Math.max( + candidate.overflow.left ?? 0, + candidate.overflow.top ?? 0, + candidate.overflow.right ?? 0, + candidate.overflow.bottom ?? 0, + ); +} + +function overflowMessage(candidate: CheckGeometryCandidate): string { + const overflow = candidate.overflow ?? {}; + const edges: string[] = []; + if (overflow.left) edges.push(`${overflow.left}px past the left`); + if (overflow.top) edges.push(`${overflow.top}px past the top`); + if (overflow.right) edges.push(`${overflow.right}px past the right`); + if (overflow.bottom) edges.push(`${overflow.bottom}px past the bottom`); + return `<${candidate.tag}> "${candidate.text.slice(0, 48)}" spills outside the frame (${edges.join(", ")}).`; +} + +function frameFinding( + candidate: CheckGeometryCandidate, + options: CheckOptions, + canvas: Canvas, + time: number, +): { key: string; issue: AnchoredLayoutIssue } | null { + if (!options.frameCheck || candidate.kind !== "media" || !candidateIsSized(candidate, canvas)) { + return null; + } + const floor = Math.max( + FRAME_BREACH_FLOOR_PX, + FRAME_BREACH_FLOOR_FRACTION * Math.min(canvas.width, canvas.height), + ); + if (maxOverflow(candidate) < floor) return null; + const text = candidate.text.slice(0, 48); + return { + key: `${candidate.tag}|${text}|${Math.round(candidate.rect.left)},${Math.round(candidate.rect.top)}`, + issue: { + ...geometryIssueAnchor(candidate, time), + code: "frame_out_of_frame", + severity: options.frameCheck.severity === "error" ? "error" : "warning", + text, + overflow: candidate.overflow, + message: overflowMessage(candidate), + fixHint: "Keep media within the composition frame's safe area.", + }, + }; +} + +function appendGeometryFinding( + result: { key: string; issue: AnchoredLayoutIssue } | null, + seen: Set, + issues: AnchoredLayoutIssue[], +): void { + if (!result || seen.has(result.key)) return; + seen.add(result.key); + issues.push(result.issue); +} + +async function collectGeometryAt( + driver: CheckAuditDriver, + options: CheckOptions, + grid: SampleGrid, + canvas: Canvas, + time: number, + seen: GeometrySeen, +): Promise { + const request = geometryRequest(time, grid, options); + if (!request) return []; + const candidates = await driver.collectGeometryCandidates(time, request); + const issues: AnchoredLayoutIssue[] = []; + for (const candidate of candidates) { + if (request.text) { + appendGeometryFinding(captionFinding(candidate, options, canvas, time), seen.caption, issues); + } + if (request.media) { + appendGeometryFinding(frameFinding(candidate, options, canvas, time), seen.frame, issues); + } + } + return issues; +} + async function collectGridSamples( driver: CheckAuditDriver, options: CheckOptions, @@ -160,6 +341,9 @@ async function collectGridSamples( const layoutSet = new Set(grid.layoutSamples); const motionSet = new Set(motion.times); const contrastSet = new Set(grid.contrastSamples); + const geometryEnabled = grid.captionSamples.length > 0 || grid.frameSamples.length > 0; + const canvas = geometryEnabled ? await driver.getCanvas() : null; + const geometrySeen: GeometrySeen = { caption: new Set(), frame: new Set() }; const collected: GridSamples = { layoutIssues: [], motionFrames: [], @@ -171,6 +355,11 @@ async function collectGridSamples( if (layoutSet.has(time)) { collected.layoutIssues.push(...(await driver.collectLayout(time, options.tolerance))); } + if (canvas) { + collected.layoutIssues.push( + ...(await collectGeometryAt(driver, options, grid, canvas, time, geometrySeen)), + ); + } if (motionSet.has(time)) { collected.motionFrames.push( await driver.collectMotionFrame(time, motion.selectors, motion.livenessScopes), diff --git a/packages/cli/src/utils/checkTypes.ts b/packages/cli/src/utils/checkTypes.ts index e129793f7..2c45dde92 100644 --- a/packages/cli/src/utils/checkTypes.ts +++ b/packages/cli/src/utils/checkTypes.ts @@ -1,5 +1,5 @@ import type { ProjectLintResult } from "./lintProject.js"; -import type { LayoutIssue } from "./layoutAudit.js"; +import type { LayoutIssue, LayoutOverflow, LayoutRect } from "./layoutAudit.js"; import type { Canvas, MotionFrame } from "./motionAudit.js"; import type { MotionSpec } from "./motionSpec.js"; import type { ProjectDir } from "./project.js"; @@ -16,6 +16,23 @@ export interface CheckOptions { contrast: boolean; strict: boolean; snapshots: boolean; + captionZone?: CaptionZoneOptions; + frameCheck?: FrameCheckOptions; +} + +export interface CaptionZoneOptions { + x0: number; + y0: number; + x1: number; + y1: number; + severity?: "error" | "warning"; + seek?: number[]; +} + +export interface FrameCheckOptions { + tol?: number; + severity?: "error" | "warning"; + seek?: number[]; } export type CheckSeverity = "error" | "warning" | "info"; @@ -70,6 +87,21 @@ export interface ContrastCapture { pngBase64: string; } +export interface GeometryCandidateRequest { + text: boolean; + media: boolean; + tolerance: number; +} + +export interface CheckGeometryCandidate extends CheckAnchor { + kind: "text" | "media"; + tag: string; + text: string; + rect: LayoutRect; + elementRect: LayoutRect; + overflow?: LayoutOverflow; +} + export type MotionSpecResolution = | { kind: "none" } | { kind: "valid"; path: string; spec: MotionSpec } @@ -83,6 +115,10 @@ export interface CheckAuditDriver { findAmbiguousSelectors(selectors: string[]): Promise; seek(time: number): Promise; collectLayout(time: number, tolerance: number): Promise; + collectGeometryCandidates( + time: number, + request: GeometryCandidateRequest, + ): Promise; collectMotionFrame( time: number, selectors: string[], diff --git a/packages/cli/src/utils/layoutAudit.ts b/packages/cli/src/utils/layoutAudit.ts index 70b00deb9..e006bf673 100644 --- a/packages/cli/src/utils/layoutAudit.ts +++ b/packages/cli/src/utils/layoutAudit.ts @@ -16,6 +16,8 @@ export type LayoutIssueCode = | "container_overflow" | "content_overlap" | "text_occluded" + | "caption_zone_collision" + | "frame_out_of_frame" // Motion-verification findings (#1437) — evaluated against the seeked timeline. | "motion_appears_late" | "motion_out_of_order" @@ -152,6 +154,7 @@ export function dedupeLayoutIssues(issues: LayoutIssue[]): LayoutIssue[] { issue.containerSelector ?? "", issue.text ?? "", issue.overflow ? formatOverflow(issue.overflow) : "", + framePositionKey(issue), ].join("|"); if (seen.has(key)) continue; seen.add(key); @@ -230,9 +233,16 @@ function staticIssueKey(issue: LayoutIssue): string { issue.containerSelector ?? "", issue.text ?? "", issue.overflow ? formatOverflow(issue.overflow) : "", + framePositionKey(issue), ].join("|"); } +function framePositionKey(issue: LayoutIssue): string { + return issue.code === "frame_out_of_frame" + ? `${Math.round(issue.rect.left)},${Math.round(issue.rect.top)}` + : ""; +} + function uniqueSortedTimes(times: number[]): number[] { const rounded = times.map(roundTime); return [...new Set(rounded)].sort((a, b) => a - b);