Skip to content

fix: content_overlap uses the real CSS box for out-of-flow text - #3923

Open
miga-heygen wants to merge 4 commits into
mainfrom
fix/content-overlap-box-metrics
Open

miga-heygen wants to merge 4 commits into
mainfrom
fix/content-overlap-box-metrics

Conversation

@miga-heygen

@miga-heygen miga-heygen commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Summary

content_overlap's text measurement (textClientRects, via document.createRange().selectNodeContents(el); range.getClientRects()) reflects a font's own vertical metrics (ascent/descent), not the CSS box (getBoundingClientRect()). A large font-size with a tight line-height keeps the range-rect height regardless of line-height, while the real box shrinks around it. In-flow text blocks sharing a flex/grid ancestor already can't collide (an existing exemption), but nothing else has that guarantee, so a genuine CSS-box gap between two blocks — typically absolutely positioned data-card labels — could still measure as overlapping via font metrics: a false collision report with nothing actually unreadable on screen.

Fix

content_overlap now measures every solid text block the same way (overlapTextRects, the single place the rule is decided): the glyph rects, with font-metric bleed past the element's own content box trimmed back to it, then clipped by overflow ancestors as before. The content box, not the border box getBoundingClientRect() reports, is the reference: line boxes are laid out inside it, so with padding the tips spill into the padding band first. Measured against the border box that spill reads as zero and is left as ink, and a caption set against the content edge of a padded headline collides with tips the unpadded box would have trimmed (Chromium: line-height 0.7 with a caption at the box bottom is clean; the same ink with padding: 20px and the caption at the content-box bottom was flagged). The vertical padding and border widths come off the border box before the spill is measured, scaled by the ratio of the rendered border-box height to offsetHeight (the same box in local px), since the client rect is post-transform while the computed widths are not — otherwise a scaled-down block would subtract too much and read its own padding band as spill.

Neither raw geometry is right on its own. Range rects over-report through half-leading; the bare box over-reports the other way (a box wider or taller than the text it holds would register collisions its glyphs never make, and swapping to the box for some blocks but not others compares unlike geometries within a pair). So the box is only used to trim spill that is sparse ink. With negative leading (line-height below the font's content area) each line spills (content-area − line-height) / 2 past each box edge. A side is trimmed only while that spill is at most a fifth of the line's own rect height, i.e. line-height at least 60% of the content area (about 0.7em): the box still encloses the x-height and cap band, only ascender/descender tips lie outside, and a neighbour set against the box edge shares about 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 roughly 0.4em down) — so it is kept and still collides, as on main. A further wrapped line spills (line-height + content-area) / 2, always more, and is kept too. The bound is measured from the rect itself, so it holds under any transform scale (an entrance punch-in sampled mid-animation gives the same verdict as the resting frame). Horizontal spill is by nature never bleed and is never trimmed. This matters because a bare unpainted box is not an overflow constraint, so text spilling out of such a box into a neighbour is only ever reported by content_overlap.

The per-rect intersection previously inlined in the ancestor overflow-clip loop is now a shared clampRectsTo helper used by both that loop and the content-box trim.

Known limitations, documented at the rule and left for follow-ups:

  • Text wrapped in a bare inline element (a span, split-text spans, <strong>/<em>) is its own solid block, and an inline box already follows the font content area, so the trim does not help there; trimming against the nearest block-level ancestor's box would.
  • A box that cuts a line within a fifth of its height is indistinguishable from bleed and is trimmed with it.
  • isManagedFlowOverlap (the same-flex/grid waiver) still waives flex/grid children whose glyphs genuinely overlap (e.g. a negative margin-top in a flex column, 29% shared ink) — pre-existing, unchanged here.
  • clipRectsToOverflowAncestors starts at the parent, so an element's own overflow: hidden (line-clamp/truncation patterns) is not applied to its glyph rects — pre-existing.

Test plan

