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 <miguel.sierra@heygen.com>
This commit is contained in:
miga-heygen
2026-09-13 03:50:43 +00:00
co-authored by Miguel Angel
parent 4aa6e17b76
commit f8d48bfcda
2 changed files with 107 additions and 21 deletions
@@ -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 });
}
@@ -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<string, OverlapBlockInput>): {
colors: Record<string, string>;
clipPaths: Record<string, string>;
positions: Record<string, string>;
textRects: Record<string, DOMRect[]>;
elementRects: Record<string, DOMRect>;
} {
const colors: Record<string, string> = {};
const clipPaths: Record<string, string> = {};
const positions: Record<string, string> = {};
const textRects: Record<string, DOMRect[]> = {};
const elementRects: Record<string, DOMRect> = {};
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<typeof runAudit> {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="1920" data-height="1080" ${options.rootAttrs ?? ""}>
@@ -2300,21 +2370,13 @@ function auditOverlapScene(options: {
<div id="b" ${options.b.attrs ?? ""}>Block B copy</div>
</div>
`;
const colors: Record<string, string> = {
a: options.a.color ?? "rgb(0, 0, 0)",
b: options.b.color ?? "rgb(0, 0, 0)",
};
const clipPaths: Record<string, string> = {
a: options.a.clipPath ?? "none",
b: options.b.clipPath ?? "none",
};
const textRects: Record<string, DOMRect[]> = {
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<string, string>,
clipPaths: Record<string, string>,
overflows: Record<string, string> = {},
positions: Record<string, string> = {},
): 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)",