fix(web): reserve one scrollbar gutter across the composer's text layers

Second review round escalated the wrap-width divergence from a separate
concern to a defect in this fix's own premise, and it is right.

Only .input scrolls, so only .input loses content width to a scrollbar that
consumes layout space — what Windows and Firefox draw, and what the theme's
global `::-webkit-scrollbar` width makes chromium treat as occupying space.
A narrower .input wraps a long soft-wrapped draft onto more lines, so it
grows taller, its scroll maximum exceeds the backdrop's, and the mirrored
offset clamps below the caret. That is the same failure the trailing-line
sentinel fixes, in the same direction, so deferring it would have shipped a
fix that does not hold where users run a classic scrollbar.

My first attempt to reproduce it found nothing and was wrong: the probe
content was not wrap-sensitive. With varied-length words the effect is
plain — the same draft laid out at 8px-apart widths differs by 2 to 5
lines, while at equal widths a textarea and a div agree exactly.

The three layers now reserve the gutter together, in the shared metrics
block that already exists to keep them symmetric. `overflow: hidden` is
still a scroll container, so the non-scrolling layers honour it: 8px is
reserved on each, measured. The cost is a text column 8px narrower on every
platform, which is the price of one geometry rather than a per-platform one.

The browser scenario asserts the premise directly — equal wrap widths, and a
reserved band greater than zero on each layer. The band is what stops the
assertion being vacuous: the widths would also match with no reservation at
all on this engine's overlay scrollbar, and it is the reservation, not the
match, that carries the guarantee to a platform whose scrollbar takes real
width. Removing the declaration fails it with `expected 0 to be greater
than 0`, and fails the golden with it.

Also from the same round, three comment corrections: the e2e file header no
longer describes the deleted layout effect, the measurement guard no longer
claims the backdrop holds exactly one text node (the sentinel makes a
second), and the sentinel comment now carries the one-sidedness argument
that also settles the ghost hint — the mirror only fails when the backdrop
is SHORTER, and the hint can only add content, never remove a line box.
This commit is contained in:
creatixchu
2026-07-31 12:39:52 +08:00
parent f8ef2cf36b
commit 2143361195
7 changed files with 98 additions and 12 deletions

View File

@@ -192,6 +192,20 @@
white-space: pre-wrap;
word-break: break-word;
overflow-wrap: anywhere;
/* Equal wrap width on every platform, which is what lets one scroll offset
be mirrored between the layers (InputBar's `scroll` listener). Only .input
scrolls, so only .input would otherwise lose width to a space-consuming
scrollbar — the kind Windows and Firefox draw, and the kind the theme's
`::-webkit-scrollbar` width makes chromium treat as occupying layout space
(see ui-theme styles/scrollbar.css and the sidebar-scrollbar scenario).
A narrower .input wraps a long soft-wrapped draft onto MORE lines than the
backdrop, so it grows taller, its scroll maximum exceeds the backdrop's,
and the mirrored offset clamps below the caret — the same failure the
trailing-line sentinel fixes, in the same direction. Reserving the gutter
on all three keeps the widths identical whatever that platform's
scrollbar costs; `overflow: hidden` is still a scroll container, so the
non-scrolling layers honour it. Measured: 8px reserved on each. */
scrollbar-gutter: stable;
}
/* figma 34:10434: #ADB2B8 light / #81858C dark — the caption pair exactly. */

View File

@@ -383,6 +383,13 @@ export function InputBar({
// offset at the very bottom clamps and the glyphs sit a line behind the
// caret. The extra newline is absorbed by that same collapse when the draft
// does not end in one, so it costs no height in the ordinary case.
//
// The mirror only fails one way — a backdrop SHORTER than the textarea
// clamps the assignment, while a taller one takes every offset exactly and
// hides the surplus below the clip. That is why the ghost hint needs no
// handling of its own: it can only add content after the draft and before
// this sentinel, never remove a line box, so it moves the pair to equal or
// to the safe side.
backdrop.push('\n')
}