diff --git a/packages/cli/src/commands/check.test.ts b/packages/cli/src/commands/check.test.ts index 8d9a07f97..050722eb5 100644 --- a/packages/cli/src/commands/check.test.ts +++ b/packages/cli/src/commands/check.test.ts @@ -1141,6 +1141,57 @@ describe("frame-check flag grammar", () => { }); expect(() => parseFrameCheck("bogus=1")).toThrow("Invalid --frame-check"); expect(() => parseFrameCheck("tol=-2")).toThrow("Invalid --frame-check"); + expect(() => parseFrameCheck("tol=4px")).toThrow("Invalid --frame-check"); + expect(() => parseFrameCheck("tol=2garbage")).toThrow("Invalid --frame-check"); + }); +}); + +describe("layout flag grammar", () => { + it("parses proseCoverageFloor and rejects malformed specs", async () => { + const { parseLayout } = await import("./check.js"); + expect(parseLayout(undefined)).toBeUndefined(); + expect(parseLayout("proseCoverageFloor=0.05")).toEqual({ proseCoverageFloor: 0.05 }); + expect(parseLayout("proseCoverageFloor=0")).toEqual({ proseCoverageFloor: 0 }); + expect(parseLayout("proseCoverageFloor=1")).toEqual({ proseCoverageFloor: 1 }); + expect(() => parseLayout(true)).toThrow("Invalid --layout"); + expect(() => parseLayout("")).toThrow("Invalid --layout"); + expect(() => parseLayout("bogus=1")).toThrow("Invalid --layout"); + expect(() => parseLayout("proseCoverageFloor=-0.1")).toThrow("Invalid --layout"); + expect(() => parseLayout("proseCoverageFloor=1.1")).toThrow("Invalid --layout"); + expect(() => parseLayout("proseCoverageFloor=0.05garbage")).toThrow("Invalid --layout"); + expect(() => parseLayout("proseCoverageFloor=0.1%")).toThrow("Invalid --layout"); + expect(() => parseLayout("proseCoverageFloor=")).toThrow("Invalid --layout"); + }); + + it("threads --layout into the check pipeline options", 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", "--layout", "proseCoverageFloor=0.05"], + }); + + expect(runPipeline).toHaveBeenCalledWith( + PROJECT, + expect.objectContaining({ + layout: { proseCoverageFloor: 0.05 }, + }), + ); + }); + + it("forwards layout options into driver.collectLayout", async () => { + const collectLayout = vi.fn(async (_time: number, _tolerance: number, _layout?: unknown) => []); + await runScenario(fakeDriver({ collectLayout }), { layout: { proseCoverageFloor: 0.05 } }); + expect(collectLayout).toHaveBeenCalled(); + expect(collectLayout).toHaveBeenCalledWith(expect.any(Number), expect.any(Number), { + proseCoverageFloor: 0.05, + }); }); }); diff --git a/packages/cli/src/commands/check.ts b/packages/cli/src/commands/check.ts index 3dfe56aad..dd5a5085e 100644 --- a/packages/cli/src/commands/check.ts +++ b/packages/cli/src/commands/check.ts @@ -16,7 +16,7 @@ import { type CheckReport, type CheckSection, } from "../utils/checkPipeline.js"; -import type { CaptionZoneOptions, FrameCheckOptions } from "../utils/checkTypes.js"; +import type { CaptionZoneOptions, FrameCheckOptions, LayoutOptions } from "../utils/checkTypes.js"; export const examples: Example[] = [ ["Run the full verification gate", "hyperframes check"], @@ -120,6 +120,10 @@ export function createCheckCommand( description: 'Bare --frame-check uses defaults (tol=2px, severity=warning, seek=.5; breach floor=max(120px, 6% of shorter canvas edge)); or pass "severity=error;seek=.25,.75;tol=4" to tune', }, + layout: { + type: "string", + description: 'Layout knobs: "proseCoverageFloor=0.05" (0–1; default 0.15).', + }, }, async run({ args }) { const asJson = args.json === true; @@ -168,6 +172,7 @@ function parseCheckOptions(args: Record): CheckOptions { snapshots: args.snapshots === true, captionZone: parseCaptionZone(args["caption-zone"]), frameCheck: parseFrameCheck(args["frame-check"]), + layout: parseLayout(args.layout), autoProxy: args.proxy as boolean | undefined, }; } @@ -176,6 +181,8 @@ const CAPTION_ZONE_FIELDS = new Set(["x0", "y0", "x1", "y1", "severity", "seek"] const FRAME_CHECK_FIELDS = new Set(["severity", "seek", "tol"]); +const LAYOUT_FIELDS = new Set(["proseCoverageFloor"]); + // Mirrors --caption-zone's spec grammar so the EF bridge's severity/seek/tol // options survive the migration instead of being silently dropped by a // boolean flag (bare --frame-check keeps today's defaults). @@ -206,8 +213,8 @@ function parseFrameCheckFields(value: string): Map { function parseFrameCheckTolerance(raw: string | undefined): number | undefined { if (raw === undefined) return undefined; - const tol = Number.parseFloat(raw); - if (!Number.isFinite(tol) || tol < 0) throw frameCheckError(); + const tol = parseNumberStrict(raw); + if (tol === null || tol < 0) throw frameCheckError(); return tol; } @@ -217,6 +224,52 @@ function frameCheckError(): Error { ); } +/** Parse `--layout "proseCoverageFloor=0.05"` (semicolon-separated key=value, like caption-zone). */ +export function parseLayout(value: unknown): LayoutOptions | undefined { + if (value === undefined || value === null || value === false) return undefined; + if (value === true || value === "") throw layoutError(); + if (typeof value !== "string") throw layoutError(); + const fields = parseLayoutFields(value); + const proseCoverageFloor = parseProseCoverageFloor(fields.get("proseCoverageFloor")); + if (proseCoverageFloor === undefined) throw layoutError(); + return { proseCoverageFloor }; +} + +function parseLayoutFields(value: string): Map { + const fields = new Map(); + for (const part of value.split(";")) { + const trimmed = part.trim(); + if (!trimmed) continue; + const separator = trimmed.indexOf("="); + if (separator <= 0) throw layoutError(); + const key = trimmed.slice(0, separator).trim(); + const entry = trimmed.slice(separator + 1).trim(); + if (!LAYOUT_FIELDS.has(key) || fields.has(key)) throw layoutError(); + fields.set(key, entry); + } + return fields; +} + +function parseProseCoverageFloor(raw: string | undefined): number | undefined { + if (raw === undefined) return undefined; + const floor = parseNumberStrict(raw); + if (floor === null || floor < 0 || floor > 1) throw layoutError(); + return floor; +} + +function layoutError(): Error { + return new Error( + 'Invalid --layout: use "proseCoverageFloor=0.05" with a fraction from 0 to 1 (inclusive)', + ); +} + +/** Reject trailing garbage that Number.parseFloat would silently accept (`4px`, `0.05abc`). */ +function parseNumberStrict(raw: string): number | null { + if (raw === "") return null; + const value = Number(raw); + return Number.isFinite(value) ? value : null; +} + function parseCaptionZone(value: unknown): CaptionZoneOptions | undefined { if (value === undefined || value === null) return undefined; const fields = parseCaptionFields(captionZoneString(value)); @@ -279,8 +332,8 @@ function requiredCaptionFraction(fields: Map, key: string): numb 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; + const parsed = parseNumberStrict(value); + return parsed !== null && parsed >= 0 && parsed <= 1 ? parsed : null; } function captionSeverity(value: string | undefined): "error" | "warning" | undefined { diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index 6bb77354d..e37e7853d 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -935,7 +935,8 @@ // (the pre-#U10 behaviour). Longer prose survives a nibbled edge; only flag // once a real share of it is covered — see `occludedTextIssue`. const ATOMIC_LABEL_MAX_CHARS = 16; - const PROSE_COVERAGE_FLOOR = 0.15; + // Default prose floor — callers may lower via auditLayout({ proseCoverageFloor }). + const DEFAULT_PROSE_COVERAGE_FLOOR = 0.15; function isAtomicLabel(text) { return text.length > 0 && text.length <= ATOMIC_LABEL_MAX_CHARS && !/\s/.test(text); @@ -995,12 +996,8 @@ return false; } - // Catches the blind spot the overflow checks miss: text that fits its box - // perfectly but is covered by a later sibling/overlay. An atomic label - // (short, no whitespace) flags at any coverage; ordinary prose only flags - // once coveredFraction clears PROSE_COVERAGE_FLOOR, since a sliver of edge - // cover on a paragraph is usually a styling artifact, not a reading defect. - function occludedTextIssue(element, time) { + // text_occluded: atomic labels flag at any hit; prose needs coveredFraction >= proseCoverageFloor (default 0.15). + function occludedTextIssue(element, time, proseCoverageFloor) { if (hasAllowOcclusionFlag(element)) return null; if (!hasVisibleTextInk(element)) return null; const textRect = textRectFor(element, true); @@ -1012,7 +1009,7 @@ textRects.length > 0 ? textRects : [textRect], ); if (!occluder) return null; - if (!isAtomicLabel(text) && coveredFraction < PROSE_COVERAGE_FLOOR) return null; + if (!isAtomicLabel(text) && coveredFraction < proseCoverageFloor) return null; return { code: "text_occluded", severity: "error", @@ -1420,6 +1417,10 @@ const time = options && typeof options.time === "number" ? options.time : 0; const tolerance = options && typeof options.tolerance === "number" ? Math.max(0, options.tolerance) : 2; + const proseCoverageFloor = + options && typeof options.proseCoverageFloor === "number" + ? Math.min(1, Math.max(0, options.proseCoverageFloor)) + : DEFAULT_PROSE_COVERAGE_FLOOR; const root = document.querySelector("[data-composition-id][data-width][data-height]") || document.querySelector("[data-composition-id]") || @@ -1437,7 +1438,7 @@ const clipped = clippedTextIssue(element, time, tolerance); if (clipped) issues.push(clipped); issues.push(...textOverflowIssues(element, root, rootRect, time, tolerance)); - const occluded = occludedTextIssue(element, time); + const occluded = occludedTextIssue(element, time, proseCoverageFloor); if (occluded) issues.push(occluded); const invisible = invisibleTextIssue(element, time); if (invisible) issues.push(invisible); diff --git a/packages/cli/src/commands/layout-audit.browser.test.ts b/packages/cli/src/commands/layout-audit.browser.test.ts index 98e3e1fe5..849db6424 100644 --- a/packages/cli/src/commands/layout-audit.browser.test.ts +++ b/packages/cli/src/commands/layout-audit.browser.test.ts @@ -1786,6 +1786,26 @@ describe("layout-audit.browser occlusion", () => { expect(issues.some((issue) => issue.code === "text_occluded")).toBe(false); }); + it("flags ~0.07 prose when proseCoverageFloor is lowered to 0.05", () => { + const issues = auditCoverageScene({ + text: "This paragraph is long enough to read as ordinary prose, not a label.", + hitCount: 2, + proseCoverageFloor: 0.05, + }); + const occluded = issues.find((issue) => issue.code === "text_occluded"); + expect(occluded).toBeDefined(); + expect(occluded?.coveredFraction).toBe(0.07); + }); + + it("still flags an atomic label at ~0.07 when proseCoverageFloor is 0.05", () => { + const issues = auditCoverageScene({ + text: "SUBSCRIBE", + hitCount: 2, + proseCoverageFloor: 0.05, + }); + expect(issues.some((issue) => issue.code === "text_occluded")).toBe(true); + }); + it("flags prose once coverage clears the 0.15 floor", () => { // 5/27 ≈ 0.185, comfortably over the ~0.15 prose floor. const issues = auditCoverageScene({ @@ -2042,6 +2062,7 @@ function occlusionProbePoints(textRect: RectInput): Array<{ x: number; y: number function auditCoverageScene(options: { text: string; hitCount: number; + proseCoverageFloor?: number; }): ReturnType { const textRect = { left: 200, top: 500, width: 600, height: 80 }; document.body.innerHTML = ` @@ -2065,7 +2086,11 @@ function auditCoverageScene(options: { return document.getElementById(isHit ? "overlay" : "headline"); }; installAuditScript(); - return runAudit(); + return runAudit( + options.proseCoverageFloor === undefined + ? undefined + : { proseCoverageFloor: options.proseCoverageFloor }, + ); } function auditOcclusionScene(options: { @@ -2318,13 +2343,17 @@ interface AuditIssue { coveredFraction?: number; } -function runAudit(): AuditIssue[] { +function runAudit(options?: { proseCoverageFloor?: number }): AuditIssue[] { const audit = ( window as unknown as { - __hyperframesLayoutAudit: (options: { time: number; tolerance: number }) => AuditIssue[]; + __hyperframesLayoutAudit: (options: { + time: number; + tolerance: number; + proseCoverageFloor?: number; + }) => AuditIssue[]; } ).__hyperframesLayoutAudit; - return audit({ time: 1, tolerance: 2 }); + return audit({ time: 1, tolerance: 2, ...options }); } function selectedRangeElement(selected: Node | null): Element | null { diff --git a/packages/cli/src/utils/checkBrowser.ts b/packages/cli/src/utils/checkBrowser.ts index c47cced9c..eb105e164 100644 --- a/packages/cli/src/utils/checkBrowser.ts +++ b/packages/cli/src/utils/checkBrowser.ts @@ -42,6 +42,7 @@ import type { ContrastAuditEntry, ContrastCapture, GeometryCandidateRequest, + LayoutOptions, MotionSpecResolution, OffPivotFrame, OffPivotRotationSample, @@ -353,7 +354,7 @@ function createPageDriver(page: Page, setTime: (time: number) => void): CheckAud setTime(time); await seekCompositionTimeline(page, time, DENSE_GEOMETRY_SEEK_OPTIONS); }, - collectLayout: (time, tolerance) => collectLayout(page, time, tolerance), + collectLayout: (time, tolerance, layout) => collectLayout(page, time, tolerance, layout), collectOverlap: (time) => collectOverlap(page, time), collectLayoutGeometry: () => collectLayoutGeometry(page), collectRotationSample: (time) => collectRotationSample(page, time), @@ -457,15 +458,22 @@ async function collectLayout( page: Page, time: number, tolerance: number, + layout?: LayoutOptions, ): Promise { const raw = await page.evaluate( - (options: { time: number; tolerance: number }) => { + (options: { time: number; tolerance: number; proseCoverageFloor?: number }) => { const audit = Reflect.get(window, "__hyperframesLayoutAudit"); if (typeof audit !== "function") return []; const result = Reflect.apply(audit, window, [options]); return Array.isArray(result) ? result : []; }, - { time, tolerance }, + { + time, + tolerance, + ...(typeof layout?.proseCoverageFloor === "number" + ? { proseCoverageFloor: layout.proseCoverageFloor } + : {}), + }, ); return anchorLayoutIssues(page, raw.flatMap(parseLayoutIssue)); } diff --git a/packages/cli/src/utils/checkPipeline.ts b/packages/cli/src/utils/checkPipeline.ts index 33cc0afbe..3f8fb0795 100644 --- a/packages/cli/src/utils/checkPipeline.ts +++ b/packages/cli/src/utils/checkPipeline.ts @@ -380,7 +380,7 @@ async function collectGridSamples( // that render time — never a stale bbox from an earlier/later sample. const issuesAtTime: AnchoredLayoutIssue[] = []; if (layoutSet.has(time)) { - const layoutIssues = await driver.collectLayout(time, options.tolerance); + const layoutIssues = await driver.collectLayout(time, options.tolerance, options.layout); collected.layoutIssues.push(...layoutIssues); issuesAtTime.push(...layoutIssues); collected.geometrySignatures.push(await driver.collectLayoutGeometry()); diff --git a/packages/cli/src/utils/checkTypes.ts b/packages/cli/src/utils/checkTypes.ts index c334787e3..54e5aee53 100644 --- a/packages/cli/src/utils/checkTypes.ts +++ b/packages/cli/src/utils/checkTypes.ts @@ -18,6 +18,8 @@ export interface CheckOptions { snapshots: boolean; captionZone?: CaptionZoneOptions; frameCheck?: FrameCheckOptions; + /** Opt-in layout-audit knobs (`--layout "proseCoverageFloor=0.05"`). Defaults stay in the browser audit. */ + layout?: LayoutOptions; /** Explicit --proxy/--no-proxy override; undefined preserves project config. */ autoProxy?: boolean; } @@ -37,6 +39,12 @@ export interface FrameCheckOptions { seek?: number[]; } +/** Layout-audit tuning passed through to `__hyperframesLayoutAudit`. All fields optional. */ +export interface LayoutOptions { + /** Prose `text_occluded` coveredFraction floor (0–1; default 0.15); atomic labels still flag at any hit. */ + proseCoverageFloor?: number; +} + export type CheckSeverity = "error" | "warning" | "info"; export interface CheckBbox { @@ -175,7 +183,11 @@ export interface CheckAuditDriver { seek(time: number): Promise; /** Settle-free seek for the geometry-only dense content_overlap pass; only collectOverlap consumes it, and getBoundingClientRect is valid synchronously after setTime. */ seekGeometry(time: number): Promise; - collectLayout(time: number, tolerance: number): Promise; + collectLayout( + time: number, + tolerance: number, + layout?: LayoutOptions, + ): Promise; /** content_overlap only, for the dense re-sampling grid — catches transient text collisions the sparse grid seeks past. */ collectOverlap(time: number): Promise; /** Frozen-sweep guard (#U10): an opaque per-sample geometry+opacity