From 2143361195b9ef39eca9772ec7c1f39f9f7d00e5 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 31 Jul 2026 12:39:52 +0800 Subject: [PATCH] fix(web): reserve one scrollbar gutter across the composer's text layers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ...-glyph-layer-tracks-the-textarea.i18n.yaml | 4 +- ...omposer-glyph-layer-tracks-the-textarea.md | 13 ++++- ...oser-glyph-layer-tracks-the-textarea.zh.md | 13 ++++- apps/web/tests/composer-draft-scroll.e2e.ts | 57 +++++++++++++++++-- .../geometry.expected.md | 2 + .../src/client/skeleton/InputBar.module.css | 14 +++++ .../src/client/skeleton/InputBar.tsx | 7 +++ 7 files changed, 98 insertions(+), 12 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-composer-glyph-layer-tracks-the-textarea.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-31-composer-glyph-layer-tracks-the-textarea.i18n.yaml index b0106b0de6..38042c1833 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-composer-glyph-layer-tracks-the-textarea.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-31-composer-glyph-layer-tracks-the-textarea.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-31-composer-glyph-layer-tracks-the-textarea.md -2026-07-31-composer-glyph-layer-tracks-the-textarea.md: bcbf88be58dd57ab7643f4c16886874355d2d9d4 -2026-07-31-composer-glyph-layer-tracks-the-textarea.zh.md: 7547e0de310f83dc4ccdda122ae435e923895a25 +2026-07-31-composer-glyph-layer-tracks-the-textarea.md: 4c4d36b54ff880de38d8db8a5d3121c555a613d7 +2026-07-31-composer-glyph-layer-tracks-the-textarea.zh.md: fa3e16e8d8298b8caf9882e2d40882db909ae79c diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-composer-glyph-layer-tracks-the-textarea.md b/.agents/notes/implemented/bug-fix/2026-07-31-composer-glyph-layer-tracks-the-textarea.md index bcbf88be58..4c4d36b54f 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-composer-glyph-layer-tracks-the-textarea.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-composer-glyph-layer-tracks-the-textarea.md @@ -20,7 +20,9 @@ The defect is therefore exactly as old as the cap, and it hid behind the resting One listener is the whole coupling, because every way the box moves ends in a `scroll` event on the textarea. A gesture scrolls it; an edit scrolls the caret into view; a draft that shrinks past the current offset clamps it. The clamp case is the one that looks like it needs separate handling and does not: the two layers share an extent, so they clamp to the same maximum, and the textarea's clamp fires the `scroll` that mirrors it. -That shared extent is not free, and mirroring an offset is only correct while it holds. A textarea reserves a line box for the caret after a final newline; `white-space: pre-wrap` collapses a text node's trailing newline and generates none. A draft ending in a newline therefore made the backdrop exactly one line shorter than the textarea — measured 628 against 652 — so the assignment clamped and the glyphs sat a line behind the caret at the very bottom. The backdrop now carries the same trailing-line sentinel the mirror div already did: its content is the decoration walk plus one `'\n'`, which the same collapse absorbs when the draft does not end in a newline and which supplies the missing line box when it does. Measured across plain, trailing-newline, soft-wrapping, unbreakable-run, and interior-blank-line drafts, the two extents now agree in every case. +That shared extent is not free, and mirroring an offset is only correct while it holds. Two things break it, both discovered in review, both failing in the same direction — a backdrop shorter than the textarea, so the assignment clamps and the glyphs sit below the caret. A textarea reserves a line box for the caret after a final newline; `white-space: pre-wrap` collapses a text node's trailing newline and generates none. A draft ending in a newline therefore made the backdrop exactly one line shorter than the textarea — measured 628 against 652 — so the assignment clamped and the glyphs sat a line behind the caret at the very bottom. The backdrop now carries the same trailing-line sentinel the mirror div already did: its content is the decoration walk plus one `'\n'`, which the same collapse absorbs when the draft does not end in a newline and which supplies the missing line box when it does. Measured across plain, trailing-newline, soft-wrapping, unbreakable-run, and interior-blank-line drafts, the two extents now agree in every case. + +The second is wrap width. 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 and its maximum exceeds the backdrop's. The three layers now reserve the gutter together (`scrollbar-gutter: stable` in the shared metrics block), which keeps their widths identical whatever the platform's scrollbar costs; `overflow: hidden` is still a scroll container, so the non-scrolling layers honour it. Measured on a standalone harness, the divergence this prevents is worth 2 to 5 lines: the same wrap-sensitive draft laid out at 8px-apart widths differs by that much, while at equal widths a textarea and a div agree exactly. The mirror is one-directional: the textarea is the authority because it owns the caret, and the caret is what the browser scrolls to. @@ -34,6 +36,10 @@ The mirror is one-directional: the textarea is the authority because it owns the **Scroll the backdrop from the existing wheel handler instead of a `scroll` listener.** The handler already runs on every wheel over the textarea, so it looks like the natural place. Rejected because it covers only one of the ways the box scrolls: typing at the end, `End`, arrow keys, drag-selection past the edge, and scrollbar drags all move the textarea without a wheel event. Listening to `scroll` is listening to the thing itself rather than to one of its causes. +**Leave the wrap-width divergence as a separate issue.** It predates this change, it is invisible on the engine the browser lane runs (headless chromium draws an overlay scrollbar, so both layers measured `clientWidth` 776 against a 776 border box), and it also causes a resting-state misalignment that has nothing to do with scrolling. Rejected on review: the resting misalignment is a consequence, not the whole defect. Unequal wrap widths make the textarea taller, which is exactly the precondition that makes a mirrored offset clamp, so deferring it would ship a fix that does not hold on the platforms where users run a space-consuming scrollbar. + +**Suppress the textarea's scrollbar instead of reserving a gutter on the other layers.** `scrollbar-width: none` on `.input` would equalize the widths without narrowing the text column. Rejected because the composer deliberately shows a thumb once the draft passes the cap — `.card` binds the l2 scrollbar tokens for exactly that — and removing it takes away the only affordance that says a long draft continues below. + **Translate the backdrop with `transform: translateY(-scrollTop)` instead of scrolling it.** A transform is not clamped by content height, so it would paper over any extent divergence — including the trailing-newline one — without matching the layers. Rejected because the divergence is the actual defect: unequal extents also mean the two layers disagree about where the last line sits, and hiding that behind an unclamped transform would leave a mismatch that resurfaces the moment anything measures the backdrop. Fixing the extent keeps one truth about the draft's height. **Add a second mirror in a layout effect keyed on the committed draft.** This shipped in the first version of the change, on the theory that an edit reflows both layers without necessarily moving the textarea, and that a shrinking draft clamps each layer independently. Both premises are false, and it was removed after mutation-testing each hook alone against the built client: with only the layout effect disabled the browser scenario stays green, while disabling only the `scroll` listener fails it. Typing scrolls the caret into view, which is an ordinary `scroll`; a shrinking draft clamps both layers to the same maximum because their extents are equal, and the textarea's clamp fires `scroll` too. The specific hazard the effect was imagined to cover — React replacing the backdrop's children when the decoration set changes shape, resetting its offset — does not occur: measured in chromium, replacing every child of an `overflow: hidden` box preserves `scrollTop` (300 stays 300), and the only replacement that zeroes it is one that shrinks the content below the offset, which is the clamp case already covered. @@ -46,13 +52,14 @@ The mirror is one-directional: the textarea is the authority because it owns the - The coupling is one-directional and cheap — one assignment of one number, no measurement, no layout read beyond `scrollTop` — so it adds nothing to the typing path's cost. - Chips, claim-token highlights, and text-ref marks stay aligned with their glyphs while scrolled, because they are positioned inside the backdrop and move with it. Nothing about the decoration walk changes. - The composer's two-layer design keeps this hazard: any future layer added beside the backdrop needs the same mirroring, and any change to how a layer reserves its last line box breaks the extent equality the mirror depends on. The e2e scenario asserts both — the relation the user cares about (which line is on screen) and the extent equality underneath it — so a future divergence fails on the invariant rather than on a screenshot. -- Extent equality is asserted, not assumed. It is the one premise that turns "mirror the offset" from correct into subtly wrong, and it failed for the trailing-newline shape before the sentinel. +- Extent equality is asserted, not assumed. It is the premise that turns "mirror the offset" from correct into subtly wrong, and it failed twice before this change: for the trailing-newline shape, and for any soft-wrapped draft on a platform whose scrollbar consumes width. +- The composer's text column is 8px narrower on every platform, including those that never draw a space-consuming scrollbar. That is the price of one geometry instead of a per-platform one, and it is paid in the layer metrics rather than in a conditional. ## Testing The unit spec in [input-bar.spec.tsx](../../../../packages/client/ui-conversation/tests/input-bar.spec.tsx) proves the mirroring path runs: it stubs both offsets, because jsdom reports `scrollHeight === clientHeight` for every element and never scrolls one, and asserts the backdrop follows the textarea to a new offset and back to the top. Reverting the `ref` makes it fail. -The user-visible fact needs a real engine, so [composer-draft-scroll.e2e.ts](../../../../apps/web/tests/composer-draft-scroll.e2e.ts) measures it in chromium against the built client: a 40-line draft in a fresh workspace's blank composer, zero model calls, with a DOM Range over the backdrop's own text reporting where the first and last lines sit relative to the visible box. A vacuity guard asserts the draft actually overflows the capped box first. A separate case drives the trailing-newline shape and asserts the two extents are equal before asserting the glyphs reach the end; each layer's maximum is observed by asking for an impossible offset and reading back the clamp, not computed from `scrollHeight`. +The user-visible fact needs a real engine, so [composer-draft-scroll.e2e.ts](../../../../apps/web/tests/composer-draft-scroll.e2e.ts) measures it in chromium against the built client: a 40-line draft in a fresh workspace's blank composer, zero model calls, with a DOM Range over the backdrop's own text reporting where the first and last lines sit relative to the visible box. A vacuity guard asserts the draft actually overflows the capped box first. A separate case drives the trailing-newline shape and asserts the two extents are equal before asserting the glyphs reach the end; each layer's maximum is observed by asking for an impossible offset and reading back the clamp, not computed from `scrollHeight`. A third asserts the gutter premise: equal wrap widths, and a reserved band greater than zero on each layer. The band is what keeps that assertion from 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. Confirmed both directions against the built client. With the mirroring reverted and the packages rebuilt, the wheel case fails on the layer offsets, the typing case fails with it, and the golden diff reads `last draft line is on screen: false` while `textarea moved: true` — the reported symptom stated as a fixture. The resting-state case passes in both builds, which is the point: it is the state that hid the defect. diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-composer-glyph-layer-tracks-the-textarea.zh.md b/.agents/notes/implemented/bug-fix/2026-07-31-composer-glyph-layer-tracks-the-textarea.zh.md index 7547e0de31..fa3e16e8d8 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-composer-glyph-layer-tracks-the-textarea.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-composer-glyph-layer-tracks-the-textarea.zh.md @@ -20,7 +20,9 @@ Status: implemented 一个监听即构成完整耦合,因为这个盒子移动的每一种方式最终都会在 textarea 上产生 `scroll` 事件:手势使它滚动;编辑会把光标滚入可见范围;草稿缩短到当前偏移之下时它会被钳位。看似需要单独处理、实则不需要的正是钳位这一种:两层共享同一滚动范围,因此它们会钳位到同一个最大值,而 textarea 的钳位本身就会触发那次完成镜像的 `scroll`。 -这个「共享的滚动范围」并非白得,而镜像偏移只有在它成立时才是正确的。textarea 会在末尾换行之后为光标保留一个行盒,而 `white-space: pre-wrap` 会折叠文本节点的尾随换行、不生成任何行盒。因此以换行结尾的草稿会让 backdrop 恰好比 textarea 少一行——实测为 628 对 652——于是该赋值被钳制,滚到最底部时字形比光标落后一行。现在 backdrop 也带上了镜像层早已具备的同一枚尾行哨兵:其内容为装饰扫描的结果再加一个 `'\n'`;草稿不以换行结尾时它被同一次折叠吸收,以换行结尾时它补上缺失的那个行盒。对纯文本、尾随换行、软折行、不可断长串以及中间空行五类草稿实测,两侧范围在每种情形下均相等。 +这个「共享的滚动范围」并非白得,而镜像偏移只有在它成立时才是正确的。有两件事会破坏它,都是在审查中被发现的,且失效方向相同——backdrop 比 textarea 矮,于是赋值被钳制、字形落到光标之下。textarea 会在末尾换行之后为光标保留一个行盒,而 `white-space: pre-wrap` 会折叠文本节点的尾随换行、不生成任何行盒。因此以换行结尾的草稿会让 backdrop 恰好比 textarea 少一行——实测为 628 对 652——于是该赋值被钳制,滚到最底部时字形比光标落后一行。现在 backdrop 也带上了镜像层早已具备的同一枚尾行哨兵:其内容为装饰扫描的结果再加一个 `'\n'`;草稿不以换行结尾时它被同一次折叠吸收,以换行结尾时它补上缺失的那个行盒。对纯文本、尾随换行、软折行、不可断长串以及中间空行五类草稿实测,两侧范围在每种情形下均相等。 + +第二件是折行宽度。只有 `.input` 会滚动,因此也只有 `.input` 会把内容宽度让给一条占布局宽度的滚动条——Windows 与 Firefox 画的就是这种,主题全局声明的 `::-webkit-scrollbar` 宽度也会让 chromium 按占宽处理。`.input` 一旦更窄,长的软折行草稿就会折出更多行,于是它更高、其最大偏移超过 backdrop。现在三层一起预留该 gutter(共享度量块中的 `scrollbar-gutter: stable`),无论该平台的滚动条要占多少宽度,三层宽度都保持一致;`overflow: hidden` 仍然是滚动容器,因此不滚动的那两层同样遵守该声明。在独立环境实测,它所避免的偏差值 2 到 5 行:同一份对折行敏感的草稿在相差 8px 的两个宽度下相差这么多,而在宽度相等时 textarea 与 div 完全一致。 该镜像是单向的:textarea 是权威方,因为它持有光标,而浏览器滚动的目标正是光标。 @@ -34,6 +36,10 @@ Status: implemented **在既有的滚轮处理函数里滚动 backdrop,而不是新增 `scroll` 监听。** 该处理函数本就在 textarea 上的每次滚轮时运行,看似是自然的落点。之所以否决,是它只覆盖了盒子滚动的其中一种成因:在末尾输入、`End`、方向键、拖选越过边缘、拖动滚动条,都会在没有滚轮事件的情况下移动 textarea。监听 `scroll` 是在监听事情本身,而不是它的某一个成因。 +**把折行宽度偏差留作独立议题。** 它先于本 PR 存在,在浏览器测试通道所用引擎上不可见(headless chromium 画 overlay 滚动条,两层实测 `clientWidth` 均为 776、边框盒也是 776),而且它还会造成一个与滚动无关的静止态错位。审查后否决:静止态错位只是后果之一,而非缺陷全貌。折行宽度不等会让 textarea 更高,而那恰恰是让镜像偏移被钳制的前置条件;把它推迟,等于交付一个在用户实际使用占宽滚动条的平台上并不成立的修复。 + +**改为抑制 textarea 的滚动条,而不是给另外两层预留 gutter。** 在 `.input` 上写 `scrollbar-width: none` 同样能让宽度相等,且不必收窄文本列。之所以否决:草稿超过上限后 composer 是有意显示滚动条滑块的——`.card` 正是为此绑定了 l2 滚动条 token——去掉它就等于拿走了「下面还有内容」这一唯一提示。 + **改用 `transform: translateY(-scrollTop)` 平移 backdrop,而不是滚动它。** transform 不受内容高度钳制,因此它能把任何范围偏差——包括尾随换行这一种——一并掩盖,却并不让两层真正对齐。之所以否决,是因为这个偏差本身就是真正的缺陷:范围不等同时意味着两层对末行位置的判断不一致,把它藏在一个不受钳制的 transform 之后,只会让这一失配在任何人去测量 backdrop 的那一刻重新浮现。修正范围本身,才能让草稿高度只有一个事实来源。 **再加一个以已提交草稿为 key 的 layout effect 作为第二道镜像。** 该改动的第一版确实带着它,理由是:一次编辑会让两层重排却不一定让 textarea 移动,且草稿变短时两层各自独立地被钳位。这两个前提都不成立,因此在针对构建产物客户端逐个变异测试每个 hook 之后将其移除:仅禁用 layout effect 时浏览器场景全绿,而仅禁用 `scroll` 监听则会失败。输入会把光标滚入可见范围,那就是一次普通的 `scroll`;草稿变短时两层因范围相等而钳位到同一个最大值,且 textarea 的钳位同样会触发 `scroll`。该 effect 本想覆盖的那个具体隐患——React 在装饰集合形状变化时替换 backdrop 的全部子节点,从而重置其偏移——并不会发生:在 chromium 中实测,替换一个 `overflow: hidden` 盒子的全部子节点会保留 `scrollTop`(300 仍为 300),唯一会将其归零的替换是把内容缩短到偏移之下,而那正是已被覆盖的钳位情形。 @@ -46,13 +52,14 @@ Status: implemented - 该耦合是单向且廉价的——一次对一个数字的赋值,没有测量,除 `scrollTop` 外没有额外的布局读取——因此不会给输入路径增加开销。 - chip、claim token 高亮与文本引用标记在滚动时始终与其字形对齐,因为它们定位在 backdrop 内部并随之移动。装饰扫描本身没有任何改动。 - composer 的双层设计保留了这一隐患:日后在 backdrop 旁新增的任何一层都需要同样的镜像;而任何改变某一层如何保留其末行行盒的改动,都会破坏镜像所依赖的范围相等性。e2e 场景对两者都做了断言——用户真正关心的关系(哪一行在屏幕上),以及其下的范围相等性——因此日后一旦出现偏差,失败会落在不变量上,而不是落在某张截图上。 -- 范围相等性是被断言的,而非被假定的。它正是那个能把「镜像偏移」从正确变为微妙错误的唯一前提,并且在加入哨兵之前,它在尾随换行这一形态上确实不成立。 +- 范围相等性是被断言的,而非被假定的。它正是那个能把「镜像偏移」从正确变为微妙错误的前提,并且在本次改动之前它两度不成立:尾随换行形态,以及在滚动条占宽的平台上任何软折行草稿。 +- composer 的文本列在所有平台上都窄了 8px,包括那些根本不会画占宽滚动条的平台。这是「只保留一套几何」而非「按平台分叉」所付的代价,且这笔代价付在图层度量里,而不是付在一个条件分支里。 ## 验证 [input-bar.spec.tsx](../../../../packages/client/ui-conversation/tests/input-bar.spec.tsx) 中的单元用例证明镜像路径确实执行:它对两侧偏移都做了桩替换——因为 jsdom 对任何元素都报告 `scrollHeight === clientHeight` 且从不滚动任何元素——并断言 backdrop 既跟随 textarea 到新的偏移,也跟随它回到顶部。撤掉那个 `ref` 会让它失败。 -用户可见的事实需要真实引擎,因此 [composer-draft-scroll.e2e.ts](../../../../apps/web/tests/composer-draft-scroll.e2e.ts) 在 chromium 中针对构建产物客户端测量它:在全新工作区空白会话的 composer 中放入 40 行草稿,零模型调用,用一个跨越 backdrop 自身文本的 DOM Range 报告首行与末行相对于可见盒子的位置。一个防空转守卫会先断言草稿确实溢出了设有上限的盒子。另有一个独立用例驱动尾随换行这一形态,先断言两侧范围相等,再断言字形确实抵达末尾;每一层的最大值都通过请求一个不可能的偏移再读回其钳位结果来观测,而非由 `scrollHeight` 计算得出。 +用户可见的事实需要真实引擎,因此 [composer-draft-scroll.e2e.ts](../../../../apps/web/tests/composer-draft-scroll.e2e.ts) 在 chromium 中针对构建产物客户端测量它:在全新工作区空白会话的 composer 中放入 40 行草稿,零模型调用,用一个跨越 backdrop 自身文本的 DOM Range 报告首行与末行相对于可见盒子的位置。一个防空转守卫会先断言草稿确实溢出了设有上限的盒子。另有一个独立用例驱动尾随换行这一形态,先断言两侧范围相等,再断言字形确实抵达末尾;每一层的最大值都通过请求一个不可能的偏移再读回其钳位结果来观测,而非由 `scrollHeight` 计算得出。第三个用例断言 gutter 前提:折行宽度相等,且每层预留的带宽大于零。正是这条「带宽」使该断言不至于空转——在本引擎的 overlay 滚动条下,即使完全不预留,两侧宽度也会相等;把保证传递到滚动条真正占宽的平台上的,是那次预留,而不是这次相等。 已双向确认。撤掉镜像并重新构建各包后,滚轮用例在两层偏移上失败,输入用例随之失败,golden 差异读作 `last draft line is on screen: false` 而 `textarea moved: true`——即以 fixture(测试前置数据)形式陈述的原始现象。静止状态用例在两种构建下都通过,这正是要点所在:它就是掩盖了该缺陷的那个状态。 diff --git a/apps/web/tests/composer-draft-scroll.e2e.ts b/apps/web/tests/composer-draft-scroll.e2e.ts index 6cd49c2be5..60709060d6 100644 --- a/apps/web/tests/composer-draft-scroll.e2e.ts +++ b/apps/web/tests/composer-draft-scroll.e2e.ts @@ -13,7 +13,16 @@ // So past the cap the textarea scrolled and the words did not: the caret walked // off the bottom of a block of text frozen at line 1, and no gesture — wheel, // drag, arrow key — moved it. `InputBar` now mirrors the offset onto the -// backdrop on every textarea `scroll` and after every committed draft. +// backdrop on every textarea `scroll`, which is the one event every way of +// moving the box ends in. +// +// Mirroring an offset is only correct while both layers can reach it, so two +// pieces of geometry are asserted here alongside the visible outcome: the +// backdrop's trailing-line sentinel (a textarea reserves a line box for the +// caret after a final newline; `pre-wrap` collapses one) and the scrollbar +// gutter reserved on all three layers (only the textarea scrolls, so only it +// would lose wrap width to a space-consuming scrollbar). Either one breaks the +// extent equality, and an unreachable offset clamps the glyphs below the caret. // // Only a real engine can show this. Scrolling is layout: jsdom reports // `scrollHeight === clientHeight` for every element and never scrolls one, so @@ -95,6 +104,19 @@ interface ComposerMetrics { inputMax: number /** Furthest the backdrop can scroll — equal to `inputMax`, or the mirror clamps below the caret. */ backdropMax: number + /** Content width the textarea wraps at. */ + inputWrapWidth: number + /** Content width the backdrop wraps at — equal, or the layers break lines in different places. */ + backdropWrapWidth: number + /** + * Width each layer holds back for a scrollbar. Non-zero is the proof the + * reservation is in force: were it absent, both would read 0 here and stay + * equal on this engine's overlay scrollbar while diverging on a platform + * whose scrollbar consumes layout width. + */ + inputGutter: number + /** The same reservation on the clipped layer, which never draws a scrollbar. */ + backdropGutter: number } /** @@ -109,10 +131,12 @@ function measureComposer(page: Page): Promise { const backdrop = input.parentElement?.querySelector('[data-input-backdrop]') if (backdrop === undefined || backdrop === null) throw new Error('no decoration backdrop beside the composer textarea') const box = input.getBoundingClientRect() - // The draft carries no chips or claim token, so the backdrop holds exactly - // one text node — the plain-text arm of the decoration walk. + // The draft carries no chips or claim token, so the decoration walk emits it + // as one text node — the backdrop's first, ahead of the trailing-line + // sentinel React renders as a second one. Both markers live in that first + // node, which is what the Range below needs. const text = backdrop.firstChild - if (!(text instanceof Text)) throw new Error('backdrop is not a single plain text node') + if (!(text instanceof Text)) throw new Error('backdrop does not open with a plain text node') const offsetOf = (marker: string): number => { const at = text.data.indexOf(marker) if (at < 0) throw new Error(`marker ${marker} missing from the backdrop text`) @@ -136,6 +160,10 @@ function measureComposer(page: Page): Promise { return { inputMax, backdropMax, + inputWrapWidth: input.clientWidth, + backdropWrapWidth: backdrop.clientWidth, + inputGutter: Math.round(input.getBoundingClientRect().width - input.clientWidth), + backdropGutter: Math.round(backdrop.getBoundingClientRect().width - backdrop.clientWidth), overflows: input.scrollHeight > input.clientHeight, clientHeight: input.clientHeight, visibleLines: Math.floor(input.clientHeight / lineHeight), @@ -170,6 +198,8 @@ function renderGeometry(top: ComposerMetrics, bottom: ComposerMetrics, trailingN `- draft overflows the capped box: ${String(top.overflows)}`, `- visible lines: ${String(top.visibleLines)}`, `- both layers share one scroll extent: ${String(top.inputMax === top.backdropMax)}`, + `- both layers wrap at one width: ${String(top.inputWrapWidth === top.backdropWrapWidth)}`, + `- scrollbar gutter reserved on each layer: ${String(top.inputGutter)}px / ${String(top.backdropGutter)}px`, `- textarea scroll offset: ${String(top.inputScrollTop)}px`, `- glyph layer tracks it: ${String(top.layersAgree)}`, `- first draft line is on screen: ${String(top.firstLineOffset >= 0 && top.firstLineOffset < top.clientHeight)}`, @@ -239,6 +269,25 @@ describe('web e2e: composer draft scrolling', () => { expect(tripwire.pageErrors).toEqual([]) }, 60_000) + it('reserves one scrollbar gutter on every text layer, so they wrap at one width', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-gutter')) + // The premise under the mirror. Only the textarea scrolls, so only it would + // lose width to a space-consuming scrollbar — and a narrower textarea wraps + // a long draft onto more lines than the backdrop, ending up taller, with a + // larger maximum, which makes the mirrored offset clamp below the caret. + // Measured on a standalone harness: an 8px width difference moves a + // wrap-sensitive draft by 2 to 5 lines. + const metrics = await measureComposer(page) + expect(metrics.inputWrapWidth).toBe(metrics.backdropWrapWidth) + // Not a tautology on this engine: the widths would also match with no + // reservation at all, because headless chromium draws an overlay scrollbar. + // The reserved band is what distinguishes the two states, and it is what + // carries the guarantee to a platform whose scrollbar takes real width. + expect(metrics.inputGutter).toBeGreaterThan(0) + expect(metrics.backdropGutter).toBe(metrics.inputGutter) + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + it('a wheel gesture over a long draft moves the words, not only the caret', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-wheel')) const input = page.locator('textarea:enabled').first() diff --git a/apps/web/tests/snapshots/composer-draft-scroll/geometry.expected.md b/apps/web/tests/snapshots/composer-draft-scroll/geometry.expected.md index bfcb273827..c7b934e5ff 100644 --- a/apps/web/tests/snapshots/composer-draft-scroll/geometry.expected.md +++ b/apps/web/tests/snapshots/composer-draft-scroll/geometry.expected.md @@ -5,6 +5,8 @@ - draft overflows the capped box: true - visible lines: 14 - both layers share one scroll extent: true +- both layers wrap at one width: true +- scrollbar gutter reserved on each layer: 8px / 8px - textarea scroll offset: 0px - glyph layer tracks it: true - first draft line is on screen: true diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css index 14aebe899b..80b77d6437 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -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. */ diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 70925425dc..d31a8d338f 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -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') }