From f8d48bfcdaa41ec8604a35534b9c9c4ae937a3d9 Mon Sep 17 00:00:00 2001 From: miga-heygen Date: Sun, 13 Sep 2026 03:50:43 +0000 Subject: [PATCH] fix: content_overlap uses the real CSS box for out-of-flow text content_overlap measured text overlap via Range.getClientRects(), which reflects a font's own ascent/descent metrics, not the CSS box: a large font-size with a tight line-height keeps the same metrics-rect height regardless of line-height while the real box scales normally. An absolutely/fixed-positioned block has no layout-engine-reserved space (unlike an in-flow flex/grid pair, already exempted), so a genuine CSS-box gap between two such blocks could still measure as overlapping via font metrics -- a false collision report immediately followed by nothing actually unreadable on screen. Adds visibleBoxClientRects, using getBoundingClientRect() instead of Range rects (sharing the existing ancestor-overflow-clip logic via a new clipRectsToOverflowAncestors helper), and switches to it for out-of-flow elements only -- in-flow text keeps the existing font-metrics measurement. Deliberately narrower than "in-flow is always safe": the flex/grid exemption only covers a pair sharing the same flex/grid ancestor, not every in-flow pair, so an unrelated pair of ordinary in-flow blocks with the same font-size/line-height mismatch remains a residual gap, left for a follow-up rather than expanding this fix's scope. Co-Authored-By: Miguel Angel --- .../cli/src/commands/layout-audit.browser.js | 30 +++++- .../src/commands/layout-audit.browser.test.ts | 98 ++++++++++++++++--- 2 files changed, 107 insertions(+), 21 deletions(-) diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index 8693f8a678..3bb887279a 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -268,10 +268,9 @@ }); } - function visibleTextClientRects(element, directOnly) { - // Range rects stay geometrically present outside an overflow clip. Reduce - // them in viewport coordinates so overlap measures only paintable text. - let rects = textClientRects(element, directOnly).map(toRect); + // Client rects stay geometrically present outside an ancestor's overflow clip. + // Reduce them in viewport coordinates so overlap measures only paintable area. + function clipRectsToOverflowAncestors(element, rects) { for ( let ancestor = element.parentElement; ancestor && rects.length > 0; @@ -296,6 +295,25 @@ return rects; } + function visibleTextClientRects(element, directOnly) { + return clipRectsToOverflowAncestors(element, textClientRects(element, directOnly).map(toRect)); + } + + // Range rects follow the font's own ascent/descent, not the CSS box: a large + // font-size with a tight line-height keeps the range-rect height while the real + // box shrinks around it, so a block can measure as colliding with a neighbor + // its box never touches. Only out-of-flow (absolute/fixed) blocks are measured + // this way, since they have no layout-engine-reserved space of their own — + // matching the reported scenario (an absolutely positioned data-card layout). + // In-flow text keeps the font-metrics measurement it has always been audited + // with: `isManagedFlowOverlap` only waives a same-flex/grid-container pair, + // not every in-flow pair, so this is a deliberately narrower fix than "in-flow + // is always safe" — an unrelated pair of ordinary in-flow blocks with the same + // font-size/line-height mismatch remains unfixed, left for a follow-up. + function visibleBoxClientRects(element) { + return clipRectsToOverflowAncestors(element, [toRect(element.getBoundingClientRect())]); + } + function textRectFor(element, directOnly) { return unionRects(textClientRects(element, directOnly)); } @@ -627,7 +645,9 @@ const blocks = []; for (const element of Array.from(root.querySelectorAll("*"))) { if (!isSolidTextBlock(element)) continue; - const rects = visibleTextClientRects(element, true); + const rects = isInFlow(element) + ? visibleTextClientRects(element, true) + : visibleBoxClientRects(element); const rect = unionRects(rects); if (rect) blocks.push({ element, rect, rects }); } diff --git a/packages/cli/src/commands/layout-audit.browser.test.ts b/packages/cli/src/commands/layout-audit.browser.test.ts index d65dabba00..362f3cd58d 100644 --- a/packages/cli/src/commands/layout-audit.browser.test.ts +++ b/packages/cli/src/commands/layout-audit.browser.test.ts @@ -1901,6 +1901,45 @@ describe("layout-audit.browser content overlap", () => { expect(issues.some((issue) => issue.code === "content_overlap")).toBe(true); }); + + // A large font-size with a tight line-height keeps its range-rect height while + // the real box shrinks around it, so the two geometries disagree below. + it("uses the real CSS box, not font-metrics text rects, for an absolutely positioned block", () => { + const issues = auditOverlapScene({ + a: { + position: "absolute", + // Metrics band overlaps b's box... + textRect: rect({ left: 100, top: 80, width: 200, height: 100 }), + // ...but the real box has a genuine 10px gap before b's box starts. + boxRect: rect({ left: 100, top: 100, width: 200, height: 50 }), + }, + b: { + position: "absolute", + textRect: rect({ left: 100, top: 160, width: 200, height: 50 }), + boxRect: rect({ left: 100, top: 160, width: 200, height: 50 }), + }, + }); + + expect(issues.some((issue) => issue.code === "content_overlap")).toBe(false); + }); + + it("still flags a genuine overlap between absolutely positioned real boxes", () => { + const issues = auditOverlapScene({ + a: { + position: "absolute", + textRect: rect({ left: 100, top: 80, width: 200, height: 100 }), + boxRect: rect({ left: 100, top: 100, width: 200, height: 50 }), + }, + b: { + position: "absolute", + textRect: rect({ left: 100, top: 130, width: 200, height: 50 }), + // Real box genuinely overlaps a's real box by 20px this time. + boxRect: rect({ left: 100, top: 130, width: 200, height: 50 }), + }, + }); + + expect(issues.some((issue) => issue.code === "content_overlap")).toBe(true); + }); }); describe("contrast-audit.browser clip-path visibility", () => { @@ -2289,10 +2328,41 @@ function expectExemptFromOverlap(aOverrides: { color?: string; attrs?: string }) expect(issues.some((issue) => issue.code === "content_overlap")).toBe(false); } +interface OverlapBlockInput { + textRect: DOMRect | DOMRect[]; + color?: string; + attrs?: string; + clipPath?: string; + position?: string; + boxRect?: DOMRect; +} + +function overlapBlockRecords(blocks: Record): { + colors: Record; + clipPaths: Record; + positions: Record; + textRects: Record; + elementRects: Record; +} { + const colors: Record = {}; + const clipPaths: Record = {}; + const positions: Record = {}; + const textRects: Record = {}; + const elementRects: Record = {}; + for (const [id, block] of Object.entries(blocks)) { + colors[id] = block.color ?? "rgb(0, 0, 0)"; + clipPaths[id] = block.clipPath ?? "none"; + positions[id] = block.position ?? "static"; + textRects[id] = normalizeTextRects(block.textRect); + if (block.boxRect) elementRects[id] = block.boxRect; + } + return { colors, clipPaths, positions, textRects, elementRects }; +} + function auditOverlapScene(options: { rootAttrs?: string; - a: { textRect: DOMRect | DOMRect[]; color?: string; attrs?: string; clipPath?: string }; - b: { textRect: DOMRect | DOMRect[]; color?: string; attrs?: string; clipPath?: string }; + a: OverlapBlockInput; + b: OverlapBlockInput; }): ReturnType { document.body.innerHTML = `
@@ -2300,21 +2370,13 @@ function auditOverlapScene(options: {
Block B copy
`; - const colors: Record = { - a: options.a.color ?? "rgb(0, 0, 0)", - b: options.b.color ?? "rgb(0, 0, 0)", - }; - const clipPaths: Record = { - a: options.a.clipPath ?? "none", - b: options.b.clipPath ?? "none", - }; - const textRects: Record = { - a: normalizeTextRects(options.a.textRect), - b: normalizeTextRects(options.b.textRect), - }; + const { colors, clipPaths, positions, textRects, elementRects } = overlapBlockRecords({ + a: options.a, + b: options.b, + }); - installOverlapStyles(colors, clipPaths); - installOverlapGeometry(textRects); + installOverlapStyles(colors, clipPaths, {}, positions); + installOverlapGeometry(textRects, elementRects); installAuditScript(); return runAudit(); } @@ -2346,11 +2408,15 @@ function installOverlapStyles( colors: Record, clipPaths: Record, overflows: Record = {}, + positions: Record = {}, ): void { vi.spyOn(window, "getComputedStyle").mockImplementation((element) => { const id = (element as Element).id; return { display: "block", + // Mirrors the real browser default, so only a scene that explicitly opts + // into absolute/fixed takes the out-of-flow box measurement. + position: positions[id] ?? "static", visibility: "visible", opacity: "1", color: colors[id] ?? "rgb(0, 0, 0)",