New fixtures in layout-audit.browser.test.ts (the overlap style mock previously reported no position; it now defaults to static like a real browser):

  • Font-metrics bands overlap but the real CSS boxes just touch (15px bleed on an 80px line, neighbour set against the box edge) → not flagged; the same bleed spilling upward into a neighbour above → not flagged.
  • Box wider than its text, and box taller than its text, with a neighbour in the empty part of the box → not flagged (a bare-box measurement would flag both).
  • Spill of exactly a fifth of the line's rect height → trimmed; 21px of a 100px line → kept and flagged.
  • Deep negative leading (a 100px line in a 25px box) with a neighbour at the box edge → flagged.
  • Padded box (padding: 20px, 50px content box inside a 90px border box) with the same 15px bleed and the neighbour at the content-box bottom → not flagged; the same with 16px padding plus a 4px border → not flagged; a 100px line spilling 25px past each content edge of a padded box → still flagged (padding does not widen the allowance); the padded scene under scale(0.5) (40px local padding rendering as 20px, offsetHeight 180 for a 90px rendered box) → not flagged.
  • Text overflowing its own 50px box by 100px into a neighbour → still flagged (an unbounded clamp would drop it).
  • Two-line block whose box cuts 60px off the 100px second line → still flagged (the bound is per line, not per block).
  • Nowrap run spilling past its box's right edge into a neighbour → still flagged.
  • writing-mode: vertical-rl column whose text runs 80px past a 420px box — under a fifth of the 500px rect, which a horizontal block would trim — → left untrimmed and still flagged (vertical modes swap the axes, so the clamp steps aside rather than trim real overflow).
  • Overflow clip leaving exactly 0.5px of a line inside a neighbour → the sliver is dropped, not flagged.
  • Multi-line block bleeding only on its first and last lines → trimmed, not flagged.
  • In-flow block paired with an absolute block, and two in-flow blocks outside any flex/grid container, with bleed-only overlap → not flagged (same geometry on both sides of every pair; the second case was previously flagged).
  • Genuine overlap between absolutely positioned boxes, and glyphs genuinely colliding inside two wide boxes → still flagged.

Mutation checks run against these fixtures: raw glyph rects (main) fails the bleed fixtures; bare box for out-of-flow blocks fails the box-larger-than-text and overflow fixtures; an unbounded clamp fails the overflow fixtures; a half-height bound fails 3 negative-leading fixtures; never trimming above fails 1; never trimming below fails 2; measuring against the border box instead of the content box fails the 3 padded/bordered/scaled fixtures; subtracting the computed widths unscaled fails the scaled fixture; deleting the writing-mode guard fails the vertical-rl fixture; loosening the sliver drop to < 0.5 fails the sliver fixture.

Real-Chromium ink oracle (Liberation Sans 100px, stacked absolute blocks with the lower box starting at the upper box's bottom edge; shared ink as % of the smaller block's ink): line-height 0.7 → 1.4% shared, main flags, this branch clean (the false positive being fixed; same with a 10px box gap, under a scaled ancestor, on the last line of a multi-line block, and with the neighbour above). Line-height 0.8 headline with a caption at its box bottom → 9.1%, clean. Line-height 0.6 → 15.4%, 0.5 → 11.6%, 0.4 → 22.4%, 0.25 → 30.3%, 0.1 → 27.9%, 2px → 41.7%, 0.5 with a caption at the box edge → 24.7%: all flagged, matching main. Wide-box/tall-box short text → 0%, clean; genuine 30px box overlap, vertical-rl overflow, caption over descenders → flagged.

layout-audit.browser.test.ts under its default happy-dom environment: 141 passed. (The earlier No such built-in module: node: collection failure was NODE_ENV=production in the shell: vite then externalises node: builtins to a bare __vite-browser-external id that vitest cannot map back; the suite collects normally with NODE_ENV unset.) tsc --noEmit -p packages/cli reports no errors in packages/cli, oxlint/oxfmt --check clean on both touched files.

miga-heygen and others added 2 commits September 13, 2026 03:50
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>
… box

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 <miguel.sierra@heygen.com>
@miga-heygen

Copy link
Copy Markdown
Contributor Author

Addressed in 6ddf660.

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 <miguel.sierra@heygen.com>
@miga-heygen

Copy link
Copy Markdown
Contributor Author

Addressed in 18d90db.

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 <miguel.sierra@heygen.com>
@miga-heygen

Copy link
Copy Markdown
Contributor Author

Addressed in 1843c55.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant