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 38042c1833..c95b2a60b7 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: 4c4d36b54ff880de38d8db8a5d3121c555a613d7 -2026-07-31-composer-glyph-layer-tracks-the-textarea.zh.md: fa3e16e8d8298b8caf9882e2d40882db909ae79c +2026-07-31-composer-glyph-layer-tracks-the-textarea.md: d60a100be98683b5f7a7c88edf7585d275134730 +2026-07-31-composer-glyph-layer-tracks-the-textarea.zh.md: eab3f9e3fe3bddb426836113d08f1839329119d5 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 4c4d36b54f..d60a100be9 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 @@ -22,7 +22,17 @@ One listener is the whole coupling, because every way the box moves ends in a `s 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 second premise is wrap width, and it is asserted rather than fixed. Only `.input` scrolls, so only `.input` can lose content width to a scrollbar that consumes layout space, and a narrower `.input` wraps a long draft onto more lines — worth 2 to 5 lines for an 8px difference, measured on a standalone harness, while at equal widths a textarea and a div agree exactly. Measured on the running app across the three engines Playwright ships, the widths agree on two and not on the third: + +| engine | `.input` / `.backdrop` / `.mirror` wrap width | extents | +|---|---|---| +| chromium | 776 / 776 / 776 | equal | +| firefox | 776 / 776 / 776 | equal | +| WebKit | **768** / 776 / 776 | equal for the drafts measured | + +WebKit's textarea loses 8px to its scrollbar while the clipped layers keep theirs. That gap predates this change and is not closed here; the mirror is unaffected on the drafts measured because the extents still agree, but a draft whose wrapping is sensitive at exactly that width would make `.input` taller and clamp the mirrored offset. The scenario asserts the equality on the lane's engine, so a regression into that state fails loudly rather than silently. + +`scrollbar-gutter: stable` on the shared metrics block was tried and removed. WebKit applies it to `overflow-y: auto` but not to `overflow: hidden`, so it left `.input` at 768 against 776 — exactly the gap it was meant to close — while costing chromium 8px of text width unconditionally. Closing this needs one geometry every engine agrees on, not that property. The mirror is one-directional: the textarea is the authority because it owns the caret, and the caret is what the browser scrolls to. @@ -36,7 +46,7 @@ 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. +**Reserve the scrollbar gutter on all three layers with `scrollbar-gutter: stable`.** Adopted, then reverted on measurement. The reasoning was that whatever a platform's scrollbar costs, three layers reserving it stay equal — and `overflow: hidden` is a scroll container, so the spec says the clipped layers honour it. Chromium agrees (8px reserved on each, widths 768/768/768). WebKit does not: it reserves for `overflow-y: auto` and not for `overflow: hidden`, leaving 768 against 776 — the same gap, unclosed — so the property bought nothing on the one engine where the divergence is observable while costing every chromium user 8px of text column. Reverted in favour of asserting the premise and recording the WebKit gap. **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. @@ -52,8 +62,9 @@ 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 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. +- Extent equality is asserted, not assumed. It is the premise that turns "mirror the offset" from correct into subtly wrong, and it failed for the trailing-newline shape before the sentinel. +- Wrap-width equality is the other premise, and it does NOT hold universally: WebKit lays `.input` out 8px narrower than the glyph layers. That predates this change and is left open, with the measurement recorded above and an assertion on the lane's engine. A draft whose wrapping turns on those 8px would clamp the mirror on WebKit. +- The composer's layout is unchanged. An earlier revision narrowed the text column by 8px on every platform to chase the wrap-width premise; measurement showed it did not buy the guarantee, so the metrics are the same as before this change. ## Testing 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 fa3e16e8d8..eab3f9e3fe 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 @@ -22,7 +22,17 @@ Status: implemented 这个「共享的滚动范围」并非白得,而镜像偏移只有在它成立时才是正确的。有两件事会破坏它,都是在审查中被发现的,且失效方向相同——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 完全一致。 +第二个前提是折行宽度,它是被断言的,而不是被修复的。只有 `.input` 会滚动,因此也只有 `.input` 会把内容宽度让给一条占布局宽度的滚动条;`.input` 一旦更窄,长草稿就会折出更多行——在独立环境实测,8px 的宽度差值 2 到 5 行,而宽度相等时 textarea 与 div 完全一致。在运行中的应用上、对 Playwright 自带的三个引擎实测,两个相等、一个不等: + +| 引擎 | `.input` / `.backdrop` / `.mirror` 折行宽度 | 滚动范围 | +|---|---|---| +| chromium | 776 / 776 / 776 | 相等 | +| firefox | 776 / 776 / 776 | 相等 | +| WebKit | **768** / 776 / 776 | 所测草稿下相等 | + +WebKit 的 textarea 把 8px 让给了自己的滚动条,而两个被裁剪的图层没有。该差距先于本次改动存在,本 PR 未予关闭;在所测草稿下滚动范围仍然相等,因此镜像不受影响,但一份恰好在该宽度上折行敏感的草稿会让 `.input` 更高、从而钳制镜像偏移。场景在测试通道所用引擎上断言了这项相等性,因此一旦回退到那种状态会显式失败,而不是悄然发生。 + +共享度量块上的 `scrollbar-gutter: stable` 曾被采用又被移除:WebKit 对 `overflow-y: auto` 应用它、对 `overflow: hidden` 不应用,于是 `.input` 仍是 768 对 776——正是它本想关闭的那个差距——同时又让 chromium 无条件损失 8px 文本宽度。要关闭它,需要一套所有引擎都认同的几何,而不是这个属性。 该镜像是单向的:textarea 是权威方,因为它持有光标,而浏览器滚动的目标正是光标。 @@ -36,7 +46,7 @@ Status: implemented **在既有的滚轮处理函数里滚动 backdrop,而不是新增 `scroll` 监听。** 该处理函数本就在 textarea 上的每次滚轮时运行,看似是自然的落点。之所以否决,是它只覆盖了盒子滚动的其中一种成因:在末尾输入、`End`、方向键、拖选越过边缘、拖动滚动条,都会在没有滚轮事件的情况下移动 textarea。监听 `scroll` 是在监听事情本身,而不是它的某一个成因。 -**把折行宽度偏差留作独立议题。** 它先于本 PR 存在,在浏览器测试通道所用引擎上不可见(headless chromium 画 overlay 滚动条,两层实测 `clientWidth` 均为 776、边框盒也是 776),而且它还会造成一个与滚动无关的静止态错位。审查后否决:静止态错位只是后果之一,而非缺陷全貌。折行宽度不等会让 textarea 更高,而那恰恰是让镜像偏移被钳制的前置条件;把它推迟,等于交付一个在用户实际使用占宽滚动条的平台上并不成立的修复。 +**用 `scrollbar-gutter: stable` 让三层一起预留滚动条 gutter。** 曾经采用,实测后回退。当初的推理是:无论平台滚动条占多少宽度,三层都预留同样多即可保持相等;而且 `overflow: hidden` 也是滚动容器,按规范应当遵守该声明。chromium 确实如此(三层各预留 8px,宽度 768/768/768)。WebKit 不然:它对 `overflow-y: auto` 预留、对 `overflow: hidden` 不预留,结果仍是 768 对 776——差距原样保留——于是该属性在唯一能观测到这一偏差的引擎上一无所获,却让每一位 chromium 用户损失 8px 文本列。改为断言该前提并记录 WebKit 的差距。 **改为抑制 textarea 的滚动条,而不是给另外两层预留 gutter。** 在 `.input` 上写 `scrollbar-width: none` 同样能让宽度相等,且不必收窄文本列。之所以否决:草稿超过上限后 composer 是有意显示滚动条滑块的——`.card` 正是为此绑定了 l2 滚动条 token——去掉它就等于拿走了「下面还有内容」这一唯一提示。 @@ -52,8 +62,9 @@ Status: implemented - 该耦合是单向且廉价的——一次对一个数字的赋值,没有测量,除 `scrollTop` 外没有额外的布局读取——因此不会给输入路径增加开销。 - chip、claim token 高亮与文本引用标记在滚动时始终与其字形对齐,因为它们定位在 backdrop 内部并随之移动。装饰扫描本身没有任何改动。 - composer 的双层设计保留了这一隐患:日后在 backdrop 旁新增的任何一层都需要同样的镜像;而任何改变某一层如何保留其末行行盒的改动,都会破坏镜像所依赖的范围相等性。e2e 场景对两者都做了断言——用户真正关心的关系(哪一行在屏幕上),以及其下的范围相等性——因此日后一旦出现偏差,失败会落在不变量上,而不是落在某张截图上。 -- 范围相等性是被断言的,而非被假定的。它正是那个能把「镜像偏移」从正确变为微妙错误的前提,并且在本次改动之前它两度不成立:尾随换行形态,以及在滚动条占宽的平台上任何软折行草稿。 -- composer 的文本列在所有平台上都窄了 8px,包括那些根本不会画占宽滚动条的平台。这是「只保留一套几何」而非「按平台分叉」所付的代价,且这笔代价付在图层度量里,而不是付在一个条件分支里。 +- 范围相等性是被断言的,而非被假定的。它正是那个能把「镜像偏移」从正确变为微妙错误的前提,并且在加入哨兵之前,它在尾随换行这一形态上确实不成立。 +- 折行宽度相等是另一个前提,而它并非普遍成立:WebKit 把 `.input` 排得比字形层窄 8px。该问题先于本次改动存在,此处保持开放,上文记录了实测数值,并在测试通道所用引擎上加了断言。一份折行恰好取决于这 8px 的草稿会在 WebKit 上钳制镜像。 +- composer 的布局没有变化。此前有一版为追求折行宽度前提而在所有平台把文本列收窄了 8px;实测表明它并不能带来该保证,因此度量与改动前保持一致。 ## 验证 diff --git a/apps/web/tests/composer-draft-scroll.e2e.ts b/apps/web/tests/composer-draft-scroll.e2e.ts index 60709060d6..34c552401e 100644 --- a/apps/web/tests/composer-draft-scroll.e2e.ts +++ b/apps/web/tests/composer-draft-scroll.e2e.ts @@ -16,13 +16,13 @@ // 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 +// Mirroring an offset is only correct while both layers can reach it, so the +// geometry underneath is 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. +// caret after a final newline; `pre-wrap` collapses one), and one wrap width +// across all three layers (only the textarea scrolls, so only it can lose +// width to a scrollbar that consumes layout space). Either 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 @@ -108,15 +108,8 @@ interface ComposerMetrics { 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 + /** Content width the hidden auto-grow mirror wraps at — it decides the box's height. */ + mirrorWrapWidth: number } /** @@ -130,6 +123,11 @@ function measureComposer(page: Page): Promise { if (input === null) throw new Error('no live composer textarea in the DOM') const backdrop = input.parentElement?.querySelector('[data-input-backdrop]') if (backdrop === undefined || backdrop === null) throw new Error('no decoration backdrop beside the composer textarea') + // The hidden auto-grow mirror: the textarea's next sibling, and the layer + // that decides the box's height, so its wrap width matters as much as the + // two that carry glyphs. + const mirror = input.nextElementSibling + if (!(mirror instanceof HTMLElement)) throw new Error('no auto-grow mirror after the composer textarea') const box = input.getBoundingClientRect() // 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 @@ -162,8 +160,7 @@ function measureComposer(page: Page): Promise { backdropMax, inputWrapWidth: input.clientWidth, backdropWrapWidth: backdrop.clientWidth, - inputGutter: Math.round(input.getBoundingClientRect().width - input.clientWidth), - backdropGutter: Math.round(backdrop.getBoundingClientRect().width - backdrop.clientWidth), + mirrorWrapWidth: mirror.clientWidth, overflows: input.scrollHeight > input.clientHeight, clientHeight: input.clientHeight, visibleLines: Math.floor(input.clientHeight / lineHeight), @@ -187,6 +184,7 @@ function measureComposer(page: Page): Promise { * keeping the coupling. * @param top - metrics with the draft scrolled to its start. * @param bottom - metrics with the draft scrolled to its end. + * @param trailingNewline - metrics with the trailing-newline draft scrolled to its end. * @returns the golden body, without a trailing newline. */ function renderGeometry(top: ComposerMetrics, bottom: ComposerMetrics, trailingNewline: ComposerMetrics): string { @@ -198,8 +196,9 @@ 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`, + `- all three layers wrap at one width: ${String( + top.inputWrapWidth === top.backdropWrapWidth && top.backdropWrapWidth === top.mirrorWrapWidth, + )}`, `- 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)}`, @@ -269,22 +268,27 @@ 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. + it('lays out all three text layers at one wrap width', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-wrap-width')) + // The premise under the mirror, asserted rather than assumed. Only .input + // scrolls, so only .input can lose content width to a scrollbar that + // consumes layout space; a narrower .input wraps a long draft onto more + // lines, ends up taller, and its larger maximum makes the mirrored offset + // clamp below the caret. Measured on a standalone harness, an 8px width + // difference is worth 2 to 5 lines on a wrap-sensitive draft. + // + // This holds on the lane's engine and is what a regression would break — + // it is NOT vacuous: measured on the same app, WebKit reports 768 against + // 776 here, which is the divergence the Agent Note records as a + // pre-existing, engine-specific limitation. The mirror is unaffected there + // today because the extents still agree; this assertion is what would + // notice if the lane's engine ever moved into the same state. 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(metrics.backdropWrapWidth).toBe(metrics.inputWrapWidth) + // The mirror decides the box height, so it belongs in the same equality — + // were it alone to wrap wider, the box would be measured too short and + // clip content before the 14-line cap, with every other assertion green. + expect(metrics.mirrorWrapWidth).toBe(metrics.inputWrapWidth) expect(tripwire.pageErrors).toEqual([]) }, 60_000) 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 c7b934e5ff..c15d0f0350 100644 --- a/apps/web/tests/snapshots/composer-draft-scroll/geometry.expected.md +++ b/apps/web/tests/snapshots/composer-draft-scroll/geometry.expected.md @@ -5,8 +5,7 @@ - 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 +- all three layers wrap at one width: true - textarea scroll offset: 0px - glyph layer tracks it: true - first draft line is on screen: true diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 9a6a4e572e..9c4a554d9a 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -40,11 +40,11 @@ "tests/seeded-history.e2e.ts", "tests/sidebar-scrollbar.e2e.ts", "tests/code-mode-round.e2e.ts", + "tests/composer-draft-scroll.e2e.ts", "tests/cordis-tool-round.e2e.ts", "tests/message-actions.e2e.ts", "tests/queue-actions.e2e.ts", - "tests/skill-invocation-policy.e2e.ts", - "tests/composer-draft-scroll.e2e.ts" + "tests/skill-invocation-policy.e2e.ts" ], "references": [ { 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 80b77d6437..28a60707b7 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -192,20 +192,18 @@ 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; + /* These three MUST wrap at one width, because InputBar mirrors a single + scroll offset between .input and .backdrop and a layer that wraps onto + more lines is taller, has a larger scroll maximum, and clamps the mirrored + offset below the caret. Only .input scrolls, so only .input can lose + content width to a scrollbar that consumes layout space. + `scrollbar-gutter: stable` here does NOT buy that guarantee and was + removed after measuring: WebKit applies it to overflow-y:auto but not to + the overflow:hidden layers, so it left .input at 768 against 776 — the + same gap it was meant to close — while costing chromium 8px of text width + unconditionally. The gap it would have closed is measured and recorded in + the Agent Note (2026-07-31-composer-glyph-layer-tracks-the-textarea); + closing it needs one geometry every engine agrees on, not this property. */ } /* figma 34:10434: #ADB2B8 light / #81858C dark — the caption pair exactly. */ diff --git a/tsconfig.host.json b/tsconfig.host.json index 2923d379a0..40e79b9793 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -27,11 +27,11 @@ "apps/web/tests/seeded-history.e2e.ts", "apps/web/tests/sidebar-scrollbar.e2e.ts", "apps/web/tests/code-mode-round.e2e.ts", + "apps/web/tests/composer-draft-scroll.e2e.ts", "apps/web/tests/cordis-tool-round.e2e.ts", "apps/web/tests/message-actions.e2e.ts", "apps/web/tests/queue-actions.e2e.ts", "apps/web/tests/skill-invocation-policy.e2e.ts", - "apps/web/tests/composer-draft-scroll.e2e.ts", "apps/cli/tests/**/*.ts", "examples/*/src/**/*.ts", "examples/*/start.ts",