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>
This commit is contained in:
Leopold T
2026-06-14 22:28:08 -07:00
committed by GitHub
co-authored by Miguel Ángel
parent 2002aa2c07
commit abaf67176c
4 changed files with 185 additions and 4 deletions
@@ -398,6 +398,91 @@
return issues;
}
function hasAllowOverlapFlag(element) {
return !!element.closest("[data-layout-allow-overlap]");
}
function alphaFromParts(parts, index) {
return parts.length > index ? parsePx(parts[index]) : 1;
}
// Alpha of a CSS colour; 1 when no alpha component is present. Handles both
// legacy `rgba(r, g, b, a)` and modern `rgb(r g b / a)` syntaxes.
function colorAlpha(color) {
const match = (color || "").match(/rgba?\(([^)]+)\)/);
if (!match) return 1;
const body = match[1];
return body.includes(",")
? alphaFromParts(body.split(","), 3)
: alphaFromParts(body.split("/"), 1);
}
// A text block competes for space only when it is solid: watermark-style text
// (low colour alpha) is decorative and exempt, as are elements opted out with
// data-layout-allow-overlap.
function isSolidTextBlock(element) {
if (!isVisibleElement(element) || !hasOwnTextCandidate(element)) return false;
if (hasAllowOverlapFlag(element)) return false;
return colorAlpha(getComputedStyle(element).color) >= 0.35;
}
function collectSolidTextBlocks(root) {
const blocks = [];
for (const element of Array.from(root.querySelectorAll("*"))) {
if (!isSolidTextBlock(element)) continue;
const rect = textRectFor(element);
if (rect) blocks.push({ element, rect });
}
return blocks;
}
function rectArea(rect) {
return rect.width * rect.height;
}
function intersectionArea(a, b) {
const overlapX = Math.min(a.right, b.right) - Math.max(a.left, b.left);
const overlapY = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top);
return overlapX > 0 && overlapY > 0 ? overlapX * overlapY : 0;
}
function isNested(a, b) {
return a.contains(b) || b.contains(a);
}
// Two solid text blocks whose boxes overlap by more than a fifth of the
// smaller block read as a collision — unreadable, and invisible to the
// overflow checks, which only compare an element against its container.
function overlapIssue(a, b, time) {
if (isNested(a.element, b.element)) return null;
const area = intersectionArea(a.rect, b.rect);
if (area <= Math.min(rectArea(a.rect), rectArea(b.rect)) * 0.2) return null;
return {
code: "content_overlap",
severity: "error",
time,
selector: selectorFor(a.element),
containerSelector: selectorFor(b.element),
text: textContentFor(a.element),
message: "Two text blocks overlap and render unreadable.",
rect: a.rect,
fixHint:
"Give each block its own zone, or mark intentional layering with data-layout-allow-overlap.",
};
}
function contentOverlapIssues(root, time) {
const blocks = collectSolidTextBlocks(root);
const issues = [];
for (let i = 0; i < blocks.length; i++) {
for (let j = i + 1; j < blocks.length; j++) {
const issue = overlapIssue(blocks[i], blocks[j], time);
if (issue) issues.push(issue);
}
}
return issues;
}
window.__hyperframesLayoutAudit = function auditLayout(options) {
const time = options && typeof options.time === "number" ? options.time : 0;
const tolerance =
@@ -418,6 +503,7 @@
}
issues.push(...containerOverflowIssues(root, time, tolerance));
issues.push(...contentOverlapIssues(root, time));
return issues;
};
})();
@@ -100,6 +100,100 @@ describe("layout-audit.browser", () => {
});
});
describe("layout-audit.browser content overlap", () => {
afterEach(() => {
vi.restoreAllMocks();
document.body.innerHTML = "";
delete (window as unknown as { __hyperframesLayoutAudit?: unknown }).__hyperframesLayoutAudit;
});
it("flags two solid text blocks that overlap", () => {
const overlap = auditOverlapScene({
a: { textRect: rect({ left: 100, top: 100, width: 400, height: 100 }) },
b: { textRect: rect({ left: 300, top: 120, width: 400, height: 100 }) },
}).find((issue) => issue.code === "content_overlap");
expect(overlap).toMatchObject({ selector: "#a", containerSelector: "#b" });
});
it("ignores blocks that overlap by less than a fifth of the smaller box", () => {
const issues = auditOverlapScene({
a: { textRect: rect({ left: 100, top: 100, width: 400, height: 100 }) },
b: { textRect: rect({ left: 490, top: 100, width: 400, height: 100 }) },
});
expect(issues.some((issue) => issue.code === "content_overlap")).toBe(false);
});
it("ignores watermark-style text with low colour alpha", () => {
expectExemptFromOverlap({ color: "rgba(0, 0, 0, 0.2)" });
});
it("respects the data-layout-allow-overlap opt-out", () => {
expectExemptFromOverlap({ attrs: "data-layout-allow-overlap" });
});
});
// Both blocks overlap heavily; only the exemption on block A should suppress
// the finding, so a missing exemption would surface as a failure here.
function expectExemptFromOverlap(aOverrides: { color?: string; attrs?: string }): void {
const issues = auditOverlapScene({
a: { textRect: rect({ left: 100, top: 100, width: 400, height: 100 }), ...aOverrides },
b: { textRect: rect({ left: 300, top: 120, width: 400, height: 100 }) },
});
expect(issues.some((issue) => issue.code === "content_overlap")).toBe(false);
}
function auditOverlapScene(options: {
a: { textRect: DOMRect; color?: string; attrs?: string };
b: { textRect: DOMRect; color?: string; attrs?: string };
}): ReturnType<typeof runAudit> {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
<div id="a" ${options.a.attrs ?? ""}>Block A copy</div>
<div id="b" ${options.b.attrs ?? ""}>Block B copy</div>
</div>
`;
const colors: Record<string, string> = {
a: options.a.color ?? "rgb(0, 0, 0)",
b: options.b.color ?? "rgb(0, 0, 0)",
};
const textRects: Record<string, DOMRect> = { a: options.a.textRect, b: options.b.textRect };
vi.spyOn(window, "getComputedStyle").mockImplementation((element) => {
const id = (element as Element).id;
return {
display: "block",
visibility: "visible",
opacity: "1",
color: colors[id] ?? "rgb(0, 0, 0)",
} as unknown as CSSStyleDeclaration;
});
for (const element of Array.from(document.querySelectorAll("*"))) {
vi.spyOn(element, "getBoundingClientRect").mockReturnValue(
textRects[element.id] ?? rect({ left: 0, top: 0, width: 1920, height: 1080 }),
);
}
vi.spyOn(document, "createRange").mockImplementation(() => {
let selected: Node | null = null;
return {
selectNodeContents(node: Node) {
selected = node;
},
getClientRects() {
const id = (selected as Element | null)?.id ?? "";
return textRects[id]
? ([textRects[id]] as unknown as DOMRectList)
: ([] as unknown as DOMRectList);
},
detach() {},
} as unknown as Range;
});
installAuditScript();
return runAudit();
}
function installAuditScript(): void {
window.eval(script);
}
+2 -1
View File
@@ -13,7 +13,8 @@ export type LayoutIssueCode =
| "text_box_overflow"
| "clipped_text"
| "canvas_overflow"
| "container_overflow";
| "container_overflow"
| "content_overlap";
export type LayoutIssueSeverity = "error" | "warning" | "info";