From 18cd90a5b00fc839f0c7308b47b88bc2e1281070 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 15 Jul 2026 05:22:20 +0000 Subject: [PATCH 1/2] fix(cli): avoid multiline overlap false positives --- .../cli/src/commands/layout-audit.browser.js | 19 ++++++-- .../src/commands/layout-audit.browser.test.ts | 43 ++++++++++++++++--- 2 files changed, 52 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index fdb182b7aa..27e9419031 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -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 diff --git a/packages/cli/src/commands/layout-audit.browser.test.ts b/packages/cli/src/commands/layout-audit.browser.test.ts index 659315739e..f067cfe348 100644 --- a/packages/cli/src/commands/layout-audit.browser.test.ts +++ b/packages/cli/src/commands/layout-audit.browser.test.ts @@ -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 { document.body.innerHTML = `
@@ -1300,7 +1314,10 @@ function auditOverlapScene(options: { a: options.a.clipPath ?? "none", b: options.b.clipPath ?? "none", }; - const textRects: Record = { a: options.a.textRect, b: options.b.textRect }; + const textRects: Record = { + 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); } From 7cacbe8c13756514d6d3ced318a2bc03ae71cd92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 15 Jul 2026 06:17:26 +0000 Subject: [PATCH 2/2] fix(cli): sample multiline occlusion fragments --- .../cli/src/commands/layout-audit.browser.js | 35 ++++++++++++------- .../src/commands/layout-audit.browser.test.ts | 29 +++++++++++++-- 2 files changed, 50 insertions(+), 14 deletions(-) diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index 27e9419031..4eb5184d92 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -941,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. @@ -998,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 { diff --git a/packages/cli/src/commands/layout-audit.browser.test.ts b/packages/cli/src/commands/layout-audit.browser.test.ts index f067cfe348..a7b36d2d36 100644 --- a/packages/cli/src/commands/layout-audit.browser.test.ts +++ b/packages/cli/src/commands/layout-audit.browser.test.ts @@ -1504,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 = ` +
+
First line of prose
Second line of prose
+
+
+ `; + 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 = `
@@ -1807,7 +1832,7 @@ function auditImageOcclusionScene( function installOcclusionGeometry(options: { styleOverrides: Record>>; - headlineTextRect: DOMRect; + headlineTextRect: DOMRect | DOMRect[]; topmostId: string; textRectElementId?: string; }): void { @@ -1861,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() {},