diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index 510902927..5c5eb3e7b 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -624,7 +624,7 @@ drift), `*.motion.json` assertions, and WCAG AA contrast. | `--timeout` | Render-ready budget in ms; also raises page navigation above its 10s floor (default 3000) | | `--no-contrast` | Skip the WCAG pass while iterating | | `--strict` | Exit non-zero on warnings too (default: errors only) | -| `--caption-zone ""` | Opt-in band gate. Flags content whose centre sits inside the fractional band. Optional `severity` and `seek`. | +| `--caption-zone ""` | Opt-in band gate. Flags a text element's DOM box that overlaps the fractional band. Optional `severity` and `seek`. | | `--frame-check` | Opt-in out-of-frame detection for `img`, `svg`, `video`, and `canvas` | | `--layout` | Layout knobs, currently `proseCoverageFloor=0.05` (0–1, default 0.15) | | `--browser-gpu` / `--no-browser-gpu` | Hardware GPU capture, or deterministic SwiftShader (default: auto-detect) | diff --git a/packages/cli/src/commands/check.test.ts b/packages/cli/src/commands/check.test.ts index d9f6e62f6..ed6224da0 100644 --- a/packages/cli/src/commands/check.test.ts +++ b/packages/cli/src/commands/check.test.ts @@ -461,7 +461,7 @@ it("rejects malformed caption-zone specs instead of silently disabling the gate" }); }); -it("flags only text whose center is inside the caption band at the default end seek", async () => { +it("flags text whose DOM box overlaps the caption band at the default end seek", async () => { const collectGeometryCandidates = vi.fn(async (time: number) => [ geometryCandidate({ kind: "text", @@ -507,10 +507,58 @@ it("flags only text whose center is inside the caption band at the default end s text: "Centered title", time: 10, }), + expect.objectContaining({ + code: "caption_zone_collision", + severity: "warning", + selector: "#overlap-only", + text: "Overlap only", + time: 10, + }), ]); expect(report.ok).toBe(true); }); +it("rejects a 1920×1080 card centered at y=.860 that overlaps the 5% keepout", async () => { + const collectGeometryCandidates = vi.fn(async (time: number) => [ + geometryCandidate({ + kind: "text", + tag: "div", + text: "Demand card", + selector: "#at-860", + rect: fixtureRect(200, 889, 400, 80), + time, + }), + geometryCandidate({ + kind: "text", + tag: "div", + text: "Clear above keepout", + selector: "#tiny-860", + rect: fixtureRect(200, 919, 400, 20), + time, + }), + ]); + const { report } = await runScenario( + fakeDriver({ + getDuration: vi.fn(async () => 10), + collectGeometryCandidates, + }), + { + samples: 1, + contrast: false, + captionZone: { x0: 0.118, y0: 0.875, x1: 0.882, y1: 0.925, severity: "error" }, + }, + ); + + expect(report.layout.findings).toEqual([ + expect.objectContaining({ + code: "caption_zone_collision", + severity: "error", + selector: "#at-860", + }), + ]); + expect(report.ok).toBe(false); +}); + it("skips caption_zone_collision when data-layout-allow-caption-zone is set", async () => { const collectGeometryCandidates = vi.fn(async (time: number) => [ geometryCandidate({ @@ -574,7 +622,7 @@ it("keeps overlap waivers from suppressing changelog caption-rail collisions", a expect(report.ok).toBe(false); }); -it("filters caption candidates by the element box while centering the text rect", async () => { +it("skips full-frame and tiny wrappers when measuring the caption box", async () => { const collectGeometryCandidates = vi.fn(async (time: number) => [ geometryCandidate({ kind: "text", diff --git a/packages/cli/src/utils/checkPipeline.ts b/packages/cli/src/utils/checkPipeline.ts index da21ecde5..3aca7a375 100644 --- a/packages/cli/src/utils/checkPipeline.ts +++ b/packages/cli/src/utils/checkPipeline.ts @@ -240,19 +240,19 @@ function geometryIssueAnchor(candidate: CheckGeometryCandidate, time: number) { }; } -function captionCenterInZone( - rect: CheckGeometryCandidate["rect"], +function captionBoxOverlapsZone( + box: CheckGeometryCandidate["elementRect"], zone: NonNullable, canvas: Canvas, -): { inside: boolean; cy: number } { - const cx = rect.left + rect.width / 2; - const cy = rect.top + 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; - return { inside, cy }; +): { overlaps: boolean; cy: number } { + const zx0 = zone.x0 * canvas.width; + const zy0 = zone.y0 * canvas.height; + const zx1 = zone.x1 * canvas.width; + const zy1 = zone.y1 * canvas.height; + const bx1 = box.left + box.width; + const by1 = box.top + box.height; + const overlaps = box.left < zx1 && bx1 > zx0 && box.top < zy1 && by1 > zy0; + return { overlaps, cy: box.top + box.height / 2 }; } function captionFinding( @@ -265,8 +265,8 @@ function captionFinding( if (!zone || candidate.kind !== "text" || !candidateIsSized(candidate, canvas)) return null; // Backstop for mocks/non-browser sources; browser already strips via closest() (own attrs only here). if ("data-layout-allow-caption-zone" in candidate.dataAttributes) return null; - const { inside, cy } = captionCenterInZone(candidate.rect, zone, canvas); - if (!inside) return null; + const { overlaps, cy } = captionBoxOverlapsZone(candidate.elementRect, zone, canvas); + if (!overlaps) return null; const text = candidate.text.slice(0, 48); const pctFromBottom = Math.round(((canvas.height - cy) / canvas.height) * 100); return { @@ -276,7 +276,7 @@ function captionFinding( 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).`, + message: `<${candidate.tag}> "${text}" overlaps the reserved caption band (~${pctFromBottom}% up from the bottom).`, fixHint: "Keep main content outside the configured caption band, or mark intentional lower-third copy with data-layout-allow-caption-zone.", }, diff --git a/skills-manifest.json b/skills-manifest.json index 149190844..10f117edc 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -30,7 +30,7 @@ "files": 7 }, "hyperframes-cli": { - "hash": "5d02a1713635e7e7", + "hash": "70bf0363795a160b", "files": 11 }, "hyperframes-core": { diff --git a/skills/hyperframes-cli/references/lint-validate-inspect.md b/skills/hyperframes-cli/references/lint-validate-inspect.md index c08cd91bb..2cb92660f 100644 --- a/skills/hyperframes-cli/references/lint-validate-inspect.md +++ b/skills/hyperframes-cli/references/lint-validate-inspect.md @@ -67,7 +67,7 @@ npx hyperframes check --caption-zone "x0=0;y0=.82;x1=1;y1=1;severity=error;seek= npx hyperframes check --frame-check # media (img/svg/video/canvas) out-of-frame detection ``` -`--caption-zone` takes fractional band geometry (`x0/y0/x1/y1` required, 0-1 fractions of the composition's own canvas, portrait included) with optional `severity` and comma-separated `seek` fractions; it flags content whose center sits inside the band. Waive intentional lower-third copy with `data-layout-allow-caption-zone` on the element or its nearest wrapper (see Escape hatches). `--frame-check` reports media elements breaching the canvas beyond `max(120px, 6% of the min canvas dimension)`. +`--caption-zone` takes fractional band geometry (`x0/y0/x1/y1` required, 0-1 fractions of the composition's own canvas, portrait included) with optional `severity` and comma-separated `seek` fractions; it flags a text element's DOM box (`getBoundingClientRect`) that overlaps the band. Waive intentional lower-third copy with `data-layout-allow-caption-zone` on the element or its nearest wrapper (see Escape hatches). `--frame-check` reports media elements breaching the canvas beyond `max(120px, 6% of the min canvas dimension)`. **Fixing contrast errors** — thresholds are 4.5:1 for normal text, 3:1 for large text (24px+, or 19px+ bold). The finding's `suggestedColor` already picks the nearest compliant color in the right direction (brighten on dark backgrounds, darken on light); apply it or adjust within the palette family, then re-run `check`.