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.
This commit is contained in:
Xuanru Li
2026-09-01 04:41:57 +00:00
committed by GitHub
parent 38e356fba4
commit 45cc343525
5 changed files with 67 additions and 19 deletions
+1 -1
View File
@@ -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 "<x0=..;y0=..;x1=..;y1=..>"` | Opt-in band gate. Flags content whose centre sits inside the fractional band. Optional `severity` and `seek`. |
| `--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`. |
| `--frame-check` | Opt-in out-of-frame detection for `img`, `svg`, `video`, and `canvas` |
| `--layout` | Layout knobs, currently `proseCoverageFloor=0.05` (01, default 0.15) |
| `--browser-gpu` / `--no-browser-gpu` | Hardware GPU capture, or deterministic SwiftShader (default: auto-detect) |
+50 -2
View File
@@ -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",
+14 -14
View File
@@ -240,19 +240,19 @@ function geometryIssueAnchor(candidate: CheckGeometryCandidate, time: number) {
};
}
function captionCenterInZone(
rect: CheckGeometryCandidate["rect"],
function captionBoxOverlapsZone(
box: CheckGeometryCandidate["elementRect"],
zone: NonNullable<CheckOptions["captionZone"]>,
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.",
},
+1 -1
View File
@@ -30,7 +30,7 @@
"files": 7
},
"hyperframes-cli": {
"hash": "5d02a1713635e7e7",
"hash": "70bf0363795a160b",
"files": 11
},
"hyperframes-core": {
@@ -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`.