From f8d48bfcdaa41ec8604a35534b9c9c4ae937a3d9 Mon Sep 17 00:00:00 2001 From: miga-heygen Date: Sun, 13 Sep 2026 03:50:43 +0000 Subject: [PATCH 1/4] 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)", From 6ddf66049f1cd3ea4db68647f42cda2c7a2b8ff4 Mon Sep 17 00:00:00 2001 From: miga-heygen Date: Tue, 15 Sep 2026 00:54:00 +0000 Subject: [PATCH 2/4] fix: measure content_overlap as glyph rects with bleed clamped to the box MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the per-position geometry switch (bare border-box for out-of-flow blocks, raw glyph rects for in-flow) with one rule for every block: glyph rects, with half-leading spill past the element's own border-box trimmed back to the box. A side is trimmed only when the outermost line spills by at most half its own rect height, which bounds (content-area - line-height) / 2 exactly and is scale-invariant; a further wrapped line always spills more and is kept as real ink. Horizontal spill is never trimmed, and vertical writing modes are left untrimmed rather than trimming real overflow. The bare box over-reported when wider or taller than its text and compared unlike geometries within a mixed pair; an unbounded clamp dropped genuine overflow from unpainted boxes, which no overflow constraint owns. Share the per-rect intersection between the ancestor overflow clip and the box trim, and cover box-larger-than-text, text-overflowing-box, threshold boundaries, multi-line bleed, mixed and in-flow pairs, and vertical writing mode with fixtures whose mutants each fail. Co-Authored-By: Miguel Ángel --- .../cli/src/commands/layout-audit.browser.js | 98 ++++--- .../src/commands/layout-audit.browser.test.ts | 268 +++++++++++++++++- 2 files changed, 320 insertions(+), 46 deletions(-) diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index 3bb887279a..acb75182ea 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -268,6 +268,21 @@ }); } + // Intersect each rect with `clip` along the requested axes, dropping slivers. + // Only the edges on a clamped axis are read from `clip`. + function clampRectsTo(rects, clip, clampX, clampY) { + return rects + .map((rect) => { + const left = clampX ? Math.max(rect.left, clip.left) : rect.left; + const right = clampX ? Math.min(rect.right, clip.right) : rect.right; + const top = clampY ? Math.max(rect.top, clip.top) : rect.top; + const bottom = clampY ? Math.min(rect.bottom, clip.bottom) : rect.bottom; + if (right - left <= 0.5 || bottom - top <= 0.5) return null; + return rectFromOrigin(left, top, right - left, bottom - top); + }) + .filter(Boolean); + } + // 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) { @@ -280,38 +295,54 @@ const clipX = clipsOverflowValue(style.overflowX || style.overflow); const clipY = clipsOverflowValue(style.overflowY || style.overflow); if (!clipX && !clipY) continue; - const clip = toRect(ancestor.getBoundingClientRect()); - rects = rects - .map((rect) => { - const left = clipX ? Math.max(rect.left, clip.left) : rect.left; - const right = clipX ? Math.min(rect.right, clip.right) : rect.right; - const top = clipY ? Math.max(rect.top, clip.top) : rect.top; - const bottom = clipY ? Math.min(rect.bottom, clip.bottom) : rect.bottom; - if (right - left <= 0.5 || bottom - top <= 0.5) return null; - return toRect({ left, right, top, bottom, width: right - left, height: bottom - top }); - }) - .filter(Boolean); + rects = clampRectsTo(rects, toRect(ancestor.getBoundingClientRect()), clipX, clipY); } 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())]); + // Geometry rule for content-overlap, decided once here and applied to every + // block so any pair compares like with like: glyph (Range) rects, with any + // font-metric bleed past the element's own border-box clamped back to it, + // then clipped by overflow ancestors. + // + // Range rects follow the font's 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 + // neighbour its box never touches. The bare box overshoots the other way: a + // box wider or taller than the text it holds would register collisions its + // glyphs never make. So the box is only used to trim glyph spill it can + // account for. That spill is half-leading, (content-area - line-height) / 2 + // per side, so it can never exceed half the spilling line's own rect height + // (its content area); a further wrapped line spills (line-height + + // content-area) / 2, more for any positive line-height. Anything past that + // bound is real ink the box says nothing about, so it is kept and still + // collides. Measuring the bound from the rect itself keeps it exact under any + // transform scale. The cost: a box that cuts a line within its first half is + // indistinguishable from bleed and is trimmed with it. In horizontal writing + // the spill is vertical by nature — glyph advances never exceed the box unless + // the text genuinely overflows — so the horizontal extent is never trimmed; + // vertical writing modes swap the axes, so they are left untrimmed entirely + // rather than trimming real overflow. The clamp does not help text wrapped + // in a bare inline element (a span): its own box already follows the font's + // content area, so the bleed is measured as inside the box. + function overlapTextRects(element) { + const glyphRects = textClientRects(element, true).map(toRect); + const glyphUnion = unionRects(glyphRects); + if (!glyphUnion) return []; + if (getComputedStyle(element).writingMode !== "horizontal-tb") { + return clipRectsToOverflowAncestors(element, glyphRects); + } + const box = toRect(element.getBoundingClientRect()); + const topLine = glyphRects.reduce((top, rect) => (rect.top < top.top ? rect : top)); + const bottomLine = glyphRects.reduce((bottom, rect) => + rect.bottom > bottom.bottom ? rect : bottom, + ); + const isBleed = (spill, line) => spill > 0 && spill <= line.height / 2; + const clip = { + top: isBleed(box.top - topLine.top, topLine) ? box.top : glyphUnion.top, + bottom: isBleed(bottomLine.bottom - box.bottom, bottomLine) ? box.bottom : glyphUnion.bottom, + }; + return clipRectsToOverflowAncestors(element, clampRectsTo(glyphRects, clip, false, true)); } function textRectFor(element, directOnly) { @@ -645,9 +676,7 @@ const blocks = []; for (const element of Array.from(root.querySelectorAll("*"))) { if (!isSolidTextBlock(element)) continue; - const rects = isInFlow(element) - ? visibleTextClientRects(element, true) - : visibleBoxClientRects(element); + const rects = overlapTextRects(element); const rect = unionRects(rects); if (rect) blocks.push({ element, rect, rects }); } @@ -705,9 +734,10 @@ return !!container && container === nearestFlexGridAncestor(b); } - // 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. + // Two solid text blocks whose measured text overlaps by more than a fifth of + // the smaller block's text area 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; if (isManagedFlowOverlap(a.element, b.element)) return null; diff --git a/packages/cli/src/commands/layout-audit.browser.test.ts b/packages/cli/src/commands/layout-audit.browser.test.ts index 362f3cd58d..ddacf7687d 100644 --- a/packages/cli/src/commands/layout-audit.browser.test.ts +++ b/packages/cli/src/commands/layout-audit.browser.test.ts @@ -1902,9 +1902,14 @@ 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", () => { + // Overlap measures glyph rects with font-metric bleed past the element's own + // CSS box trimmed back to it: neither the bare font-metrics band nor the bare + // box. The scenes below pull the geometries apart so only that measurement + // gives the right answer. Spill counts as bleed up to half the spilling glyph + // rect's own height (its font content area), never more. + it("does not flag when only font-metrics bands overlap but the real CSS boxes have a gap", () => { + // A large font-size with a tight line-height keeps the range-rect height + // while the real box shrinks around it: 20px above, 30px below. const issues = auditOverlapScene({ a: { position: "absolute", @@ -1923,6 +1928,221 @@ describe("layout-audit.browser content overlap", () => { expect(issues.some((issue) => issue.code === "content_overlap")).toBe(false); }); + it("does not flag a box wider than its text just because the empty part of the box overlaps a neighbour", () => { + // A short label in a wide absolutely positioned box: a box-based measurement + // would flag the neighbour sitting in the box's empty right half. + const issues = auditOverlapScene({ + a: { + position: "absolute", + textRect: rect({ left: 100, top: 100, width: 60, height: 40 }), + boxRect: rect({ left: 100, top: 100, width: 400, height: 40 }), + }, + b: { + position: "absolute", + textRect: rect({ left: 300, top: 100, width: 150, height: 40 }), + boxRect: rect({ left: 300, top: 100, width: 150, height: 40 }), + }, + }); + + expect(issues.some((issue) => issue.code === "content_overlap")).toBe(false); + }); + + it("does not flag a box taller than its text just because the empty part of the box overlaps a neighbour", () => { + const issues = auditOverlapScene({ + a: { + position: "absolute", + textRect: rect({ left: 100, top: 100, width: 200, height: 40 }), + boxRect: rect({ left: 100, top: 100, width: 200, height: 200 }), + }, + b: { + position: "absolute", + textRect: rect({ left: 100, top: 200, width: 200, height: 40 }), + boxRect: rect({ left: 100, top: 200, width: 200, height: 40 }), + }, + }); + + expect(issues.some((issue) => issue.code === "content_overlap")).toBe(false); + }); + + it("still flags text that overflows its own box into a neighbour", () => { + // Glyphs run 100px past a's 50px box — beyond half the 150px glyph rect — + // and into b. A bare unpainted box is not an overflow constraint, so overlap + // must keep owning this: the spill is real ink, not font-metric bleed. + const issues = auditOverlapScene({ + a: { + position: "absolute", + textRect: rect({ left: 100, top: 100, width: 200, height: 150 }), + 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(true); + }); + + it("trims spill of exactly half the glyph rect height as bleed", () => { + // 50px of a 100px rect: the largest bleed a line can produce (line-height + // 0 spills exactly half the content area past each edge of the box). + const issues = auditOverlapScene({ + a: { + position: "absolute", + textRect: rect({ left: 100, top: 100, width: 200, height: 100 }), + 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("keeps spill just past half the glyph rect height as real ink", () => { + // 51px of a 101px rect: more than any half-leading, so it stays in play. + const issues = auditOverlapScene({ + a: { + position: "absolute", + textRect: rect({ left: 100, top: 100, width: 200, height: 101 }), + 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(true); + }); + + it("trims bleed on only the first and last lines of a multi-line block", () => { + // Two 100px-tall glyph lines on an 80px line-height: 10px bleeds above and + // below the 160px box. Only the bottom bleed reaches b; trimmed, the real + // 5px box overlap is under the collision threshold. + const issues = auditOverlapScene({ + a: { + position: "absolute", + textRect: [ + rect({ left: 100, top: 90, width: 200, height: 100 }), + rect({ left: 100, top: 170, width: 200, height: 100 }), + ], + boxRect: rect({ left: 100, top: 100, width: 200, height: 160 }), + }, + b: { + position: "absolute", + textRect: rect({ left: 100, top: 255, width: 200, height: 50 }), + boxRect: rect({ left: 100, top: 255, width: 200, height: 50 }), + }, + }); + + expect(issues.some((issue) => issue.code === "content_overlap")).toBe(false); + }); + + it("keeps a wrapped line spilling more than half its own height, however tall the block", () => { + // Two 100px lines; the 140px box cuts 60px off the second line. That is + // more than half of that line's own rect, so it is real overflow — the + // 200px block height must not widen the bleed allowance. + const issues = auditOverlapScene({ + a: { + position: "absolute", + textRect: [ + rect({ left: 100, top: 100, width: 200, height: 100 }), + rect({ left: 100, top: 200, width: 200, height: 100 }), + ], + boxRect: rect({ left: 100, top: 100, width: 200, height: 140 }), + }, + b: { + position: "absolute", + textRect: rect({ left: 100, top: 250, width: 200, height: 50 }), + boxRect: rect({ left: 100, top: 250, width: 200, height: 50 }), + }, + }); + + expect(issues.some((issue) => issue.code === "content_overlap")).toBe(true); + }); + + it("does not flag two in-flow blocks outside any flex/grid container whose bleed alone overlaps", () => { + // Neither block is absolute, and no shared flex/grid ancestor waives the + // pair: the bleed trim is what keeps this clean. + const issues = auditOverlapScene({ + a: { + textRect: rect({ left: 100, top: 80, width: 200, height: 100 }), + boxRect: rect({ left: 100, top: 100, width: 200, height: 50 }), + }, + b: { + 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("leaves a vertical writing-mode column untrimmed, so its real overflow still collides", () => { + // In vertical-rl the glyph rect's height is the inline advance: 200px past + // a 300px column is genuine overflow, not bleed, even though it is under + // half the rect height. The clamp steps aside rather than swallow it. + const issues = auditOverlapScene({ + a: { + position: "absolute", + writingMode: "vertical-rl", + textRect: rect({ left: 100, top: 100, width: 60, height: 500 }), + boxRect: rect({ left: 100, top: 100, width: 60, height: 300 }), + }, + b: { + position: "absolute", + textRect: rect({ left: 100, top: 450, width: 60, height: 100 }), + boxRect: rect({ left: 100, top: 450, width: 60, height: 100 }), + }, + }); + + expect(issues.some((issue) => issue.code === "content_overlap")).toBe(true); + }); + + it("still flags a nowrap run that spills past its box's right edge into a neighbour", () => { + // Horizontal spill is never font-metric bleed, so the box's right edge must + // not trim it away. + const issues = auditOverlapScene({ + a: { + position: "absolute", + textRect: rect({ left: 100, top: 100, width: 400, height: 40 }), + boxRect: rect({ left: 100, top: 100, width: 200, height: 40 }), + }, + b: { + position: "absolute", + textRect: rect({ left: 350, top: 100, width: 200, height: 40 }), + boxRect: rect({ left: 350, top: 100, width: 200, height: 40 }), + }, + }); + + expect(issues.some((issue) => issue.code === "content_overlap")).toBe(true); + }); + + it("applies the same clamped-glyph geometry to an in-flow block paired with an absolute one", () => { + // a is in normal flow with the same tight line-height bleed; b is absolute. + // Both sides of the pair are measured the same way, so the bleed alone does + // not register as a collision with the free-positioned neighbour. + const issues = auditOverlapScene({ + a: { + 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: 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: { @@ -1940,6 +2160,25 @@ describe("layout-audit.browser content overlap", () => { expect(issues.some((issue) => issue.code === "content_overlap")).toBe(true); }); + + it("still flags glyphs that genuinely collide inside two wide boxes", () => { + // Boxes overlap AND the ink inside them overlaps: a real collision, not a + // box artefact, so the clamp must not swallow it. + const issues = auditOverlapScene({ + a: { + position: "absolute", + textRect: rect({ left: 100, top: 100, width: 250, height: 40 }), + boxRect: rect({ left: 100, top: 100, width: 400, height: 40 }), + }, + b: { + position: "absolute", + textRect: rect({ left: 300, top: 100, width: 150, height: 40 }), + boxRect: rect({ left: 300, top: 100, width: 150, height: 40 }), + }, + }); + + expect(issues.some((issue) => issue.code === "content_overlap")).toBe(true); + }); }); describe("contrast-audit.browser clip-path visibility", () => { @@ -2334,6 +2573,7 @@ interface OverlapBlockInput { attrs?: string; clipPath?: string; position?: string; + writingMode?: string; boxRect?: DOMRect; } @@ -2341,22 +2581,25 @@ function overlapBlockRecords(blocks: Record): { colors: Record; clipPaths: Record; positions: Record; + writingModes: Record; textRects: Record; elementRects: Record; } { const colors: Record = {}; const clipPaths: Record = {}; const positions: Record = {}; + const writingModes: 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"; + if (block.position) positions[id] = block.position; + if (block.writingMode) writingModes[id] = block.writingMode; textRects[id] = normalizeTextRects(block.textRect); if (block.boxRect) elementRects[id] = block.boxRect; } - return { colors, clipPaths, positions, textRects, elementRects }; + return { colors, clipPaths, positions, writingModes, textRects, elementRects }; } function auditOverlapScene(options: { @@ -2370,12 +2613,10 @@ function auditOverlapScene(options: {
Block B copy
`; - const { colors, clipPaths, positions, textRects, elementRects } = overlapBlockRecords({ - a: options.a, - b: options.b, - }); + const { colors, clipPaths, positions, writingModes, textRects, elementRects } = + overlapBlockRecords({ a: options.a, b: options.b }); - installOverlapStyles(colors, clipPaths, {}, positions); + installOverlapStyles(colors, clipPaths, {}, positions, writingModes); installOverlapGeometry(textRects, elementRects); installAuditScript(); return runAudit(); @@ -2409,13 +2650,16 @@ function installOverlapStyles( clipPaths: Record, overflows: Record = {}, positions: Record = {}, + writingModes: 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. + writingMode: writingModes[id] ?? "horizontal-tb", + // Mirrors the real browser default. Overlap geometry is the same for every + // block, so position only decides whether a pair qualifies for the + // same-flex/grid managed-flow waiver. position: positions[id] ?? "static", visibility: "visible", opacity: "1", From 18d90db678eb94d28d5eb380e784b3c852c33fdf Mon Sep 17 00:00:00 2001 From: miga-heygen Date: Tue, 15 Sep 2026 01:38:31 +0000 Subject: [PATCH 3/4] fix: keep negative-leading spill as ink in content_overlap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bound the border-box trim to a fifth of the spilling line's rect height instead of a half. Half-leading is (content-area - line-height) / 2, and at up to a fifth of the content area the box still encloses the cap band, so only ascender and descender tips lie outside it; a neighbour set against the box edge shares about 1% of its ink with them. Tighter than that, the box sits inside the cap band and the spill is dense glyph ink: in Chromium a neighbour at the box edge shares 22-42% of its ink from line-height 0.4 down to 2px, which the half bound trimmed away and no longer reported. Drop the redundant glyph union in favour of the outermost line edges, state negative leading explicitly in the rule comment, and pin the new bound from both sides, deep negative leading in 50px and 25px boxes, and bleed trimmed above the box with the neighbour above. Co-Authored-By: Miguel Ángel --- .../cli/src/commands/layout-audit.browser.js | 54 ++++---- .../src/commands/layout-audit.browser.test.ts | 122 +++++++++++++----- 2 files changed, 119 insertions(+), 57 deletions(-) diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index acb75182ea..5707664675 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -301,34 +301,36 @@ } // Geometry rule for content-overlap, decided once here and applied to every - // block so any pair compares like with like: glyph (Range) rects, with any - // font-metric bleed past the element's own border-box clamped back to it, - // then clipped by overflow ancestors. + // block so any pair compares like with like: glyph (Range) rects, with + // font-metric bleed past the element's own border-box clamped back to it, then + // clipped by overflow ancestors. // - // Range rects follow the font's 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 - // neighbour its box never touches. The bare box overshoots the other way: a - // box wider or taller than the text it holds would register collisions its - // glyphs never make. So the box is only used to trim glyph spill it can - // account for. That spill is half-leading, (content-area - line-height) / 2 - // per side, so it can never exceed half the spilling line's own rect height - // (its content area); a further wrapped line spills (line-height + - // content-area) / 2, more for any positive line-height. Anything past that - // bound is real ink the box says nothing about, so it is kept and still - // collides. Measuring the bound from the rect itself keeps it exact under any - // transform scale. The cost: a box that cuts a line within its first half is - // indistinguishable from bleed and is trimmed with it. In horizontal writing + // Range rects follow the font's ascent/descent (the content area), 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 neighbour its box never touches. The bare box overshoots the other + // way: a box wider or taller than the text it holds would register collisions + // its glyphs never make. So the box only trims glyph spill that is sparse ink. + // With negative leading (line-height below the content area) the line spills + // (content-area - line-height) / 2 past each box edge. While that is at most a + // fifth of the line's rect height — line-height at least 60% of the content + // area, roughly 0.7em for a typical 1.2em content area — the box still encloses + // the cap band and only ascender/descender tips lie outside, so a neighbour set + // against the box edge shares ~1% of its ink with them. Any tighter and the box + // sits inside the cap band: the spill is dense glyph ink that a neighbour at + // the box edge collides with for real (over 20% shared ink from about 0.4em + // down), so it is kept. A further wrapped line spills (line-height + + // content-area) / 2, always more, and is kept too. Measuring the bound from the + // rect itself keeps it exact under any transform scale. In horizontal writing // the spill is vertical by nature — glyph advances never exceed the box unless // the text genuinely overflows — so the horizontal extent is never trimmed; // vertical writing modes swap the axes, so they are left untrimmed entirely - // rather than trimming real overflow. The clamp does not help text wrapped - // in a bare inline element (a span): its own box already follows the font's - // content area, so the bleed is measured as inside the box. + // rather than trimming real overflow. The clamp does not help text wrapped in a + // bare inline element (a span): its own box already follows the font's content + // area, so the bleed is measured as inside the box. function overlapTextRects(element) { const glyphRects = textClientRects(element, true).map(toRect); - const glyphUnion = unionRects(glyphRects); - if (!glyphUnion) return []; + if (glyphRects.length === 0) return []; if (getComputedStyle(element).writingMode !== "horizontal-tb") { return clipRectsToOverflowAncestors(element, glyphRects); } @@ -337,10 +339,12 @@ const bottomLine = glyphRects.reduce((bottom, rect) => rect.bottom > bottom.bottom ? rect : bottom, ); - const isBleed = (spill, line) => spill > 0 && spill <= line.height / 2; + const topSpill = box.top - topLine.top; + const bottomSpill = bottomLine.bottom - box.bottom; + const isBleed = (spill, line) => spill > 0 && spill <= line.height / 5; const clip = { - top: isBleed(box.top - topLine.top, topLine) ? box.top : glyphUnion.top, - bottom: isBleed(bottomLine.bottom - box.bottom, bottomLine) ? box.bottom : glyphUnion.bottom, + top: isBleed(topSpill, topLine) ? box.top : topLine.top, + bottom: isBleed(bottomSpill, bottomLine) ? box.bottom : bottomLine.bottom, }; return clipRectsToOverflowAncestors(element, clampRectsTo(glyphRects, clip, false, true)); } diff --git a/packages/cli/src/commands/layout-audit.browser.test.ts b/packages/cli/src/commands/layout-audit.browser.test.ts index ddacf7687d..d290f363cd 100644 --- a/packages/cli/src/commands/layout-audit.browser.test.ts +++ b/packages/cli/src/commands/layout-audit.browser.test.ts @@ -1905,23 +1905,25 @@ describe("layout-audit.browser content overlap", () => { // Overlap measures glyph rects with font-metric bleed past the element's own // CSS box trimmed back to it: neither the bare font-metrics band nor the bare // box. The scenes below pull the geometries apart so only that measurement - // gives the right answer. Spill counts as bleed up to half the spilling glyph - // rect's own height (its font content area), never more. - it("does not flag when only font-metrics bands overlap but the real CSS boxes have a gap", () => { + // gives the right answer. Spill counts as bleed up to a fifth of the spilling + // glyph rect's own height (its font content area): the box still encloses the + // cap band and only ascender/descender tips lie outside. Tighter than that is + // negative leading deep into the glyphs, and the spill is kept as real ink. + it("does not flag when only font-metrics bands overlap but the real CSS boxes just touch", () => { // A large font-size with a tight line-height keeps the range-rect height - // while the real box shrinks around it: 20px above, 30px below. + // while the real box shrinks around it: 15px above and below an 80px line. 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. + textRect: rect({ left: 100, top: 85, width: 200, height: 80 }), + // ...but the real box ends exactly where 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 }), + textRect: rect({ left: 100, top: 150, width: 200, height: 50 }), + boxRect: rect({ left: 100, top: 150, width: 200, height: 50 }), }, }); @@ -1965,8 +1967,8 @@ describe("layout-audit.browser content overlap", () => { }); it("still flags text that overflows its own box into a neighbour", () => { - // Glyphs run 100px past a's 50px box — beyond half the 150px glyph rect — - // and into b. A bare unpainted box is not an overflow constraint, so overlap + // Glyphs run 100px past a's 50px box — far beyond a fifth of the 150px + // glyph rect — and into b. A bare unpainted box is not an overflow constraint, so overlap // must keep owning this: the spill is real ink, not font-metric bleed. const issues = auditOverlapScene({ a: { @@ -1984,43 +1986,99 @@ describe("layout-audit.browser content overlap", () => { expect(issues.some((issue) => issue.code === "content_overlap")).toBe(true); }); - it("trims spill of exactly half the glyph rect height as bleed", () => { - // 50px of a 100px rect: the largest bleed a line can produce (line-height - // 0 spills exactly half the content area past each edge of the box). + it("trims spill of exactly a fifth of the glyph rect height as bleed", () => { + // 20px of a 100px line: line-height at 60% of the content area, the + // tightest leading whose spill is still only ascender/descender tips. const issues = auditOverlapScene({ a: { position: "absolute", textRect: rect({ left: 100, top: 100, width: 200, height: 100 }), - boxRect: rect({ left: 100, top: 100, width: 200, height: 50 }), + boxRect: rect({ left: 100, top: 100, width: 200, height: 80 }), }, b: { position: "absolute", - textRect: rect({ left: 100, top: 160, width: 200, height: 50 }), - boxRect: rect({ left: 100, top: 160, width: 200, height: 50 }), + textRect: rect({ left: 100, top: 185, width: 200, height: 50 }), + boxRect: rect({ left: 100, top: 185, width: 200, height: 50 }), }, }); expect(issues.some((issue) => issue.code === "content_overlap")).toBe(false); }); - it("keeps spill just past half the glyph rect height as real ink", () => { - // 51px of a 101px rect: more than any half-leading, so it stays in play. + it("keeps spill just past a fifth of the glyph rect height as real ink", () => { + // 21px of a 100px line stays in play, so the neighbour set against the box + // edge collides. const issues = auditOverlapScene({ a: { position: "absolute", - textRect: rect({ left: 100, top: 100, width: 200, height: 101 }), - boxRect: rect({ left: 100, top: 100, width: 200, height: 50 }), + textRect: rect({ left: 100, top: 100, width: 200, height: 100 }), + boxRect: rect({ left: 100, top: 100, width: 200, height: 79 }), }, b: { position: "absolute", - textRect: rect({ left: 100, top: 160, width: 200, height: 50 }), - boxRect: rect({ left: 100, top: 160, width: 200, height: 50 }), + textRect: rect({ left: 100, top: 179, width: 200, height: 50 }), + boxRect: rect({ left: 100, top: 179, width: 200, height: 50 }), + }, + }); + + expect(issues.some((issue) => issue.code === "content_overlap")).toBe(true); + }); + + it("flags a neighbour at the box edge of a line with deep negative leading", () => { + // line-height 0 on a 100px line: half the content area spills past each + // box edge and the box sits inside the cap band. The spill is dense glyph + // ink, so b, set against a's box bottom, collides for real. + const issues = auditOverlapScene({ + a: { + position: "absolute", + textRect: rect({ left: 100, top: 100, width: 200, height: 100 }), + boxRect: rect({ left: 100, top: 125, width: 200, height: 50 }), + }, + b: { + position: "absolute", + textRect: rect({ left: 100, top: 175, width: 200, height: 50 }), + boxRect: rect({ left: 100, top: 175, width: 200, height: 50 }), }, }); expect(issues.some((issue) => issue.code === "content_overlap")).toBe(true); }); + it("flags a neighbour at the box edge of a 25px box holding a 100px line", () => { + const issues = auditOverlapScene({ + a: { + position: "absolute", + textRect: rect({ left: 100, top: 100, width: 200, height: 100 }), + boxRect: rect({ left: 100, top: 137, width: 200, height: 25 }), + }, + b: { + position: "absolute", + textRect: rect({ left: 100, top: 162, width: 200, height: 50 }), + boxRect: rect({ left: 100, top: 162, width: 200, height: 50 }), + }, + }); + + expect(issues.some((issue) => issue.code === "content_overlap")).toBe(true); + }); + + it("trims bleed above the box when the neighbour sits above", () => { + // Same 15px bleed on an 80px line, spilling upward into b's box this time. + const issues = auditOverlapScene({ + a: { + position: "absolute", + textRect: rect({ left: 100, top: 85, width: 200, height: 80 }), + boxRect: rect({ left: 100, top: 100, width: 200, height: 50 }), + }, + b: { + position: "absolute", + textRect: rect({ left: 100, top: 50, width: 200, height: 50 }), + boxRect: rect({ left: 100, top: 50, width: 200, height: 50 }), + }, + }); + + expect(issues.some((issue) => issue.code === "content_overlap")).toBe(false); + }); + it("trims bleed on only the first and last lines of a multi-line block", () => { // Two 100px-tall glyph lines on an 80px line-height: 10px bleeds above and // below the 160px box. Only the bottom bleed reaches b; trimmed, the real @@ -2044,9 +2102,9 @@ describe("layout-audit.browser content overlap", () => { expect(issues.some((issue) => issue.code === "content_overlap")).toBe(false); }); - it("keeps a wrapped line spilling more than half its own height, however tall the block", () => { + it("keeps a wrapped line spilling more than a fifth of its own height, however tall the block", () => { // Two 100px lines; the 140px box cuts 60px off the second line. That is - // more than half of that line's own rect, so it is real overflow — the + // more than a fifth of that line's own rect, so it is real overflow — the // 200px block height must not widen the bleed allowance. const issues = auditOverlapScene({ a: { @@ -2072,12 +2130,12 @@ describe("layout-audit.browser content overlap", () => { // pair: the bleed trim is what keeps this clean. const issues = auditOverlapScene({ a: { - textRect: rect({ left: 100, top: 80, width: 200, height: 100 }), + textRect: rect({ left: 100, top: 85, width: 200, height: 80 }), boxRect: rect({ left: 100, top: 100, width: 200, height: 50 }), }, b: { - textRect: rect({ left: 100, top: 160, width: 200, height: 50 }), - boxRect: rect({ left: 100, top: 160, width: 200, height: 50 }), + textRect: rect({ left: 100, top: 150, width: 200, height: 50 }), + boxRect: rect({ left: 100, top: 150, width: 200, height: 50 }), }, }); @@ -2086,8 +2144,8 @@ describe("layout-audit.browser content overlap", () => { it("leaves a vertical writing-mode column untrimmed, so its real overflow still collides", () => { // In vertical-rl the glyph rect's height is the inline advance: 200px past - // a 300px column is genuine overflow, not bleed, even though it is under - // half the rect height. The clamp steps aside rather than swallow it. + // a 300px column is genuine overflow, not bleed, whatever its share of the + // rect height. The clamp steps aside rather than swallow it. const issues = auditOverlapScene({ a: { position: "absolute", @@ -2130,13 +2188,13 @@ describe("layout-audit.browser content overlap", () => { // not register as a collision with the free-positioned neighbour. const issues = auditOverlapScene({ a: { - textRect: rect({ left: 100, top: 80, width: 200, height: 100 }), + textRect: rect({ left: 100, top: 85, width: 200, height: 80 }), 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 }), + textRect: rect({ left: 100, top: 150, width: 200, height: 50 }), + boxRect: rect({ left: 100, top: 150, width: 200, height: 50 }), }, }); From 1843c55f6ff1ce54556bdaf1eafb0f89ea3eaf30 Mon Sep 17 00:00:00 2001 From: miga-heygen Date: Tue, 15 Sep 2026 06:25:12 +0000 Subject: [PATCH 4/4] fix(cli): measure glyph spill against the content box MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit content_overlap trimmed font-metric bleed against the border box that getBoundingClientRect() reports. Line boxes are laid out inside the content box, so with any padding the tips spill into the padding band first: measured against the border box that spill read as <= 0, was left as ink, and a caption set against the content edge of a padded headline collided with tips the unpadded box would have trimmed. The vertical padding and border widths now come off the border box before the spill is measured, scaled by the ratio of the rendered border-box height to offsetHeight since the client rect is post-transform while the computed widths are local px. Tests: padded, bordered and scaled fixtures at the content-box edge stay clean and a 25px spill past a padded content edge is still flagged; the vertical writing-mode fixture now spills under a fifth of its rect so it binds the writing-mode guard; a 0.5px clipped sliver is dropped; the line-height-0 fixture (a zero-height box in Chromium) is removed as its spill geometry is covered by the padded and 25px-box fixtures. Co-Authored-By: Miguel Ángel --- .../cli/src/commands/layout-audit.browser.js | 49 +++-- .../src/commands/layout-audit.browser.test.ts | 203 ++++++++++++++---- 2 files changed, 202 insertions(+), 50 deletions(-) diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index 5707664675..76f35f6942 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -302,8 +302,8 @@ // Geometry rule for content-overlap, decided once here and applied to every // block so any pair compares like with like: glyph (Range) rects, with - // font-metric bleed past the element's own border-box clamped back to it, then - // clipped by overflow ancestors. + // font-metric bleed past the element's own content box clamped back to it, + // then clipped by overflow ancestors. // // Range rects follow the font's ascent/descent (the content area), not the CSS // box: a large font-size with a tight line-height keeps the range-rect height @@ -321,30 +321,51 @@ // the box edge collides with for real (over 20% shared ink from about 0.4em // down), so it is kept. A further wrapped line spills (line-height + // content-area) / 2, always more, and is kept too. Measuring the bound from the - // rect itself keeps it exact under any transform scale. In horizontal writing - // the spill is vertical by nature — glyph advances never exceed the box unless - // the text genuinely overflows — so the horizontal extent is never trimmed; - // vertical writing modes swap the axes, so they are left untrimmed entirely - // rather than trimming real overflow. The clamp does not help text wrapped in a - // bare inline element (a span): its own box already follows the font's content - // area, so the bleed is measured as inside the box. + // rect itself keeps it valid under any transform scale. + // + // The spill is measured against the content box, not the border box + // getBoundingClientRect() reports: line boxes are laid out inside the content + // box, so the tips spill into the padding band first and past the border edge + // only once they outgrow it. Against the border box, any bleed narrower than + // the padding reads as spill <= 0 and stays as ink for a neighbour set at the + // content edge to collide with. Padding must not change the answer, so the + // vertical padding and border widths come off the border box first and the + // clamp lands on the content edges. The computed widths are local px while the + // client rect is post-transform, so they are scaled by the ratio of the + // rendered border-box height to offsetHeight (the same box in local px) — + // otherwise a scaled-down block would subtract too much and read its own + // padding band as spill, or vanish entirely once the band outgrew the box. + // + // In horizontal writing the spill is vertical by nature — glyph advances never + // exceed the box unless the text genuinely overflows — so the horizontal extent + // is never trimmed; vertical writing modes swap the axes, so they are left + // untrimmed entirely rather than trimming real overflow. The clamp does not + // help text wrapped in a bare inline element (a span): its own box already + // follows the font's content area, so the bleed is measured as inside the box. function overlapTextRects(element) { const glyphRects = textClientRects(element, true).map(toRect); if (glyphRects.length === 0) return []; - if (getComputedStyle(element).writingMode !== "horizontal-tb") { + const style = getComputedStyle(element); + if (style.writingMode !== "horizontal-tb") { return clipRectsToOverflowAncestors(element, glyphRects); } const box = toRect(element.getBoundingClientRect()); + const localHeight = element.offsetHeight; + const scale = localHeight > 0 ? box.height / localHeight : 1; + const contentTop = + box.top + (parsePx(style.borderTopWidth) + parsePx(style.paddingTop)) * scale; + const contentBottom = + box.bottom - (parsePx(style.borderBottomWidth) + parsePx(style.paddingBottom)) * scale; const topLine = glyphRects.reduce((top, rect) => (rect.top < top.top ? rect : top)); const bottomLine = glyphRects.reduce((bottom, rect) => rect.bottom > bottom.bottom ? rect : bottom, ); - const topSpill = box.top - topLine.top; - const bottomSpill = bottomLine.bottom - box.bottom; + const topSpill = contentTop - topLine.top; + const bottomSpill = bottomLine.bottom - contentBottom; const isBleed = (spill, line) => spill > 0 && spill <= line.height / 5; const clip = { - top: isBleed(topSpill, topLine) ? box.top : topLine.top, - bottom: isBleed(bottomSpill, bottomLine) ? box.bottom : bottomLine.bottom, + top: isBleed(topSpill, topLine) ? contentTop : topLine.top, + bottom: isBleed(bottomSpill, bottomLine) ? contentBottom : bottomLine.bottom, }; return clipRectsToOverflowAncestors(element, clampRectsTo(glyphRects, clip, false, true)); } diff --git a/packages/cli/src/commands/layout-audit.browser.test.ts b/packages/cli/src/commands/layout-audit.browser.test.ts index d290f363cd..21e005771b 100644 --- a/packages/cli/src/commands/layout-audit.browser.test.ts +++ b/packages/cli/src/commands/layout-audit.browser.test.ts @@ -1902,13 +1902,28 @@ describe("layout-audit.browser content overlap", () => { expect(issues.some((issue) => issue.code === "content_overlap")).toBe(true); }); + it("drops a clipped fragment of half a pixel or less instead of comparing it", () => { + // The clip leaves exactly 0.5px of a's line painted, and that sliver lies + // entirely inside b. Kept, it would be a 100% overlap of a's own area; it + // is sub-pixel anti-aliasing, not text, so it is discarded. + const issues = auditOverflowClippedOverlap({ + overflow: "hidden", + clipRect: rect({ left: 0, top: 0, width: 640, height: 200.5 }), + aTextRect: rect({ left: 100, top: 200, width: 300, height: 50 }), + bTextRect: rect({ left: 100, top: 200, width: 300, height: 50 }), + }); + + expect(issues.some((issue) => issue.code === "content_overlap")).toBe(false); + }); + // Overlap measures glyph rects with font-metric bleed past the element's own - // CSS box trimmed back to it: neither the bare font-metrics band nor the bare - // box. The scenes below pull the geometries apart so only that measurement - // gives the right answer. Spill counts as bleed up to a fifth of the spilling - // glyph rect's own height (its font content area): the box still encloses the - // cap band and only ascender/descender tips lie outside. Tighter than that is - // negative leading deep into the glyphs, and the spill is kept as real ink. + // content box trimmed back to it: neither the bare font-metrics band nor the + // bare box. The scenes below pull the geometries apart so only that + // measurement gives the right answer. Spill counts as bleed up to a fifth of + // the spilling glyph rect's own height (its font content area): the box still + // encloses the cap band and only ascender/descender tips lie outside. Tighter + // than that is negative leading deep into the glyphs, and the spill is kept + // as real ink. it("does not flag when only font-metrics bands overlap but the real CSS boxes just touch", () => { // A large font-size with a tight line-height keeps the range-rect height // while the real box shrinks around it: 15px above and below an 80px line. @@ -1930,6 +1945,95 @@ describe("layout-audit.browser content overlap", () => { expect(issues.some((issue) => issue.code === "content_overlap")).toBe(false); }); + it("does not flag the same bleed on a padded box when the neighbour sits at its content-box bottom", () => { + // The unpadded scene above with 20px of padding around it: the 50px content + // box now sits at 120..170 inside a 90px border box, and the same 15px of + // bleed lands in the padding band. Spill is measured against the content + // box, so the bleed is still trimmed and b, set against the content edge, + // stays clean — a border-box measurement would read the spill as 0 and + // leave the tips as ink for b to collide with. + const issues = auditOverlapScene({ + a: { + position: "absolute", + padding: 20, + textRect: rect({ left: 100, top: 105, width: 200, height: 80 }), + boxRect: rect({ left: 100, top: 100, width: 200, height: 90 }), + }, + b: { + position: "absolute", + textRect: rect({ left: 100, top: 170, width: 200, height: 50 }), + boxRect: rect({ left: 100, top: 170, width: 200, height: 50 }), + }, + }); + + expect(issues.some((issue) => issue.code === "content_overlap")).toBe(false); + }); + + it("counts the border toward the band the content box sits inside", () => { + // Same content box at 120..170, reached through 16px of padding plus a 4px + // border on each edge instead of 20px of padding alone. + const issues = auditOverlapScene({ + a: { + position: "absolute", + padding: 16, + borderWidth: 4, + textRect: rect({ left: 100, top: 105, width: 200, height: 80 }), + boxRect: rect({ left: 100, top: 100, width: 200, height: 90 }), + }, + b: { + position: "absolute", + textRect: rect({ left: 100, top: 170, width: 200, height: 50 }), + boxRect: rect({ left: 100, top: 170, width: 200, height: 50 }), + }, + }); + + expect(issues.some((issue) => issue.code === "content_overlap")).toBe(false); + }); + + it("scales the padding band with the rendered box under a transform", () => { + // The padded scene at scale 0.5: 40px of local padding renders as 20px, so + // the content box still sits at 120..170 inside the 90px rendered border + // box (180px in local px). Subtracting the local 40px instead would put the + // content edges at 140..150 and read the 15px bleed as 35px of real ink. + const issues = auditOverlapScene({ + a: { + position: "absolute", + padding: 40, + localBoxHeight: 180, + textRect: rect({ left: 100, top: 105, width: 200, height: 80 }), + boxRect: rect({ left: 100, top: 100, width: 200, height: 90 }), + }, + b: { + position: "absolute", + textRect: rect({ left: 100, top: 170, width: 200, height: 50 }), + boxRect: rect({ left: 100, top: 170, width: 200, height: 50 }), + }, + }); + + expect(issues.some((issue) => issue.code === "content_overlap")).toBe(false); + }); + + it("still keeps spill past a padded box's content edge that exceeds a fifth of the line", () => { + // A 100px line in a 50px content box at 120..170: 25px past each content + // edge, over a fifth of the line. The padding does not widen the bleed + // allowance, so b at the content edge collides. + const issues = auditOverlapScene({ + a: { + position: "absolute", + padding: 20, + textRect: rect({ left: 100, top: 95, width: 200, height: 100 }), + boxRect: rect({ left: 100, top: 100, width: 200, height: 90 }), + }, + b: { + position: "absolute", + textRect: rect({ left: 100, top: 170, width: 200, height: 50 }), + boxRect: rect({ left: 100, top: 170, width: 200, height: 50 }), + }, + }); + + expect(issues.some((issue) => issue.code === "content_overlap")).toBe(true); + }); + it("does not flag a box wider than its text just because the empty part of the box overlaps a neighbour", () => { // A short label in a wide absolutely positioned box: a box-based measurement // would flag the neighbour sitting in the box's empty right half. @@ -2024,27 +2128,10 @@ describe("layout-audit.browser content overlap", () => { expect(issues.some((issue) => issue.code === "content_overlap")).toBe(true); }); - it("flags a neighbour at the box edge of a line with deep negative leading", () => { - // line-height 0 on a 100px line: half the content area spills past each - // box edge and the box sits inside the cap band. The spill is dense glyph - // ink, so b, set against a's box bottom, collides for real. - const issues = auditOverlapScene({ - a: { - position: "absolute", - textRect: rect({ left: 100, top: 100, width: 200, height: 100 }), - boxRect: rect({ left: 100, top: 125, width: 200, height: 50 }), - }, - b: { - position: "absolute", - textRect: rect({ left: 100, top: 175, width: 200, height: 50 }), - boxRect: rect({ left: 100, top: 175, width: 200, height: 50 }), - }, - }); - - expect(issues.some((issue) => issue.code === "content_overlap")).toBe(true); - }); - it("flags a neighbour at the box edge of a 25px box holding a 100px line", () => { + // line-height at a quarter of the content area: 37-38px spills past each + // box edge and the box sits inside the cap band. The spill is dense glyph ink, + // so b, set against a's box bottom, collides for real. const issues = auditOverlapScene({ a: { position: "absolute", @@ -2143,20 +2230,22 @@ describe("layout-audit.browser content overlap", () => { }); it("leaves a vertical writing-mode column untrimmed, so its real overflow still collides", () => { - // In vertical-rl the glyph rect's height is the inline advance: 200px past - // a 300px column is genuine overflow, not bleed, whatever its share of the - // rect height. The clamp steps aside rather than swallow it. + // In vertical-rl the glyph rect's height is the inline advance, so the + // bleed rule does not apply: 80px past a 420px column is under a fifth of + // the 500px rect, which a horizontal block would trim as bleed, yet here + // it is genuine overflow. The clamp steps aside rather than swallow it, + // and b, set against the column's end, collides. const issues = auditOverlapScene({ a: { position: "absolute", writingMode: "vertical-rl", textRect: rect({ left: 100, top: 100, width: 60, height: 500 }), - boxRect: rect({ left: 100, top: 100, width: 60, height: 300 }), + boxRect: rect({ left: 100, top: 100, width: 60, height: 420 }), }, b: { position: "absolute", - textRect: rect({ left: 100, top: 450, width: 60, height: 100 }), - boxRect: rect({ left: 100, top: 450, width: 60, height: 100 }), + textRect: rect({ left: 100, top: 520, width: 60, height: 80 }), + boxRect: rect({ left: 100, top: 520, width: 60, height: 80 }), }, }); @@ -2625,7 +2714,17 @@ function expectExemptFromOverlap(aOverrides: { color?: string; attrs?: string }) expect(issues.some((issue) => issue.code === "content_overlap")).toBe(false); } -interface OverlapBlockInput { +// Vertical padding / border widths in local (untransformed) px, applied to both +// the top and bottom edges. They shrink the content box inside boxRect. +// localBoxHeight is offsetHeight, the border-box height in local px; leave it +// unset for an untransformed block, where it equals boxRect.height. +interface OverlapBoxStyle { + padding?: number; + borderWidth?: number; + localBoxHeight?: number; +} + +interface OverlapBlockInput extends OverlapBoxStyle { textRect: DOMRect | DOMRect[]; color?: string; attrs?: string; @@ -2640,6 +2739,7 @@ function overlapBlockRecords(blocks: Record): { clipPaths: Record; positions: Record; writingModes: Record; + boxStyles: Record; textRects: Record; elementRects: Record; } { @@ -2647,6 +2747,7 @@ function overlapBlockRecords(blocks: Record): { const clipPaths: Record = {}; const positions: Record = {}; const writingModes: Record = {}; + const boxStyles: Record = {}; const textRects: Record = {}; const elementRects: Record = {}; for (const [id, block] of Object.entries(blocks)) { @@ -2654,10 +2755,15 @@ function overlapBlockRecords(blocks: Record): { clipPaths[id] = block.clipPath ?? "none"; if (block.position) positions[id] = block.position; if (block.writingMode) writingModes[id] = block.writingMode; + boxStyles[id] = { + padding: block.padding, + borderWidth: block.borderWidth, + localBoxHeight: block.localBoxHeight, + }; textRects[id] = normalizeTextRects(block.textRect); if (block.boxRect) elementRects[id] = block.boxRect; } - return { colors, clipPaths, positions, writingModes, textRects, elementRects }; + return { colors, clipPaths, positions, writingModes, boxStyles, textRects, elementRects }; } function auditOverlapScene(options: { @@ -2671,11 +2777,16 @@ function auditOverlapScene(options: {
Block B copy
`; - const { colors, clipPaths, positions, writingModes, textRects, elementRects } = + const { colors, clipPaths, positions, writingModes, boxStyles, textRects, elementRects } = overlapBlockRecords({ a: options.a, b: options.b }); - installOverlapStyles(colors, clipPaths, {}, positions, writingModes); + installOverlapStyles(colors, clipPaths, {}, positions, writingModes, boxStyles); installOverlapGeometry(textRects, elementRects); + for (const [id, boxStyle] of Object.entries(boxStyles)) { + const element = document.getElementById(id); + if (!element || boxStyle.localBoxHeight == null) continue; + Object.defineProperty(element, "offsetHeight", { value: boxStyle.localBoxHeight }); + } installAuditScript(); return runAudit(); } @@ -2709,12 +2820,14 @@ function installOverlapStyles( overflows: Record = {}, positions: Record = {}, writingModes: Record = {}, + boxStyles: Record = {}, ): void { vi.spyOn(window, "getComputedStyle").mockImplementation((element) => { const id = (element as Element).id; return { display: "block", writingMode: writingModes[id] ?? "horizontal-tb", + ...boxStyleDeclaration(boxStyles[id]), // Mirrors the real browser default. Overlap geometry is the same for every // block, so position only decides whether a pair qualifies for the // same-flex/grid managed-flow waiver. @@ -2738,6 +2851,24 @@ function installOverlapStyles( }; } +// The vertical padding / border widths of an OverlapBoxStyle as computed-style +// px strings; an absent style is an unpadded, borderless box. +function boxStyleDeclaration(boxStyle: OverlapBoxStyle | undefined): { + paddingTop: string; + paddingBottom: string; + borderTopWidth: string; + borderBottomWidth: string; +} { + const padding = `${boxStyle?.padding ?? 0}px`; + const borderWidth = `${boxStyle?.borderWidth ?? 0}px`; + return { + paddingTop: padding, + paddingBottom: padding, + borderTopWidth: borderWidth, + borderBottomWidth: borderWidth, + }; +} + function installOverlapGeometry( textRects: Record, elementRects: Record = {},