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.
This commit is contained in:
Miguel Angel Simon Sierra
2026-07-10 13:27:52 -04:00
parent 7d6d41361b
commit 7ab6c2b7a2
9 changed files with 1376 additions and 27 deletions
+456 -2
View File
@@ -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> = {}): 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> = {}): 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(
'<div data-composition-id="portrait" data-width="1080" data-height="1920"></div>',
);
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/);
});
});
+107 -5
View File
@@ -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<string, unknown>): 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<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;
+142 -15
View File
@@ -81,6 +81,22 @@
return `${selectorFor(parent)} > ${element.tagName.toLowerCase()}:nth-of-type(${index})`;
}
function uniqueSelectorFor(element) {
const preferred = selectorFor(element);
try {
if (document.querySelectorAll(preferred).length === 1) return preferred;
} catch {
// Fall through to a structural selector.
}
const parent = element.parentElement;
if (!parent) return preferred;
const siblings = Array.from(parent.children).filter(
(child) => child.tagName === element.tagName,
);
const index = siblings.indexOf(element) + 1;
return `${uniqueSelectorFor(parent)} > ${element.tagName.toLowerCase()}:nth-of-type(${index})`;
}
function hasIgnoreFlag(element) {
return !!element.closest("[data-layout-ignore], [data-layout-check='ignore']");
}
@@ -98,6 +114,14 @@
return opacity;
}
function hasOpacityBelow(element, floor) {
for (let current = element; current; current = current.parentElement) {
const parsed = Number.parseFloat(getComputedStyle(current).opacity || "1");
if (Number.isFinite(parsed) && parsed < floor) return true;
}
return false;
}
// A clip-path can shrink an element's painted region to nothing (e.g. a
// typewriter span pre-reveal at `inset(0 100% 0 0)`, or `circle(0px)`) while
// its layout box, opacity, visibility and display all still read as present.
@@ -142,9 +166,20 @@
return !paintsAnyProbePoint(element, rect);
}
function isVisibleElement(element) {
function isVisibleElement(element, opacityFloor, probeClipPath) {
if (IGNORE_TAGS.has(element.tagName)) return false;
if (hasIgnoreFlag(element)) return false;
if (
opacityFloor != null &&
typeof element.checkVisibility === "function" &&
!element.checkVisibility({
opacityProperty: true,
visibilityProperty: true,
contentVisibilityAuto: true,
})
) {
return false;
}
const style = getComputedStyle(element);
if (
style.display === "none" ||
@@ -153,32 +188,57 @@
) {
return false;
}
if (opacityChain(element) < 0.2) return false;
if (
opacityFloor == null ? opacityChain(element) < 0.2 : hasOpacityBelow(element, opacityFloor)
) {
return false;
}
const rect = element.getBoundingClientRect();
if (rect.width <= 0.5 || rect.height <= 0.5) return false;
return !isClippedAway(element);
return probeClipPath === false || !isClippedAway(element);
}
function textContentFor(element) {
return (element.innerText || element.textContent || "").replace(/\s+/g, " ").trim();
function directTextNodes(element) {
return Array.from(element.childNodes).filter((node) => node.nodeType === 3);
}
function hasOwnTextCandidate(element) {
const text = textContentFor(element);
function textContentFor(element, ownTextOnly) {
const content = ownTextOnly
? directTextNodes(element)
.map((node) => node.textContent || "")
.join("")
: element.innerText || element.textContent || "";
return content.replace(/\s+/g, " ").trim();
}
function hasOwnTextCandidate(element, directOnly) {
const text = textContentFor(element, directOnly);
if (!text) return false;
if (directOnly) return true;
for (const child of Array.from(element.children)) {
if (isVisibleElement(child) && textContentFor(child)) return false;
}
return true;
}
function textRectFor(element) {
const range = document.createRange();
range.selectNodeContents(element);
const rects = Array.from(range.getClientRects()).filter(
(rect) => rect.width > 0.5 && rect.height > 0.5,
);
range.detach();
function textClientRects(element, directOnly) {
const subjects = directOnly ? directTextNodes(element) : [element];
const rects = [];
for (const subject of subjects) {
const range = document.createRange();
range.selectNodeContents(subject);
rects.push(
...Array.from(range.getClientRects()).filter(
(rect) => rect.width > 0.5 && rect.height > 0.5,
),
);
range.detach();
}
return rects;
}
function textRectFor(element, directOnly) {
const rects = textClientRects(element, directOnly);
if (rects.length === 0) return null;
const union = rects.reduce(
@@ -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) {
@@ -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", () => {
@@ -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<string, DOMRect>): void {
function installGeometry(
rects: Record<string, DOMRect>,
styleOverrides: Record<string, Partial<CSSStyleDeclaration>> = {},
): 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<string, DOMRect>): 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<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,