Skip to content

Commit abaf671

Browse files
feat(cli): flag overlapping text blocks in inspect (#1436)
The layout audit compares each element against its container, so two text blocks that collide with each other — neither overflowing its own box — render unreadable yet pass clean. Add a content_overlap check that pairs up the solid text blocks and reports any two whose boxes intersect by more than a fifth of the smaller box. Watermark-style text (low colour alpha) is decorative and exempt; opt out of intentional stacking with data-layout-allow-overlap. Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
1 parent 2002aa2 commit abaf671

4 files changed

Lines changed: 185 additions & 4 deletions

File tree

docs/packages/cli.mdx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ npx hyperframes <command>
1919
- Preview compositions with live hot reload (`preview`)
2020
- Render compositions to MP4 locally or in Docker (`render`)
2121
- Lint compositions for structural issues (`lint`)
22-
- Inspect rendered visual layout for text overflow and clipped containers (`inspect`)
22+
- Inspect rendered visual layout for text overflow, clipped containers, and overlapping text (`inspect`)
2323
- Capture key frames as PNG screenshots (`snapshot`)
2424
- Check your environment for missing dependencies (`doctor`)
2525

@@ -559,7 +559,7 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_
559559
◇ 1 error(s), 0 warning(s), 0 info(s)
560560
```
561561

562-
`inspect` bundles the project, serves it locally, opens headless Chrome, seeks through the composition, and reports text or elements that escape their intended boxes. It is designed for agent workflows: each finding includes a schema version, timestamp or collapsed timestamp range, selector, nearest container selector, measured bounding boxes, overflow sides, and a fix hint.
562+
`inspect` bundles the project, serves it locally, opens headless Chrome, seeks through the composition, and reports text or elements that escape their intended boxes, plus pairs of text blocks that overlap each other (`content_overlap`). It is designed for agent workflows: each finding includes a schema version, timestamp or collapsed timestamp range, selector, nearest container selector, measured bounding boxes, overflow sides, and a fix hint.
563563

564564
| Flag | Description |
565565
|------|-------------|
@@ -572,7 +572,7 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_
572572
| `--max-issues` | Maximum findings to print or return after static collapse (default: 80) |
573573
| `--strict` | Exit non-zero on warnings as well as errors |
574574

575-
Use `data-layout-allow-overflow` on an element or ancestor when overflow is intentional, such as a planned off-canvas entrance. Use `data-layout-ignore` for decorative elements that should not be audited.
575+
Use `data-layout-allow-overflow` on an element or ancestor when overflow is intentional, such as a planned off-canvas entrance. Use `data-layout-ignore` for decorative elements that should not be audited. Use `data-layout-allow-overlap` on a text element that is intentionally stacked over another (for example a lower-third caption above a heading).
576576

577577
`layout` remains available as a compatibility alias for the same visual inspection pass:
578578

packages/cli/src/commands/layout-audit.browser.js

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,91 @@
398398
return issues;
399399
}
400400

401+
function hasAllowOverlapFlag(element) {
402+
return !!element.closest("[data-layout-allow-overlap]");
403+
}
404+
405+
function alphaFromParts(parts, index) {
406+
return parts.length > index ? parsePx(parts[index]) : 1;
407+
}
408+
409+
// Alpha of a CSS colour; 1 when no alpha component is present. Handles both
410+
// legacy `rgba(r, g, b, a)` and modern `rgb(r g b / a)` syntaxes.
411+
function colorAlpha(color) {
412+
const match = (color || "").match(/rgba?\(([^)]+)\)/);
413+
if (!match) return 1;
414+
const body = match[1];
415+
return body.includes(",")
416+
? alphaFromParts(body.split(","), 3)
417+
: alphaFromParts(body.split("/"), 1);
418+
}
419+
420+
// A text block competes for space only when it is solid: watermark-style text
421+
// (low colour alpha) is decorative and exempt, as are elements opted out with
422+
// data-layout-allow-overlap.
423+
function isSolidTextBlock(element) {
424+
if (!isVisibleElement(element) || !hasOwnTextCandidate(element)) return false;
425+
if (hasAllowOverlapFlag(element)) return false;
426+
return colorAlpha(getComputedStyle(element).color) >= 0.35;
427+
}
428+
429+
function collectSolidTextBlocks(root) {
430+
const blocks = [];
431+
for (const element of Array.from(root.querySelectorAll("*"))) {
432+
if (!isSolidTextBlock(element)) continue;
433+
const rect = textRectFor(element);
434+
if (rect) blocks.push({ element, rect });
435+
}
436+
return blocks;
437+
}
438+
439+
function rectArea(rect) {
440+
return rect.width * rect.height;
441+
}
442+
443+
function intersectionArea(a, b) {
444+
const overlapX = Math.min(a.right, b.right) - Math.max(a.left, b.left);
445+
const overlapY = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top);
446+
return overlapX > 0 && overlapY > 0 ? overlapX * overlapY : 0;
447+
}
448+
449+
function isNested(a, b) {
450+
return a.contains(b) || b.contains(a);
451+
}
452+
453+
// Two solid text blocks whose boxes overlap by more than a fifth of the
454+
// smaller block read as a collision — unreadable, and invisible to the
455+
// overflow checks, which only compare an element against its container.
456+
function overlapIssue(a, b, time) {
457+
if (isNested(a.element, b.element)) return null;
458+
const area = intersectionArea(a.rect, b.rect);
459+
if (area <= Math.min(rectArea(a.rect), rectArea(b.rect)) * 0.2) return null;
460+
return {
461+
code: "content_overlap",
462+
severity: "error",
463+
time,
464+
selector: selectorFor(a.element),
465+
containerSelector: selectorFor(b.element),
466+
text: textContentFor(a.element),
467+
message: "Two text blocks overlap and render unreadable.",
468+
rect: a.rect,
469+
fixHint:
470+
"Give each block its own zone, or mark intentional layering with data-layout-allow-overlap.",
471+
};
472+
}
473+
474+
function contentOverlapIssues(root, time) {
475+
const blocks = collectSolidTextBlocks(root);
476+
const issues = [];
477+
for (let i = 0; i < blocks.length; i++) {
478+
for (let j = i + 1; j < blocks.length; j++) {
479+
const issue = overlapIssue(blocks[i], blocks[j], time);
480+
if (issue) issues.push(issue);
481+
}
482+
}
483+
return issues;
484+
}
485+
401486
window.__hyperframesLayoutAudit = function auditLayout(options) {
402487
const time = options && typeof options.time === "number" ? options.time : 0;
403488
const tolerance =
@@ -418,6 +503,7 @@
418503
}
419504

420505
issues.push(...containerOverflowIssues(root, time, tolerance));
506+
issues.push(...contentOverlapIssues(root, time));
421507
return issues;
422508
};
423509
})();

packages/cli/src/commands/layout-audit.browser.test.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,100 @@ describe("layout-audit.browser", () => {
100100
});
101101
});
102102

103+
describe("layout-audit.browser content overlap", () => {
104+
afterEach(() => {
105+
vi.restoreAllMocks();
106+
document.body.innerHTML = "";
107+
delete (window as unknown as { __hyperframesLayoutAudit?: unknown }).__hyperframesLayoutAudit;
108+
});
109+
110+
it("flags two solid text blocks that overlap", () => {
111+
const overlap = auditOverlapScene({
112+
a: { textRect: rect({ left: 100, top: 100, width: 400, height: 100 }) },
113+
b: { textRect: rect({ left: 300, top: 120, width: 400, height: 100 }) },
114+
}).find((issue) => issue.code === "content_overlap");
115+
expect(overlap).toMatchObject({ selector: "#a", containerSelector: "#b" });
116+
});
117+
118+
it("ignores blocks that overlap by less than a fifth of the smaller box", () => {
119+
const issues = auditOverlapScene({
120+
a: { textRect: rect({ left: 100, top: 100, width: 400, height: 100 }) },
121+
b: { textRect: rect({ left: 490, top: 100, width: 400, height: 100 }) },
122+
});
123+
expect(issues.some((issue) => issue.code === "content_overlap")).toBe(false);
124+
});
125+
126+
it("ignores watermark-style text with low colour alpha", () => {
127+
expectExemptFromOverlap({ color: "rgba(0, 0, 0, 0.2)" });
128+
});
129+
130+
it("respects the data-layout-allow-overlap opt-out", () => {
131+
expectExemptFromOverlap({ attrs: "data-layout-allow-overlap" });
132+
});
133+
});
134+
135+
// Both blocks overlap heavily; only the exemption on block A should suppress
136+
// the finding, so a missing exemption would surface as a failure here.
137+
function expectExemptFromOverlap(aOverrides: { color?: string; attrs?: string }): void {
138+
const issues = auditOverlapScene({
139+
a: { textRect: rect({ left: 100, top: 100, width: 400, height: 100 }), ...aOverrides },
140+
b: { textRect: rect({ left: 300, top: 120, width: 400, height: 100 }) },
141+
});
142+
expect(issues.some((issue) => issue.code === "content_overlap")).toBe(false);
143+
}
144+
145+
function auditOverlapScene(options: {
146+
a: { textRect: DOMRect; color?: string; attrs?: string };
147+
b: { textRect: DOMRect; color?: string; attrs?: string };
148+
}): ReturnType<typeof runAudit> {
149+
document.body.innerHTML = `
150+
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
151+
<div id="a" ${options.a.attrs ?? ""}>Block A copy</div>
152+
<div id="b" ${options.b.attrs ?? ""}>Block B copy</div>
153+
</div>
154+
`;
155+
const colors: Record<string, string> = {
156+
a: options.a.color ?? "rgb(0, 0, 0)",
157+
b: options.b.color ?? "rgb(0, 0, 0)",
158+
};
159+
const textRects: Record<string, DOMRect> = { a: options.a.textRect, b: options.b.textRect };
160+
161+
vi.spyOn(window, "getComputedStyle").mockImplementation((element) => {
162+
const id = (element as Element).id;
163+
return {
164+
display: "block",
165+
visibility: "visible",
166+
opacity: "1",
167+
color: colors[id] ?? "rgb(0, 0, 0)",
168+
} as unknown as CSSStyleDeclaration;
169+
});
170+
171+
for (const element of Array.from(document.querySelectorAll("*"))) {
172+
vi.spyOn(element, "getBoundingClientRect").mockReturnValue(
173+
textRects[element.id] ?? rect({ left: 0, top: 0, width: 1920, height: 1080 }),
174+
);
175+
}
176+
177+
vi.spyOn(document, "createRange").mockImplementation(() => {
178+
let selected: Node | null = null;
179+
return {
180+
selectNodeContents(node: Node) {
181+
selected = node;
182+
},
183+
getClientRects() {
184+
const id = (selected as Element | null)?.id ?? "";
185+
return textRects[id]
186+
? ([textRects[id]] as unknown as DOMRectList)
187+
: ([] as unknown as DOMRectList);
188+
},
189+
detach() {},
190+
} as unknown as Range;
191+
});
192+
193+
installAuditScript();
194+
return runAudit();
195+
}
196+
103197
function installAuditScript(): void {
104198
window.eval(script);
105199
}

packages/cli/src/utils/layoutAudit.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ export type LayoutIssueCode =
1313
| "text_box_overflow"
1414
| "clipped_text"
1515
| "canvas_overflow"
16-
| "container_overflow";
16+
| "container_overflow"
17+
| "content_overlap";
1718

1819
export type LayoutIssueSeverity = "error" | "warning" | "info";
1920

0 commit comments

Comments
 (0)