Skip to content

Commit 45cc343

Browse files
authored
fix(cli): flag caption-zone by DOM box overlap (#3580)
A card centered at y=.860 can still cover the painted V2A pill. Intersect the element's getBoundingClientRect with the keepout instead of testing whether its center sits inside the band.
1 parent 38e356f commit 45cc343

5 files changed

Lines changed: 67 additions & 19 deletions

File tree

docs/packages/cli.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -624,7 +624,7 @@ drift), `*.motion.json` assertions, and WCAG AA contrast.
624624
| `--timeout` | Render-ready budget in ms; also raises page navigation above its 10s floor (default 3000) |
625625
| `--no-contrast` | Skip the WCAG pass while iterating |
626626
| `--strict` | Exit non-zero on warnings too (default: errors only) |
627-
| `--caption-zone "<x0=..;y0=..;x1=..;y1=..>"` | Opt-in band gate. Flags content whose centre sits inside the fractional band. Optional `severity` and `seek`. |
627+
| `--caption-zone "<x0=..;y0=..;x1=..;y1=..>"` | Opt-in band gate. Flags a text element's DOM box that overlaps the fractional band. Optional `severity` and `seek`. |
628628
| `--frame-check` | Opt-in out-of-frame detection for `img`, `svg`, `video`, and `canvas` |
629629
| `--layout` | Layout knobs, currently `proseCoverageFloor=0.05` (0–1, default 0.15) |
630630
| `--browser-gpu` / `--no-browser-gpu` | Hardware GPU capture, or deterministic SwiftShader (default: auto-detect) |

packages/cli/src/commands/check.test.ts

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -461,7 +461,7 @@ it("rejects malformed caption-zone specs instead of silently disabling the gate"
461461
});
462462
});
463463

