fix(cli): avoid multiline overlap false positives (#2468)

* fix(cli): avoid multiline overlap false positives

* fix(cli): sample multiline occlusion fragments
This commit is contained in:
Miguel Ángel
2026-07-15 02:32:25 -04:00
committed by GitHub
parent d2aa4f8bc4
commit c54bf13fb4
2 changed files with 102 additions and 24 deletions
@@ -580,8 +580,9 @@
const blocks = [];
for (const element of Array.from(root.querySelectorAll("*"))) {
if (!isSolidTextBlock(element)) continue;
const rects = textClientRects(element, true);
const rect = textRectFor(element, true);
if (rect) blocks.push({ element, rect });
if (rect) blocks.push({ element, rect, rects });
}
return blocks;
}
@@ -596,6 +597,18 @@
return overlapX > 0 && overlapY > 0 ? overlapX * overlapY : 0;
}
function rectsArea(rects) {
return rects.reduce((total, rect) => total + rectArea(rect), 0);
}
function fragmentIntersectionArea(a, b) {
let total = 0;
for (const aRect of a) {
for (const bRect of b) total += intersectionArea(aRect, bRect);
}
return total;
}
function isNested(a, b) {
return a.contains(b) || b.contains(a);
}
@@ -631,8 +644,8 @@
function overlapIssue(a, b, time) {
if (isNested(a.element, b.element)) return null;
if (isManagedFlowOverlap(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;
const area = fragmentIntersectionArea(a.rects, b.rects);
if (area <= Math.min(rectsArea(a.rects), rectsArea(b.rects)) * 0.2) return null;
return {
// Warning at the per-sample level: a single-sample overlap is usually an
// entrance/exit transient (two blocks crossing mid-animation), not a real
@@ -928,25 +941,32 @@
return text.length > 0 && text.length <= ATOMIC_LABEL_MAX_CHARS && !/\s/.test(text);
}
// Sweep a grid across the text box (three rows, not just the mid-line, so
// overlays covering only part of a multi-line block are caught). Unlike a
// Sweep a grid across each painted text fragment (three rows, not just the
// mid-line, so overlays covering only part of a multi-line block are caught).
// Sampling fragments instead of their union avoids probing empty line gaps.
// Unlike a
// first-hit scan, this keeps sampling every point so it can report what
// fraction of the box is actually covered — a corner nibble on a paragraph
// reads very differently from a label buried under an overlay. Still
// returns the first opaque element found, for `containerSelector`.
function occlusionCoverage(element, textRect) {
function occlusionCoverage(element, textRects) {
let occluder = null;
let hits = 0;
for (const yFraction of OCCLUSION_PROBE_Y_FRACTIONS) {
const y = textRect.top + textRect.height * yFraction;
for (const xFraction of OCCLUSION_PROBE_X_FRACTIONS) {
const hit = occluderAt(element, textRect.left + textRect.width * xFraction, y);
if (!hit) continue;
hits += 1;
if (!occluder) occluder = hit;
for (const textRect of textRects) {
for (const yFraction of OCCLUSION_PROBE_Y_FRACTIONS) {
const y = textRect.top + textRect.height * yFraction;
for (const xFraction of OCCLUSION_PROBE_X_FRACTIONS) {
const hit = occluderAt(element, textRect.left + textRect.width * xFraction, y);
if (!hit) continue;
hits += 1;
if (!occluder) occluder = hit;
}
}
}
return { occluder, coveredFraction: round(hits / OCCLUSION_GRID_POINTS) };
return {
occluder,
coveredFraction: round(hits / (OCCLUSION_GRID_POINTS * textRects.length)),
};
}
// pointer-events:none hides elements from elementFromPoint — both probed text AND occluders.
@@ -985,8 +1005,12 @@
if (!hasVisibleTextInk(element)) return null;
const textRect = textRectFor(element, true);
if (!textRect) return null;
const textRects = textClientRects(element, true);
const text = textContentFor(element, true);
const { occluder, coveredFraction } = occlusionCoverage(element, textRect);
const { occluder, coveredFraction } = occlusionCoverage(
element,
textRects.length > 0 ? textRects : [textRect],
);
if (!occluder) return null;
if (!isAtomicLabel(text) && coveredFraction < PROSE_COVERAGE_FLOOR) return null;
return {
@@ -861,6 +861,20 @@ describe("layout-audit.browser content overlap", () => {
expect(issues.some((issue) => issue.code === "content_overlap")).toBe(false);
});
it("ignores another block placed only in a multiline text block's empty line gap", () => {
const issues = auditOverlapScene({
a: {
textRect: [
rect({ left: 100, top: 100, width: 400, height: 60 }),
rect({ left: 100, top: 260, width: 400, height: 60 }),
],
},
b: { textRect: rect({ left: 180, top: 180, width: 240, height: 50 }) },
});
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)" });
});
@@ -1283,8 +1297,8 @@ function expectExemptFromOverlap(aOverrides: { color?: string; attrs?: string })
}
function auditOverlapScene(options: {
a: { textRect: DOMRect; color?: string; attrs?: string; clipPath?: string };
b: { textRect: DOMRect; color?: string; attrs?: string; clipPath?: string };
a: { textRect: DOMRect | DOMRect[]; color?: string; attrs?: string; clipPath?: string };
b: { textRect: DOMRect | DOMRect[]; color?: string; attrs?: string; clipPath?: string };
}): ReturnType<typeof runAudit> {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
@@ -1300,7 +1314,10 @@ function auditOverlapScene(options: {
a: options.a.clipPath ?? "none",
b: options.b.clipPath ?? "none",
};
const textRects: Record<string, DOMRect> = { a: options.a.textRect, b: options.b.textRect };
const textRects: Record<string, DOMRect[]> = {
a: normalizeTextRects(options.a.textRect),
b: normalizeTextRects(options.b.textRect),
};
vi.spyOn(window, "getComputedStyle").mockImplementation((element) => {
const id = (element as Element).id;
@@ -1323,7 +1340,8 @@ function auditOverlapScene(options: {
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 }),
boundingTextRect(textRects[element.id]) ??
rect({ left: 0, top: 0, width: 1920, height: 1080 }),
);
}
@@ -1339,9 +1357,7 @@ function auditOverlapScene(options: {
? selected.parentElement
: (selected as Element | null);
const id = element?.id ?? "";
return textRects[id]
? ([textRects[id]] as unknown as DOMRectList)
: ([] as unknown as DOMRectList);
return (textRects[id] ?? []) as unknown as DOMRectList;
},
detach() {},
} as unknown as Range;
@@ -1351,6 +1367,19 @@ function auditOverlapScene(options: {
return runAudit();
}
function normalizeTextRects(value: DOMRect | DOMRect[]): DOMRect[] {
return Array.isArray(value) ? value : [value];
}
function boundingTextRect(rects: DOMRect[] | undefined): DOMRect | undefined {
if (!rects?.length) return undefined;
const left = Math.min(...rects.map((item) => item.left));
const top = Math.min(...rects.map((item) => item.top));
const right = Math.max(...rects.map((item) => item.right));
const bottom = Math.max(...rects.map((item) => item.bottom));
return rect({ left, top, width: right - left, height: bottom - top });
}
function isFullyClipped(clipPath: string): boolean {
return /inset\([^)]*100%|circle\(0px/i.test(clipPath);
}
@@ -1475,6 +1504,31 @@ describe("layout-audit.browser occlusion", () => {
expect(issues.some((issue) => issue.code === "text_occluded")).toBe(true);
});
it("does not sample opaque content in the gap between multiline text fragments", () => {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
<div id="headline">First line of prose<br />Second line of prose</div>
<div id="overlay"></div>
</div>
`;
const lineRects = [
rect({ left: 200, top: 500, width: 600, height: 40 }),
rect({ left: 200, top: 580, width: 600, height: 40 }),
];
installOcclusionGeometry({
styleOverrides: { overlay: { backgroundColor: "rgb(10, 10, 10)" } },
headlineTextRect: lineRects,
topmostId: "headline",
});
(
document as unknown as { elementFromPoint: (x: number, y: number) => Element | null }
).elementFromPoint = (_x, y) =>
document.getElementById(y > 540 && y < 580 ? "overlay" : "headline");
installAuditScript();
expect(runAudit().some((issue) => issue.code === "text_occluded")).toBe(false);
});
it("does not flag visible text carrying pointer-events:none (probe restores hit-testing)", () => {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
@@ -1778,7 +1832,7 @@ function auditImageOcclusionScene(
function installOcclusionGeometry(options: {
styleOverrides: Record<string, Partial<Record<string, string>>>;
headlineTextRect: DOMRect;
headlineTextRect: DOMRect | DOMRect[];
topmostId: string;
textRectElementId?: string;
}): void {
@@ -1832,7 +1886,7 @@ function installOcclusionGeometry(options: {
? (selected.parentElement as Element | null)
: (selected as Element | null);
return selectedElement?.id === (options.textRectElementId ?? "headline")
? ([options.headlineTextRect] as unknown as DOMRectList)
? (normalizeTextRects(options.headlineTextRect) as unknown as DOMRectList)
: ([] as unknown as DOMRectList);
},
detach() {},