464-
it("flags only text whose center is inside the caption band at the default end seek", async () => {
464+
it("flags text whose DOM box overlaps the caption band at the default end seek", async () => {
465465
const collectGeometryCandidates = vi.fn(async (time: number) => [
466466
geometryCandidate({
467467
kind: "text",
@@ -507,10 +507,58 @@ it("flags only text whose center is inside the caption band at the default end s
507507
text: "Centered title",
508508
time: 10,
509509
}),
510+
expect.objectContaining({
511+
code: "caption_zone_collision",
512+
severity: "warning",
513+
selector: "#overlap-only",
514+
text: "Overlap only",
515+
time: 10,
516+
}),
510517
]);
511518
expect(report.ok).toBe(true);
512519
});
513520

521+
it("rejects a 1920×1080 card centered at y=.860 that overlaps the 5% keepout", async () => {
522+
const collectGeometryCandidates = vi.fn(async (time: number) => [
523+
geometryCandidate({
524+
kind: "text",
525+
tag: "div",
526+
text: "Demand card",
527+
selector: "#at-860",
528+
rect: fixtureRect(200, 889, 400, 80),
529+
time,
530+
}),
531+
geometryCandidate({
532+
kind: "text",
533+
tag: "div",
534+
text: "Clear above keepout",
535+
selector: "#tiny-860",
536+
rect: fixtureRect(200, 919, 400, 20),
537+
time,
538+
}),
539+
]);
540+
const { report } = await runScenario(
541+
fakeDriver({
542+
getDuration: vi.fn(async () => 10),
543+
collectGeometryCandidates,
544+
}),
545+
{
546+
samples: 1,
547+
contrast: false,
548+
captionZone: { x0: 0.118, y0: 0.875, x1: 0.882, y1: 0.925, severity: "error" },
549+
},
550+
);
551+
552+
expect(report.layout.findings).toEqual([
553+
expect.objectContaining({
554+
code: "caption_zone_collision",
555+
severity: "error",
556+
selector: "#at-860",
557+
}),
558+
]);
559+
expect(report.ok).toBe(false);
560+
});
561+
514562
it("skips caption_zone_collision when data-layout-allow-caption-zone is set", async () => {
515563
const collectGeometryCandidates = vi.fn(async (time: number) => [
516564
geometryCandidate({
@@ -574,7 +622,7 @@ it("keeps overlap waivers from suppressing changelog caption-rail collisions", a
574622
expect(report.ok).toBe(false);
575623
});
576624

577-
it("filters caption candidates by the element box while centering the text rect", async () => {
625+
it("skips full-frame and tiny wrappers when measuring the caption box", async () => {
578626
const collectGeometryCandidates = vi.fn(async (time: number) => [
579627
geometryCandidate({
580628
kind: "text",

packages/cli/src/utils/checkPipeline.ts

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -240,19 +240,19 @@ function geometryIssueAnchor(candidate: CheckGeometryCandidate, time: number) {
240240
};
241241
}
242242

243-
function captionCenterInZone(
244-
rect: CheckGeometryCandidate["rect"],
243+
function captionBoxOverlapsZone(
244+
box: CheckGeometryCandidate["elementRect"],
245245
zone: NonNullable<CheckOptions["captionZone"]>,
246246
canvas: Canvas,
247-
): { inside: boolean; cy: number } {
248-
const cx = rect.left + rect.width / 2;
249-
const cy = rect.top + rect.height / 2;
250-
const inside =
251-
cx >= zone.x0 * canvas.width &&
252-
cx <= zone.x1 * canvas.width &&
253-
cy >= zone.y0 * canvas.height &&
254-
cy <= zone.y1 * canvas.height;
255-
return { inside, cy };
247+
): { overlaps: boolean; cy: number } {
248+
const zx0 = zone.x0 * canvas.width;
249+
const zy0 = zone.y0 * canvas.height;
250+
const zx1 = zone.x1 * canvas.width;
251+
const zy1 = zone.y1 * canvas.height;
252+
const bx1 = box.left + box.width;
253+
const by1 = box.top + box.height;
254+
const overlaps = box.left < zx1 && bx1 > zx0 && box.top < zy1 && by1 > zy0;
255+
return { overlaps, cy: box.top + box.height / 2 };
256256
}
257257

258258
function captionFinding(
@@ -265,8 +265,8 @@ function captionFinding(
265265
if (!zone || candidate.kind !== "text" || !candidateIsSized(candidate, canvas)) return null;
266266
// Backstop for mocks/non-browser sources; browser already strips via closest() (own attrs only here).
267267
if ("data-layout-allow-caption-zone" in candidate.dataAttributes) return null;
268-
const { inside, cy } = captionCenterInZone(candidate.rect, zone, canvas);
269-
if (!inside) return null;
268+
const { overlaps, cy } = captionBoxOverlapsZone(candidate.elementRect, zone, canvas);
269+
if (!overlaps) return null;
270270
const text = candidate.text.slice(0, 48);
271271
const pctFromBottom = Math.round(((canvas.height - cy) / canvas.height) * 100);
272272
return {
@@ -276,7 +276,7 @@ function captionFinding(
276276
code: "caption_zone_collision",
277277
severity: zone.severity === "error" ? "error" : "warning",
278278
text,
279-
message: `<${candidate.tag}> "${text}" is centred in the reserved caption band (~${pctFromBottom}% up from the bottom).`,
279+
message: `<${candidate.tag}> "${text}" overlaps the reserved caption band (~${pctFromBottom}% up from the bottom).`,
280280
fixHint:
281281
"Keep main content outside the configured caption band, or mark intentional lower-third copy with data-layout-allow-caption-zone.",
282282
},

skills-manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
"files": 7
3131
},
3232
"hyperframes-cli": {
33-
"hash": "5d02a1713635e7e7",
33+
"hash": "70bf0363795a160b",
3434
"files": 11
3535
},
3636
"hyperframes-core": {

skills/hyperframes-cli/references/lint-validate-inspect.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ npx hyperframes check --caption-zone "x0=0;y0=.82;x1=1;y1=1;severity=error;seek=
6767
npx hyperframes check --frame-check # media (img/svg/video/canvas) out-of-frame detection
6868
```
6969

70-
`--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)`.
70+
`--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)`.
7171

7272
**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`.
7373

0 commit comments

Comments
 (0)