2026-08-19 17:55:06 +08:00
|
|
|
// Browser geometry for the composer's caret and visible text layers. A
|
|
|
|
|
// same-task gap probe detects deferred scroll synchronization that DOM-only
|
|
|
|
|
// tests cannot observe.
|
fix(web): scroll the composer's glyph layer with its textarea
A composer draft past the 14-line cap could not be scrolled: the caret and
the selection moved, but the words stayed frozen at line 1, so the tail of
anything longer than the cap was unreachable while writing it.
The composer paints its text in two stacked layers. The textarea owns the
value, the selection and the caret but renders its own glyphs transparent;
every visible character is painted by the decoration backdrop beneath it,
which also carries the claim-token highlight, the chips and the ghost hint.
The backdrop is `inset: 0; overflow: hidden` — clipped, not scrolled — and
nothing linked its offset to the textarea's. Below the cap both layers rest
at 0, which is why the defect hid behind every short-draft screenshot and
fixture.
InputBar now mirrors the textarea's scrollTop onto the backdrop, from a
`scroll` listener (every gesture and every caret-driven scroll) and from a
layout effect keyed on the committed draft (an edit reflows both layers
without necessarily firing a scroll event).
Scrolling is layout, so jsdom cannot show this: the unit spec stubs both
offsets and proves the mirroring paths run, while a new browser scenario
measures the user-visible fact against the built client with a DOM Range
over the backdrop's own text — after a wheel gesture over a 40-line draft
the last line is on screen and the first has scrolled out. Confirmed both
directions: with the mirroring reverted and the packages rebuilt, the
golden reads `last draft line is on screen: false` while `textarea moved:
true`.
2026-07-31 11:53:04 +08:00
|
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
|
import { join } from 'node:path'
|
|
|
|
|
import type { Browser, Page } from 'playwright'
|
|
|
|
|
import { chromium } from 'playwright'
|
|
|
|
|
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
|
|
|
|
import {
|
|
|
|
|
assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, watchConsole,
|
|
|
|
|
webSnapshotMode, type WebScaffold,
|
|
|
|
|
} from './scaffold.ts'
|
|
|
|
|
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
|
|
|
|
|
|
|
|
|
|
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/composer-draft-scroll', import.meta.url))
|
2026-08-19 17:55:06 +08:00
|
|
|
/** Scroll geometry is absent from ARIA snapshots, so this scenario records it directly. */
|
fix(web): scroll the composer's glyph layer with its textarea
A composer draft past the 14-line cap could not be scrolled: the caret and
the selection moved, but the words stayed frozen at line 1, so the tail of
anything longer than the cap was unreachable while writing it.
The composer paints its text in two stacked layers. The textarea owns the
value, the selection and the caret but renders its own glyphs transparent;
every visible character is painted by the decoration backdrop beneath it,
which also carries the claim-token highlight, the chips and the ghost hint.
The backdrop is `inset: 0; overflow: hidden` — clipped, not scrolled — and
nothing linked its offset to the textarea's. Below the cap both layers rest
at 0, which is why the defect hid behind every short-draft screenshot and
fixture.
InputBar now mirrors the textarea's scrollTop onto the backdrop, from a
`scroll` listener (every gesture and every caret-driven scroll) and from a
layout effect keyed on the committed draft (an edit reflows both layers
without necessarily firing a scroll event).
Scrolling is layout, so jsdom cannot show this: the unit spec stubs both
offsets and proves the mirroring paths run, while a new browser scenario
measures the user-visible fact against the built client with a DOM Range
over the backdrop's own text — after a wheel gesture over a 40-line draft
the last line is on screen and the first has scrolled out. Confirmed both
directions: with the mirroring reverted and the packages rebuilt, the
golden reads `last draft line is on screen: false` while `textarea moved:
true`.
2026-07-31 11:53:04 +08:00
|
|
|
const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md')
|
|
|
|
|
const MODE = webSnapshotMode()
|
|
|
|
|
|
|
|
|
|
/** Marks the first and last line so a Range can find them in the backdrop's text. */
|
|
|
|
|
const FIRST_MARKER = 'FIRST-LINE-MARKER'
|
|
|
|
|
const LAST_MARKER = 'LAST-LINE-MARKER'
|
|
|
|
|
/** Comfortably past the 14-line cap, so the draft overflows however the lines wrap. */
|
|
|
|
|
const DRAFT_LINES = 40
|
|
|
|
|
const DRAFT = Array.from({ length: DRAFT_LINES }, (_unused, index) => {
|
|
|
|
|
if (index === 0) return FIRST_MARKER
|
|
|
|
|
if (index === DRAFT_LINES - 1) return LAST_MARKER
|
|
|
|
|
return `draft line ${String(index + 1).padStart(2, '0')}`
|
|
|
|
|
}).join('\n')
|
|
|
|
|
|
fix(web): give the backdrop the trailing-line sentinel so the layers share one extent
Review caught a real divergence the earlier measurements missed: mirroring
an offset is only correct while both layers can reach it, and for a draft
ending in a newline the backdrop could not.
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. So a draft ending in a newline made the backdrop exactly one
line shorter than the textarea — measured 628 against 652 — and the mirrored
assignment clamped, leaving the glyphs one line behind the caret at the very
bottom of the draft.
The backdrop now carries the same trailing-line sentinel the mirror div has
carried all along: its content is the decoration walk plus one newline. The
same pre-wrap collapse absorbs it when the draft does not end in a newline,
so it costs no height in the ordinary case, and it supplies the missing line
box when it does. Verified in isolation first: a bare pre-wrap div measures
180/180/198 against a textarea's 180/198/216 for zero, one and two trailing
newlines, and 180/198/216 with the sentinel.
Coverage for the shape that exposed it: the browser scenario asserts the two
extents are equal before asserting the glyphs reach the end, observing each
layer's maximum by asking for an impossible offset and reading back the
clamp rather than computing it from scrollHeight, and the golden records the
relation. The unit spec pins the backdrop's text as the draft plus exactly
one newline. Removing the sentinel fails both, the e2e with the same 628
against 652.
The scrollbar-gutter half of the same review point does not reproduce here:
both layers measure clientWidth 776 against a border box of 776 while the
draft overflows, so this engine's textarea scrollbar is an overlay and takes
no width out of the wrap.
2026-07-31 12:12:54 +08:00
|
|
|
/**
|
2026-08-09 15:27:21 +08:00
|
|
|
* A draft ending in a newline, where the two layers reserve their
|
fix(web): bind the composer's caret to its glyphs with one scrollport
The composer paints its draft in two layers — the textarea owns the value,
the selection and the caret, the backdrop paints every visible glyph — and
they had one scroll offset each, kept equal by a `scroll` listener. That
holds at rest and not in motion: a wheel gesture scrolls the textarea on the
compositor, the listener runs afterwards, and for those frames the caret sits
at the new offset with the words at the old one. Measured on a harness of the
same geometry, a 200px offset change separates caret from glyphs by ~200px
(chromium 203, firefox 202, WebKit 203) until a later frame — the caret
flying out of its own text when a user swipes a long draft quickly.
Both layers now ride one scrollport: `[data-input-scroll]` carries the
14-line cap, the auto-grow stack inside it is as tall as the whole draft, and
the textarea holds no scrollable overflow of its own. The browser applies one
offset to both layers in the same frame, so the coupling is structural rather
than maintained. The backdrop's trailing-line sentinel and the cross-engine
wrap-width premise go with the mirror: the layers now share a containing
block, which closes the WebKit 768-against-776 gap by construction.
2026-07-31 15:25:29 +08:00
|
|
|
* final line box on different terms. A textarea keeps one for the caret after a
|
|
|
|
|
* final newline; `white-space: pre-wrap` collapses a text node's trailing
|
|
|
|
|
* newline and generates none. The hidden auto-grow mirror carries the newline
|
|
|
|
|
* and so decides the height for both, which is why the backdrop needs no
|
2026-08-09 15:27:21 +08:00
|
|
|
* padding of its own — but only a draft with a trailing newline can show it.
|
fix(web): give the backdrop the trailing-line sentinel so the layers share one extent
Review caught a real divergence the earlier measurements missed: mirroring
an offset is only correct while both layers can reach it, and for a draft
ending in a newline the backdrop could not.
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. So a draft ending in a newline made the backdrop exactly one
line shorter than the textarea — measured 628 against 652 — and the mirrored
assignment clamped, leaving the glyphs one line behind the caret at the very
bottom of the draft.
The backdrop now carries the same trailing-line sentinel the mirror div has
carried all along: its content is the decoration walk plus one newline. The
same pre-wrap collapse absorbs it when the draft does not end in a newline,
so it costs no height in the ordinary case, and it supplies the missing line
box when it does. Verified in isolation first: a bare pre-wrap div measures
180/180/198 against a textarea's 180/198/216 for zero, one and two trailing
newlines, and 180/198/216 with the sentinel.
Coverage for the shape that exposed it: the browser scenario asserts the two
extents are equal before asserting the glyphs reach the end, observing each
layer's maximum by asking for an impossible offset and reading back the
clamp rather than computing it from scrollHeight, and the golden records the
relation. The unit spec pins the backdrop's text as the draft plus exactly
one newline. Removing the sentinel fails both, the e2e with the same 628
against 652.
The scrollbar-gutter half of the same review point does not reproduce here:
both layers measure clientWidth 776 against a border box of 776 while the
draft overflows, so this engine's textarea scrollbar is an overlay and takes
no width out of the wrap.
2026-07-31 12:12:54 +08:00
|
|
|
*/
|
|
|
|
|
const DRAFT_TRAILING_NEWLINE = `${DRAFT}\n`
|
|
|
|
|
|
fix(web): bind the composer's caret to its glyphs with one scrollport
The composer paints its draft in two layers — the textarea owns the value,
the selection and the caret, the backdrop paints every visible glyph — and
they had one scroll offset each, kept equal by a `scroll` listener. That
holds at rest and not in motion: a wheel gesture scrolls the textarea on the
compositor, the listener runs afterwards, and for those frames the caret sits
at the new offset with the words at the old one. Measured on a harness of the
same geometry, a 200px offset change separates caret from glyphs by ~200px
(chromium 203, firefox 202, WebKit 203) until a later frame — the caret
flying out of its own text when a user swipes a long draft quickly.
Both layers now ride one scrollport: `[data-input-scroll]` carries the
14-line cap, the auto-grow stack inside it is as tall as the whole draft, and
the textarea holds no scrollable overflow of its own. The browser applies one
offset to both layers in the same frame, so the coupling is structural rather
than maintained. The backdrop's trailing-line sentinel and the cross-engine
wrap-width premise go with the mirror: the layers now share a containing
block, which closes the WebKit 768-against-776 gap by construction.
2026-07-31 15:25:29 +08:00
|
|
|
/** The composer's text layers as the browser lays them out. */
|
fix(web): scroll the composer's glyph layer with its textarea
A composer draft past the 14-line cap could not be scrolled: the caret and
the selection moved, but the words stayed frozen at line 1, so the tail of
anything longer than the cap was unreachable while writing it.
The composer paints its text in two stacked layers. The textarea owns the
value, the selection and the caret but renders its own glyphs transparent;
every visible character is painted by the decoration backdrop beneath it,
which also carries the claim-token highlight, the chips and the ghost hint.
The backdrop is `inset: 0; overflow: hidden` — clipped, not scrolled — and
nothing linked its offset to the textarea's. Below the cap both layers rest
at 0, which is why the defect hid behind every short-draft screenshot and
fixture.
InputBar now mirrors the textarea's scrollTop onto the backdrop, from a
`scroll` listener (every gesture and every caret-driven scroll) and from a
layout effect keyed on the committed draft (an edit reflows both layers
without necessarily firing a scroll event).
Scrolling is layout, so jsdom cannot show this: the unit spec stubs both
offsets and proves the mirroring paths run, while a new browser scenario
measures the user-visible fact against the built client with a DOM Range
over the backdrop's own text — after a wheel gesture over a 40-line draft
the last line is on screen and the first has scrolled out. Confirmed both
directions: with the mirroring reverted and the packages rebuilt, the
golden reads `last draft line is on screen: false` while `textarea moved:
true`.
2026-07-31 11:53:04 +08:00
|
|
|
interface ComposerMetrics {
|
|
|
|
|
overflows: boolean
|
|
|
|
|
clientHeight: number
|
|
|
|
|
visibleLines: number
|
fix(web): bind the composer's caret to its glyphs with one scrollport
The composer paints its draft in two layers — the textarea owns the value,
the selection and the caret, the backdrop paints every visible glyph — and
they had one scroll offset each, kept equal by a `scroll` listener. That
holds at rest and not in motion: a wheel gesture scrolls the textarea on the
compositor, the listener runs afterwards, and for those frames the caret sits
at the new offset with the words at the old one. Measured on a harness of the
same geometry, a 200px offset change separates caret from glyphs by ~200px
(chromium 203, firefox 202, WebKit 203) until a later frame — the caret
flying out of its own text when a user swipes a long draft quickly.
Both layers now ride one scrollport: `[data-input-scroll]` carries the
14-line cap, the auto-grow stack inside it is as tall as the whole draft, and
the textarea holds no scrollable overflow of its own. The browser applies one
offset to both layers in the same frame, so the coupling is structural rather
than maintained. The backdrop's trailing-line sentinel and the cross-engine
wrap-width premise go with the mirror: the layers now share a containing
block, which closes the WebKit 768-against-776 gap by construction.
2026-07-31 15:25:29 +08:00
|
|
|
scrollTop: number
|
|
|
|
|
scrollMax: number
|
|
|
|
|
inputScrollable: number
|
|
|
|
|
/**
|
|
|
|
|
* Distance between where the caret sits for a draft line and where the
|
|
|
|
|
* backdrop paints that line, in pixels. A fixed value (the difference between
|
|
|
|
|
* a line box's top and its glyph box's) is alignment; a value that CHANGES
|
|
|
|
|
* with the scroll offset is the defect — the words trailing the caret.
|
|
|
|
|
*/
|
|
|
|
|
caretGlyphGap: number
|
|
|
|
|
/**
|
2026-08-19 17:55:06 +08:00
|
|
|
* How much the caret-to-glyph gap moves when the offset changes before a
|
|
|
|
|
* scroll listener can run.
|
fix(web): bind the composer's caret to its glyphs with one scrollport
The composer paints its draft in two layers — the textarea owns the value,
the selection and the caret, the backdrop paints every visible glyph — and
they had one scroll offset each, kept equal by a `scroll` listener. That
holds at rest and not in motion: a wheel gesture scrolls the textarea on the
compositor, the listener runs afterwards, and for those frames the caret sits
at the new offset with the words at the old one. Measured on a harness of the
same geometry, a 200px offset change separates caret from glyphs by ~200px
(chromium 203, firefox 202, WebKit 203) until a later frame — the caret
flying out of its own text when a user swipes a long draft quickly.
Both layers now ride one scrollport: `[data-input-scroll]` carries the
14-line cap, the auto-grow stack inside it is as tall as the whole draft, and
the textarea holds no scrollable overflow of its own. The browser applies one
offset to both layers in the same frame, so the coupling is structural rather
than maintained. The backdrop's trailing-line sentinel and the cross-engine
wrap-width premise go with the mirror: the layers now share a containing
block, which closes the WebKit 768-against-776 gap by construction.
2026-07-31 15:25:29 +08:00
|
|
|
*/
|
|
|
|
|
gapShiftOnScroll: number
|
fix(web): scroll the composer's glyph layer with its textarea
A composer draft past the 14-line cap could not be scrolled: the caret and
the selection moved, but the words stayed frozen at line 1, so the tail of
anything longer than the cap was unreachable while writing it.
The composer paints its text in two stacked layers. The textarea owns the
value, the selection and the caret but renders its own glyphs transparent;
every visible character is painted by the decoration backdrop beneath it,
which also carries the claim-token highlight, the chips and the ghost hint.
The backdrop is `inset: 0; overflow: hidden` — clipped, not scrolled — and
nothing linked its offset to the textarea's. Below the cap both layers rest
at 0, which is why the defect hid behind every short-draft screenshot and
fixture.
InputBar now mirrors the textarea's scrollTop onto the backdrop, from a
`scroll` listener (every gesture and every caret-driven scroll) and from a
layout effect keyed on the committed draft (an edit reflows both layers
without necessarily firing a scroll event).
Scrolling is layout, so jsdom cannot show this: the unit spec stubs both
offsets and proves the mirroring paths run, while a new browser scenario
measures the user-visible fact against the built client with a DOM Range
over the backdrop's own text — after a wheel gesture over a 40-line draft
the last line is on screen and the first has scrolled out. Confirmed both
directions: with the mirroring reverted and the packages rebuilt, the
golden reads `last draft line is on screen: false` while `textarea moved:
true`.
2026-07-31 11:53:04 +08:00
|
|
|
lastLineOffset: number
|
|
|
|
|
firstLineOffset: number
|
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.
2026-07-31 12:39:52 +08:00
|
|
|
inputWrapWidth: number
|
|
|
|
|
backdropWrapWidth: number
|
2026-07-31 13:13:50 +08:00
|
|
|
mirrorWrapWidth: number
|
fix(web): scroll the composer's glyph layer with its textarea
A composer draft past the 14-line cap could not be scrolled: the caret and
the selection moved, but the words stayed frozen at line 1, so the tail of
anything longer than the cap was unreachable while writing it.
The composer paints its text in two stacked layers. The textarea owns the
value, the selection and the caret but renders its own glyphs transparent;
every visible character is painted by the decoration backdrop beneath it,
which also carries the claim-token highlight, the chips and the ghost hint.
The backdrop is `inset: 0; overflow: hidden` — clipped, not scrolled — and
nothing linked its offset to the textarea's. Below the cap both layers rest
at 0, which is why the defect hid behind every short-draft screenshot and
fixture.
InputBar now mirrors the textarea's scrollTop onto the backdrop, from a
`scroll` listener (every gesture and every caret-driven scroll) and from a
layout effect keyed on the committed draft (an edit reflows both layers
without necessarily firing a scroll event).
Scrolling is layout, so jsdom cannot show this: the unit spec stubs both
offsets and proves the mirroring paths run, while a new browser scenario
measures the user-visible fact against the built client with a DOM Range
over the backdrop's own text — after a wheel gesture over a 40-line draft
the last line is on screen and the first has scrolled out. Confirmed both
directions: with the mirroring reverted and the packages rebuilt, the
golden reads `last draft line is on screen: false` while `textarea moved:
true`.
2026-07-31 11:53:04 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
fix(web): bind the composer's caret to its glyphs with one scrollport
The composer paints its draft in two layers — the textarea owns the value,
the selection and the caret, the backdrop paints every visible glyph — and
they had one scroll offset each, kept equal by a `scroll` listener. That
holds at rest and not in motion: a wheel gesture scrolls the textarea on the
compositor, the listener runs afterwards, and for those frames the caret sits
at the new offset with the words at the old one. Measured on a harness of the
same geometry, a 200px offset change separates caret from glyphs by ~200px
(chromium 203, firefox 202, WebKit 203) until a later frame — the caret
flying out of its own text when a user swipes a long draft quickly.
Both layers now ride one scrollport: `[data-input-scroll]` carries the
14-line cap, the auto-grow stack inside it is as tall as the whole draft, and
the textarea holds no scrollable overflow of its own. The browser applies one
offset to both layers in the same frame, so the coupling is structural rather
than maintained. The backdrop's trailing-line sentinel and the cross-engine
wrap-width premise go with the mirror: the layers now share a containing
block, which closes the WebKit 768-against-776 gap by construction.
2026-07-31 15:25:29 +08:00
|
|
|
* Measure the composer's layers in the page, in the caret's coordinate frame.
|
fix(web): scroll the composer's glyph layer with its textarea
A composer draft past the 14-line cap could not be scrolled: the caret and
the selection moved, but the words stayed frozen at line 1, so the tail of
anything longer than the cap was unreachable while writing it.
The composer paints its text in two stacked layers. The textarea owns the
value, the selection and the caret but renders its own glyphs transparent;
every visible character is painted by the decoration backdrop beneath it,
which also carries the claim-token highlight, the chips and the ghost hint.
The backdrop is `inset: 0; overflow: hidden` — clipped, not scrolled — and
nothing linked its offset to the textarea's. Below the cap both layers rest
at 0, which is why the defect hid behind every short-draft screenshot and
fixture.
InputBar now mirrors the textarea's scrollTop onto the backdrop, from a
`scroll` listener (every gesture and every caret-driven scroll) and from a
layout effect keyed on the committed draft (an edit reflows both layers
without necessarily firing a scroll event).
Scrolling is layout, so jsdom cannot show this: the unit spec stubs both
offsets and proves the mirroring paths run, while a new browser scenario
measures the user-visible fact against the built client with a DOM Range
over the backdrop's own text — after a wheel gesture over a 40-line draft
the last line is on screen and the first has scrolled out. Confirmed both
directions: with the mirroring reverted and the packages rebuilt, the
golden reads `last draft line is on screen: false` while `textarea moved:
true`.
2026-07-31 11:53:04 +08:00
|
|
|
* @param page - the page under test.
|
fix(web): bind the composer's caret to its glyphs with one scrollport
The composer paints its draft in two layers — the textarea owns the value,
the selection and the caret, the backdrop paints every visible glyph — and
they had one scroll offset each, kept equal by a `scroll` listener. That
holds at rest and not in motion: a wheel gesture scrolls the textarea on the
compositor, the listener runs afterwards, and for those frames the caret sits
at the new offset with the words at the old one. Measured on a harness of the
same geometry, a 200px offset change separates caret from glyphs by ~200px
(chromium 203, firefox 202, WebKit 203) until a later frame — the caret
flying out of its own text when a user swipes a long draft quickly.
Both layers now ride one scrollport: `[data-input-scroll]` carries the
14-line cap, the auto-grow stack inside it is as tall as the whole draft, and
the textarea holds no scrollable overflow of its own. The browser applies one
offset to both layers in the same frame, so the coupling is structural rather
than maintained. The backdrop's trailing-line sentinel and the cross-engine
wrap-width premise go with the mirror: the layers now share a containing
block, which closes the WebKit 768-against-776 gap by construction.
2026-07-31 15:25:29 +08:00
|
|
|
* @returns the offset, the caret-to-glyph gap, and where the draft's first and last lines sit.
|
fix(web): scroll the composer's glyph layer with its textarea
A composer draft past the 14-line cap could not be scrolled: the caret and
the selection moved, but the words stayed frozen at line 1, so the tail of
anything longer than the cap was unreachable while writing it.
The composer paints its text in two stacked layers. The textarea owns the
value, the selection and the caret but renders its own glyphs transparent;
every visible character is painted by the decoration backdrop beneath it,
which also carries the claim-token highlight, the chips and the ghost hint.
The backdrop is `inset: 0; overflow: hidden` — clipped, not scrolled — and
nothing linked its offset to the textarea's. Below the cap both layers rest
at 0, which is why the defect hid behind every short-draft screenshot and
fixture.
InputBar now mirrors the textarea's scrollTop onto the backdrop, from a
`scroll` listener (every gesture and every caret-driven scroll) and from a
layout effect keyed on the committed draft (an edit reflows both layers
without necessarily firing a scroll event).
Scrolling is layout, so jsdom cannot show this: the unit spec stubs both
offsets and proves the mirroring paths run, while a new browser scenario
measures the user-visible fact against the built client with a DOM Range
over the backdrop's own text — after a wheel gesture over a 40-line draft
the last line is on screen and the first has scrolled out. Confirmed both
directions: with the mirroring reverted and the packages rebuilt, the
golden reads `last draft line is on screen: false` while `textarea moved:
true`.
2026-07-31 11:53:04 +08:00
|
|
|
*/
|
|
|
|
|
function measureComposer(page: Page): Promise<ComposerMetrics> {
|
|
|
|
|
return page.evaluate(({ first, last }) => {
|
|
|
|
|
const input = document.querySelector<HTMLTextAreaElement>('textarea:enabled')
|
|
|
|
|
if (input === null) throw new Error('no live composer textarea in the DOM')
|
fix(web): bind the composer's caret to its glyphs with one scrollport
The composer paints its draft in two layers — the textarea owns the value,
the selection and the caret, the backdrop paints every visible glyph — and
they had one scroll offset each, kept equal by a `scroll` listener. That
holds at rest and not in motion: a wheel gesture scrolls the textarea on the
compositor, the listener runs afterwards, and for those frames the caret sits
at the new offset with the words at the old one. Measured on a harness of the
same geometry, a 200px offset change separates caret from glyphs by ~200px
(chromium 203, firefox 202, WebKit 203) until a later frame — the caret
flying out of its own text when a user swipes a long draft quickly.
Both layers now ride one scrollport: `[data-input-scroll]` carries the
14-line cap, the auto-grow stack inside it is as tall as the whole draft, and
the textarea holds no scrollable overflow of its own. The browser applies one
offset to both layers in the same frame, so the coupling is structural rather
than maintained. The backdrop's trailing-line sentinel and the cross-engine
wrap-width premise go with the mirror: the layers now share a containing
block, which closes the WebKit 768-against-776 gap by construction.
2026-07-31 15:25:29 +08:00
|
|
|
const scroll = input.closest<HTMLElement>('[data-input-scroll]')
|
|
|
|
|
if (scroll === null) throw new Error('the composer textarea is not inside a draft scrollport')
|
fix(web): scroll the composer's glyph layer with its textarea
A composer draft past the 14-line cap could not be scrolled: the caret and
the selection moved, but the words stayed frozen at line 1, so the tail of
anything longer than the cap was unreachable while writing it.
The composer paints its text in two stacked layers. The textarea owns the
value, the selection and the caret but renders its own glyphs transparent;
every visible character is painted by the decoration backdrop beneath it,
which also carries the claim-token highlight, the chips and the ghost hint.
The backdrop is `inset: 0; overflow: hidden` — clipped, not scrolled — and
nothing linked its offset to the textarea's. Below the cap both layers rest
at 0, which is why the defect hid behind every short-draft screenshot and
fixture.
InputBar now mirrors the textarea's scrollTop onto the backdrop, from a
`scroll` listener (every gesture and every caret-driven scroll) and from a
layout effect keyed on the committed draft (an edit reflows both layers
without necessarily firing a scroll event).
Scrolling is layout, so jsdom cannot show this: the unit spec stubs both
offsets and proves the mirroring paths run, while a new browser scenario
measures the user-visible fact against the built client with a DOM Range
over the backdrop's own text — after a wheel gesture over a 40-line draft
the last line is on screen and the first has scrolled out. Confirmed both
directions: with the mirroring reverted and the packages rebuilt, the
golden reads `last draft line is on screen: false` while `textarea moved:
true`.
2026-07-31 11:53:04 +08:00
|
|
|
const backdrop = input.parentElement?.querySelector<HTMLElement>('[data-input-backdrop]')
|
|
|
|
|
if (backdrop === undefined || backdrop === null) throw new Error('no decoration backdrop beside the composer textarea')
|
2026-07-31 13:13:50 +08:00
|
|
|
// 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')
|
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.
2026-07-31 12:39:52 +08:00
|
|
|
// The draft carries no chips or claim token, so the decoration walk emits it
|
fix(web): bind the composer's caret to its glyphs with one scrollport
The composer paints its draft in two layers — the textarea owns the value,
the selection and the caret, the backdrop paints every visible glyph — and
they had one scroll offset each, kept equal by a `scroll` listener. That
holds at rest and not in motion: a wheel gesture scrolls the textarea on the
compositor, the listener runs afterwards, and for those frames the caret sits
at the new offset with the words at the old one. Measured on a harness of the
same geometry, a 200px offset change separates caret from glyphs by ~200px
(chromium 203, firefox 202, WebKit 203) until a later frame — the caret
flying out of its own text when a user swipes a long draft quickly.
Both layers now ride one scrollport: `[data-input-scroll]` carries the
14-line cap, the auto-grow stack inside it is as tall as the whole draft, and
the textarea holds no scrollable overflow of its own. The browser applies one
offset to both layers in the same frame, so the coupling is structural rather
than maintained. The backdrop's trailing-line sentinel and the cross-engine
wrap-width premise go with the mirror: the layers now share a containing
block, which closes the WebKit 768-against-776 gap by construction.
2026-07-31 15:25:29 +08:00
|
|
|
// as a single text node, which is what the Range below needs.
|
fix(web): scroll the composer's glyph layer with its textarea
A composer draft past the 14-line cap could not be scrolled: the caret and
the selection moved, but the words stayed frozen at line 1, so the tail of
anything longer than the cap was unreachable while writing it.
The composer paints its text in two stacked layers. The textarea owns the
value, the selection and the caret but renders its own glyphs transparent;
every visible character is painted by the decoration backdrop beneath it,
which also carries the claim-token highlight, the chips and the ghost hint.
The backdrop is `inset: 0; overflow: hidden` — clipped, not scrolled — and
nothing linked its offset to the textarea's. Below the cap both layers rest
at 0, which is why the defect hid behind every short-draft screenshot and
fixture.
InputBar now mirrors the textarea's scrollTop onto the backdrop, from a
`scroll` listener (every gesture and every caret-driven scroll) and from a
layout effect keyed on the committed draft (an edit reflows both layers
without necessarily firing a scroll event).
Scrolling is layout, so jsdom cannot show this: the unit spec stubs both
offsets and proves the mirroring paths run, while a new browser scenario
measures the user-visible fact against the built client with a DOM Range
over the backdrop's own text — after a wheel gesture over a 40-line draft
the last line is on screen and the first has scrolled out. Confirmed both
directions: with the mirroring reverted and the packages rebuilt, the
golden reads `last draft line is on screen: false` while `textarea moved:
true`.
2026-07-31 11:53:04 +08:00
|
|
|
const text = backdrop.firstChild
|
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.
2026-07-31 12:39:52 +08:00
|
|
|
if (!(text instanceof Text)) throw new Error('backdrop does not open with a plain text node')
|
fix(web): bind the composer's caret to its glyphs with one scrollport
The composer paints its draft in two layers — the textarea owns the value,
the selection and the caret, the backdrop paints every visible glyph — and
they had one scroll offset each, kept equal by a `scroll` listener. That
holds at rest and not in motion: a wheel gesture scrolls the textarea on the
compositor, the listener runs afterwards, and for those frames the caret sits
at the new offset with the words at the old one. Measured on a harness of the
same geometry, a 200px offset change separates caret from glyphs by ~200px
(chromium 203, firefox 202, WebKit 203) until a later frame — the caret
flying out of its own text when a user swipes a long draft quickly.
Both layers now ride one scrollport: `[data-input-scroll]` carries the
14-line cap, the auto-grow stack inside it is as tall as the whole draft, and
the textarea holds no scrollable overflow of its own. The browser applies one
offset to both layers in the same frame, so the coupling is structural rather
than maintained. The backdrop's trailing-line sentinel and the cross-engine
wrap-width premise go with the mirror: the layers now share a containing
block, which closes the WebKit 768-against-776 gap by construction.
2026-07-31 15:25:29 +08:00
|
|
|
const lineHeight = Number.parseFloat(getComputedStyle(input).lineHeight)
|
|
|
|
|
/** Where the backdrop paints the line holding `marker`, in viewport coordinates. */
|
|
|
|
|
const glyphTop = (marker: string): number => {
|
fix(web): scroll the composer's glyph layer with its textarea
A composer draft past the 14-line cap could not be scrolled: the caret and
the selection moved, but the words stayed frozen at line 1, so the tail of
anything longer than the cap was unreachable while writing it.
The composer paints its text in two stacked layers. The textarea owns the
value, the selection and the caret but renders its own glyphs transparent;
every visible character is painted by the decoration backdrop beneath it,
which also carries the claim-token highlight, the chips and the ghost hint.
The backdrop is `inset: 0; overflow: hidden` — clipped, not scrolled — and
nothing linked its offset to the textarea's. Below the cap both layers rest
at 0, which is why the defect hid behind every short-draft screenshot and
fixture.
InputBar now mirrors the textarea's scrollTop onto the backdrop, from a
`scroll` listener (every gesture and every caret-driven scroll) and from a
layout effect keyed on the committed draft (an edit reflows both layers
without necessarily firing a scroll event).
Scrolling is layout, so jsdom cannot show this: the unit spec stubs both
offsets and proves the mirroring paths run, while a new browser scenario
measures the user-visible fact against the built client with a DOM Range
over the backdrop's own text — after a wheel gesture over a 40-line draft
the last line is on screen and the first has scrolled out. Confirmed both
directions: with the mirroring reverted and the packages rebuilt, the
golden reads `last draft line is on screen: false` while `textarea moved:
true`.
2026-07-31 11:53:04 +08:00
|
|
|
const at = text.data.indexOf(marker)
|
|
|
|
|
if (at < 0) throw new Error(`marker ${marker} missing from the backdrop text`)
|
|
|
|
|
const range = document.createRange()
|
|
|
|
|
range.setStart(text, at)
|
|
|
|
|
range.setEnd(text, at + marker.length)
|
fix(web): bind the composer's caret to its glyphs with one scrollport
The composer paints its draft in two layers — the textarea owns the value,
the selection and the caret, the backdrop paints every visible glyph — and
they had one scroll offset each, kept equal by a `scroll` listener. That
holds at rest and not in motion: a wheel gesture scrolls the textarea on the
compositor, the listener runs afterwards, and for those frames the caret sits
at the new offset with the words at the old one. Measured on a harness of the
same geometry, a 200px offset change separates caret from glyphs by ~200px
(chromium 203, firefox 202, WebKit 203) until a later frame — the caret
flying out of its own text when a user swipes a long draft quickly.
Both layers now ride one scrollport: `[data-input-scroll]` carries the
14-line cap, the auto-grow stack inside it is as tall as the whole draft, and
the textarea holds no scrollable overflow of its own. The browser applies one
offset to both layers in the same frame, so the coupling is structural rather
than maintained. The backdrop's trailing-line sentinel and the cross-engine
wrap-width premise go with the mirror: the layers now share a containing
block, which closes the WebKit 768-against-776 gap by construction.
2026-07-31 15:25:29 +08:00
|
|
|
return range.getBoundingClientRect().top
|
fix(web): scroll the composer's glyph layer with its textarea
A composer draft past the 14-line cap could not be scrolled: the caret and
the selection moved, but the words stayed frozen at line 1, so the tail of
anything longer than the cap was unreachable while writing it.
The composer paints its text in two stacked layers. The textarea owns the
value, the selection and the caret but renders its own glyphs transparent;
every visible character is painted by the decoration backdrop beneath it,
which also carries the claim-token highlight, the chips and the ghost hint.
The backdrop is `inset: 0; overflow: hidden` — clipped, not scrolled — and
nothing linked its offset to the textarea's. Below the cap both layers rest
at 0, which is why the defect hid behind every short-draft screenshot and
fixture.
InputBar now mirrors the textarea's scrollTop onto the backdrop, from a
`scroll` listener (every gesture and every caret-driven scroll) and from a
layout effect keyed on the committed draft (an edit reflows both layers
without necessarily firing a scroll event).
Scrolling is layout, so jsdom cannot show this: the unit spec stubs both
offsets and proves the mirroring paths run, while a new browser scenario
measures the user-visible fact against the built client with a DOM Range
over the backdrop's own text — after a wheel gesture over a 40-line draft
the last line is on screen and the first has scrolled out. Confirmed both
directions: with the mirroring reverted and the packages rebuilt, the
golden reads `last draft line is on screen: false` while `textarea moved:
true`.
2026-07-31 11:53:04 +08:00
|
|
|
}
|
fix(web): bind the composer's caret to its glyphs with one scrollport
The composer paints its draft in two layers — the textarea owns the value,
the selection and the caret, the backdrop paints every visible glyph — and
they had one scroll offset each, kept equal by a `scroll` listener. That
holds at rest and not in motion: a wheel gesture scrolls the textarea on the
compositor, the listener runs afterwards, and for those frames the caret sits
at the new offset with the words at the old one. Measured on a harness of the
same geometry, a 200px offset change separates caret from glyphs by ~200px
(chromium 203, firefox 202, WebKit 203) until a later frame — the caret
flying out of its own text when a user swipes a long draft quickly.
Both layers now ride one scrollport: `[data-input-scroll]` carries the
14-line cap, the auto-grow stack inside it is as tall as the whole draft, and
the textarea holds no scrollable overflow of its own. The browser applies one
offset to both layers in the same frame, so the coupling is structural rather
than maintained. The backdrop's trailing-line sentinel and the cross-engine
wrap-width premise go with the mirror: the layers now share a containing
block, which closes the WebKit 768-against-776 gap by construction.
2026-07-31 15:25:29 +08:00
|
|
|
const paddingTop = Number.parseFloat(getComputedStyle(input).paddingTop)
|
|
|
|
|
// Where the CARET sits on the draft's first line: the textarea lays its own
|
|
|
|
|
// (transparent) glyphs out from its border box, shifted by any offset it
|
|
|
|
|
// holds itself. Reading the caret's frame this way rather than the
|
|
|
|
|
// scrollport's is what makes the gap the user-visible quantity — it stays
|
|
|
|
|
// honest if the textarea ever starts scrolling on its own again.
|
|
|
|
|
const gap = (): number =>
|
|
|
|
|
Math.round(input.getBoundingClientRect().top + paddingTop - input.scrollTop - glyphTop(first))
|
|
|
|
|
// The same-task probe: move the offset and re-read the gap before the task
|
|
|
|
|
// ends, which is before any scroll event could have run a listener.
|
|
|
|
|
const before = gap()
|
|
|
|
|
const restore = scroll.scrollTop
|
|
|
|
|
scroll.scrollTop = restore === 0 ? 120 : 0
|
|
|
|
|
const gapShiftOnScroll = Math.abs(gap() - before)
|
|
|
|
|
scroll.scrollTop = restore
|
|
|
|
|
const box = scroll.getBoundingClientRect()
|
fix(web): scroll the composer's glyph layer with its textarea
A composer draft past the 14-line cap could not be scrolled: the caret and
the selection moved, but the words stayed frozen at line 1, so the tail of
anything longer than the cap was unreachable while writing it.
The composer paints its text in two stacked layers. The textarea owns the
value, the selection and the caret but renders its own glyphs transparent;
every visible character is painted by the decoration backdrop beneath it,
which also carries the claim-token highlight, the chips and the ghost hint.
The backdrop is `inset: 0; overflow: hidden` — clipped, not scrolled — and
nothing linked its offset to the textarea's. Below the cap both layers rest
at 0, which is why the defect hid behind every short-draft screenshot and
fixture.
InputBar now mirrors the textarea's scrollTop onto the backdrop, from a
`scroll` listener (every gesture and every caret-driven scroll) and from a
layout effect keyed on the committed draft (an edit reflows both layers
without necessarily firing a scroll event).
Scrolling is layout, so jsdom cannot show this: the unit spec stubs both
offsets and proves the mirroring paths run, while a new browser scenario
measures the user-visible fact against the built client with a DOM Range
over the backdrop's own text — after a wheel gesture over a 40-line draft
the last line is on screen and the first has scrolled out. Confirmed both
directions: with the mirroring reverted and the packages rebuilt, the
golden reads `last draft line is on screen: false` while `textarea moved:
true`.
2026-07-31 11:53:04 +08:00
|
|
|
return {
|
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.
2026-07-31 12:39:52 +08:00
|
|
|
inputWrapWidth: input.clientWidth,
|
|
|
|
|
backdropWrapWidth: backdrop.clientWidth,
|
2026-07-31 13:13:50 +08:00
|
|
|
mirrorWrapWidth: mirror.clientWidth,
|
fix(web): bind the composer's caret to its glyphs with one scrollport
The composer paints its draft in two layers — the textarea owns the value,
the selection and the caret, the backdrop paints every visible glyph — and
they had one scroll offset each, kept equal by a `scroll` listener. That
holds at rest and not in motion: a wheel gesture scrolls the textarea on the
compositor, the listener runs afterwards, and for those frames the caret sits
at the new offset with the words at the old one. Measured on a harness of the
same geometry, a 200px offset change separates caret from glyphs by ~200px
(chromium 203, firefox 202, WebKit 203) until a later frame — the caret
flying out of its own text when a user swipes a long draft quickly.
Both layers now ride one scrollport: `[data-input-scroll]` carries the
14-line cap, the auto-grow stack inside it is as tall as the whole draft, and
the textarea holds no scrollable overflow of its own. The browser applies one
offset to both layers in the same frame, so the coupling is structural rather
than maintained. The backdrop's trailing-line sentinel and the cross-engine
wrap-width premise go with the mirror: the layers now share a containing
block, which closes the WebKit 768-against-776 gap by construction.
2026-07-31 15:25:29 +08:00
|
|
|
overflows: scroll.scrollHeight > scroll.clientHeight,
|
|
|
|
|
clientHeight: scroll.clientHeight,
|
|
|
|
|
visibleLines: Math.floor(scroll.clientHeight / lineHeight),
|
|
|
|
|
scrollTop: scroll.scrollTop,
|
|
|
|
|
scrollMax: scroll.scrollHeight - scroll.clientHeight,
|
|
|
|
|
inputScrollable: input.scrollHeight - input.clientHeight,
|
|
|
|
|
caretGlyphGap: before,
|
|
|
|
|
gapShiftOnScroll,
|
|
|
|
|
lastLineOffset: glyphTop(last) - box.top,
|
|
|
|
|
firstLineOffset: glyphTop(first) - box.top,
|
fix(web): scroll the composer's glyph layer with its textarea
A composer draft past the 14-line cap could not be scrolled: the caret and
the selection moved, but the words stayed frozen at line 1, so the tail of
anything longer than the cap was unreachable while writing it.
The composer paints its text in two stacked layers. The textarea owns the
value, the selection and the caret but renders its own glyphs transparent;
every visible character is painted by the decoration backdrop beneath it,
which also carries the claim-token highlight, the chips and the ghost hint.
The backdrop is `inset: 0; overflow: hidden` — clipped, not scrolled — and
nothing linked its offset to the textarea's. Below the cap both layers rest
at 0, which is why the defect hid behind every short-draft screenshot and
fixture.
InputBar now mirrors the textarea's scrollTop onto the backdrop, from a
`scroll` listener (every gesture and every caret-driven scroll) and from a
layout effect keyed on the committed draft (an edit reflows both layers
without necessarily firing a scroll event).
Scrolling is layout, so jsdom cannot show this: the unit spec stubs both
offsets and proves the mirroring paths run, while a new browser scenario
measures the user-visible fact against the built client with a DOM Range
over the backdrop's own text — after a wheel gesture over a 40-line draft
the last line is on screen and the first has scrolled out. Confirmed both
directions: with the mirroring reverted and the packages rebuilt, the
golden reads `last draft line is on screen: false` while `textarea moved:
true`.
2026-07-31 11:53:04 +08:00
|
|
|
}
|
|
|
|
|
}, { first: FIRST_MARKER, last: LAST_MARKER })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-08-19 17:55:06 +08:00
|
|
|
* Render platform-neutral comparisons instead of font-dependent glyph
|
|
|
|
|
* coordinates.
|
fix(web): scroll the composer's glyph layer with its textarea
A composer draft past the 14-line cap could not be scrolled: the caret and
the selection moved, but the words stayed frozen at line 1, so the tail of
anything longer than the cap was unreachable while writing it.
The composer paints its text in two stacked layers. The textarea owns the
value, the selection and the caret but renders its own glyphs transparent;
every visible character is painted by the decoration backdrop beneath it,
which also carries the claim-token highlight, the chips and the ghost hint.
The backdrop is `inset: 0; overflow: hidden` — clipped, not scrolled — and
nothing linked its offset to the textarea's. Below the cap both layers rest
at 0, which is why the defect hid behind every short-draft screenshot and
fixture.
InputBar now mirrors the textarea's scrollTop onto the backdrop, from a
`scroll` listener (every gesture and every caret-driven scroll) and from a
layout effect keyed on the committed draft (an edit reflows both layers
without necessarily firing a scroll event).
Scrolling is layout, so jsdom cannot show this: the unit spec stubs both
offsets and proves the mirroring paths run, while a new browser scenario
measures the user-visible fact against the built client with a DOM Range
over the backdrop's own text — after a wheel gesture over a 40-line draft
the last line is on screen and the first has scrolled out. Confirmed both
directions: with the mirroring reverted and the packages rebuilt, the
golden reads `last draft line is on screen: false` while `textarea moved:
true`.
2026-07-31 11:53:04 +08:00
|
|
|
* @param top - metrics with the draft scrolled to its start.
|
|
|
|
|
* @param bottom - metrics with the draft scrolled to its end.
|
2026-07-31 13:13:50 +08:00
|
|
|
* @param trailingNewline - metrics with the trailing-newline draft scrolled to its end.
|
2026-07-31 16:07:55 +08:00
|
|
|
* @param pasted - metrics right after a long block was pasted at the draft's end.
|
fix(web): scroll the composer's glyph layer with its textarea
A composer draft past the 14-line cap could not be scrolled: the caret and
the selection moved, but the words stayed frozen at line 1, so the tail of
anything longer than the cap was unreachable while writing it.
The composer paints its text in two stacked layers. The textarea owns the
value, the selection and the caret but renders its own glyphs transparent;
every visible character is painted by the decoration backdrop beneath it,
which also carries the claim-token highlight, the chips and the ghost hint.
The backdrop is `inset: 0; overflow: hidden` — clipped, not scrolled — and
nothing linked its offset to the textarea's. Below the cap both layers rest
at 0, which is why the defect hid behind every short-draft screenshot and
fixture.
InputBar now mirrors the textarea's scrollTop onto the backdrop, from a
`scroll` listener (every gesture and every caret-driven scroll) and from a
layout effect keyed on the committed draft (an edit reflows both layers
without necessarily firing a scroll event).
Scrolling is layout, so jsdom cannot show this: the unit spec stubs both
offsets and proves the mirroring paths run, while a new browser scenario
measures the user-visible fact against the built client with a DOM Range
over the backdrop's own text — after a wheel gesture over a 40-line draft
the last line is on screen and the first has scrolled out. Confirmed both
directions: with the mirroring reverted and the packages rebuilt, the
golden reads `last draft line is on screen: false` while `textarea moved:
true`.
2026-07-31 11:53:04 +08:00
|
|
|
* @returns the golden body, without a trailing newline.
|
|
|
|
|
*/
|
2026-07-31 16:07:55 +08:00
|
|
|
function renderGeometry(
|
|
|
|
|
top: ComposerMetrics, bottom: ComposerMetrics, trailingNewline: ComposerMetrics, pasted: ComposerMetrics,
|
|
|
|
|
): string {
|
fix(web): scroll the composer's glyph layer with its textarea
A composer draft past the 14-line cap could not be scrolled: the caret and
the selection moved, but the words stayed frozen at line 1, so the tail of
anything longer than the cap was unreachable while writing it.
The composer paints its text in two stacked layers. The textarea owns the
value, the selection and the caret but renders its own glyphs transparent;
every visible character is painted by the decoration backdrop beneath it,
which also carries the claim-token highlight, the chips and the ghost hint.
The backdrop is `inset: 0; overflow: hidden` — clipped, not scrolled — and
nothing linked its offset to the textarea's. Below the cap both layers rest
at 0, which is why the defect hid behind every short-draft screenshot and
fixture.
InputBar now mirrors the textarea's scrollTop onto the backdrop, from a
`scroll` listener (every gesture and every caret-driven scroll) and from a
layout effect keyed on the committed draft (an edit reflows both layers
without necessarily firing a scroll event).
Scrolling is layout, so jsdom cannot show this: the unit spec stubs both
offsets and proves the mirroring paths run, while a new browser scenario
measures the user-visible fact against the built client with a DOM Range
over the backdrop's own text — after a wheel gesture over a 40-line draft
the last line is on screen and the first has scrolled out. Confirmed both
directions: with the mirroring reverted and the packages rebuilt, the
golden reads `last draft line is on screen: false` while `textarea moved:
true`.
2026-07-31 11:53:04 +08:00
|
|
|
return [
|
fix(web): bind the composer's caret to its glyphs with one scrollport
The composer paints its draft in two layers — the textarea owns the value,
the selection and the caret, the backdrop paints every visible glyph — and
they had one scroll offset each, kept equal by a `scroll` listener. That
holds at rest and not in motion: a wheel gesture scrolls the textarea on the
compositor, the listener runs afterwards, and for those frames the caret sits
at the new offset with the words at the old one. Measured on a harness of the
same geometry, a 200px offset change separates caret from glyphs by ~200px
(chromium 203, firefox 202, WebKit 203) until a later frame — the caret
flying out of its own text when a user swipes a long draft quickly.
Both layers now ride one scrollport: `[data-input-scroll]` carries the
14-line cap, the auto-grow stack inside it is as tall as the whole draft, and
the textarea holds no scrollable overflow of its own. The browser applies one
offset to both layers in the same frame, so the coupling is structural rather
than maintained. The backdrop's trailing-line sentinel and the cross-engine
wrap-width premise go with the mirror: the layers now share a containing
block, which closes the WebKit 768-against-776 gap by construction.
2026-07-31 15:25:29 +08:00
|
|
|
'# Composer draft scrolling (14-line cap, two text layers, one scrollport)',
|
fix(web): scroll the composer's glyph layer with its textarea
A composer draft past the 14-line cap could not be scrolled: the caret and
the selection moved, but the words stayed frozen at line 1, so the tail of
anything longer than the cap was unreachable while writing it.
The composer paints its text in two stacked layers. The textarea owns the
value, the selection and the caret but renders its own glyphs transparent;
every visible character is painted by the decoration backdrop beneath it,
which also carries the claim-token highlight, the chips and the ghost hint.
The backdrop is `inset: 0; overflow: hidden` — clipped, not scrolled — and
nothing linked its offset to the textarea's. Below the cap both layers rest
at 0, which is why the defect hid behind every short-draft screenshot and
fixture.
InputBar now mirrors the textarea's scrollTop onto the backdrop, from a
`scroll` listener (every gesture and every caret-driven scroll) and from a
layout effect keyed on the committed draft (an edit reflows both layers
without necessarily firing a scroll event).
Scrolling is layout, so jsdom cannot show this: the unit spec stubs both
offsets and proves the mirroring paths run, while a new browser scenario
measures the user-visible fact against the built client with a DOM Range
over the backdrop's own text — after a wheel gesture over a 40-line draft
the last line is on screen and the first has scrolled out. Confirmed both
directions: with the mirroring reverted and the packages rebuilt, the
golden reads `last draft line is on screen: false` while `textarea moved:
true`.
2026-07-31 11:53:04 +08:00
|
|
|
'',
|
|
|
|
|
'## At the start of the draft',
|
|
|
|
|
'',
|
|
|
|
|
`- draft overflows the capped box: ${String(top.overflows)}`,
|
|
|
|
|
`- visible lines: ${String(top.visibleLines)}`,
|
fix(web): bind the composer's caret to its glyphs with one scrollport
The composer paints its draft in two layers — the textarea owns the value,
the selection and the caret, the backdrop paints every visible glyph — and
they had one scroll offset each, kept equal by a `scroll` listener. That
holds at rest and not in motion: a wheel gesture scrolls the textarea on the
compositor, the listener runs afterwards, and for those frames the caret sits
at the new offset with the words at the old one. Measured on a harness of the
same geometry, a 200px offset change separates caret from glyphs by ~200px
(chromium 203, firefox 202, WebKit 203) until a later frame — the caret
flying out of its own text when a user swipes a long draft quickly.
Both layers now ride one scrollport: `[data-input-scroll]` carries the
14-line cap, the auto-grow stack inside it is as tall as the whole draft, and
the textarea holds no scrollable overflow of its own. The browser applies one
offset to both layers in the same frame, so the coupling is structural rather
than maintained. The backdrop's trailing-line sentinel and the cross-engine
wrap-width premise go with the mirror: the layers now share a containing
block, which closes the WebKit 768-against-776 gap by construction.
2026-07-31 15:25:29 +08:00
|
|
|
`- the textarea holds no scroll offset of its own: ${String(top.inputScrollable === 0)}`,
|
2026-07-31 13:13:50 +08:00
|
|
|
`- all three layers wrap at one width: ${String(
|
|
|
|
|
top.inputWrapWidth === top.backdropWrapWidth && top.backdropWrapWidth === top.mirrorWrapWidth,
|
|
|
|
|
)}`,
|
fix(web): bind the composer's caret to its glyphs with one scrollport
The composer paints its draft in two layers — the textarea owns the value,
the selection and the caret, the backdrop paints every visible glyph — and
they had one scroll offset each, kept equal by a `scroll` listener. That
holds at rest and not in motion: a wheel gesture scrolls the textarea on the
compositor, the listener runs afterwards, and for those frames the caret sits
at the new offset with the words at the old one. Measured on a harness of the
same geometry, a 200px offset change separates caret from glyphs by ~200px
(chromium 203, firefox 202, WebKit 203) until a later frame — the caret
flying out of its own text when a user swipes a long draft quickly.
Both layers now ride one scrollport: `[data-input-scroll]` carries the
14-line cap, the auto-grow stack inside it is as tall as the whole draft, and
the textarea holds no scrollable overflow of its own. The browser applies one
offset to both layers in the same frame, so the coupling is structural rather
than maintained. The backdrop's trailing-line sentinel and the cross-engine
wrap-width premise go with the mirror: the layers now share a containing
block, which closes the WebKit 768-against-776 gap by construction.
2026-07-31 15:25:29 +08:00
|
|
|
`- scroll offset: ${String(top.scrollTop)}px`,
|
|
|
|
|
`- caret and glyphs stay level when the offset changes: ${String(top.gapShiftOnScroll === 0)}`,
|
fix(web): scroll the composer's glyph layer with its textarea
A composer draft past the 14-line cap could not be scrolled: the caret and
the selection moved, but the words stayed frozen at line 1, so the tail of
anything longer than the cap was unreachable while writing it.
The composer paints its text in two stacked layers. The textarea owns the
value, the selection and the caret but renders its own glyphs transparent;
every visible character is painted by the decoration backdrop beneath it,
which also carries the claim-token highlight, the chips and the ghost hint.
The backdrop is `inset: 0; overflow: hidden` — clipped, not scrolled — and
nothing linked its offset to the textarea's. Below the cap both layers rest
at 0, which is why the defect hid behind every short-draft screenshot and
fixture.
InputBar now mirrors the textarea's scrollTop onto the backdrop, from a
`scroll` listener (every gesture and every caret-driven scroll) and from a
layout effect keyed on the committed draft (an edit reflows both layers
without necessarily firing a scroll event).
Scrolling is layout, so jsdom cannot show this: the unit spec stubs both
offsets and proves the mirroring paths run, while a new browser scenario
measures the user-visible fact against the built client with a DOM Range
over the backdrop's own text — after a wheel gesture over a 40-line draft
the last line is on screen and the first has scrolled out. Confirmed both
directions: with the mirroring reverted and the packages rebuilt, the
golden reads `last draft line is on screen: false` while `textarea moved:
true`.
2026-07-31 11:53:04 +08:00
|
|
|
`- first draft line is on screen: ${String(top.firstLineOffset >= 0 && top.firstLineOffset < top.clientHeight)}`,
|
|
|
|
|
`- last draft line is on screen: ${String(top.lastLineOffset >= 0 && top.lastLineOffset < top.clientHeight)}`,
|
|
|
|
|
'',
|
|
|
|
|
'## Scrolled to the end of the draft',
|
|
|
|
|
'',
|
fix(web): bind the composer's caret to its glyphs with one scrollport
The composer paints its draft in two layers — the textarea owns the value,
the selection and the caret, the backdrop paints every visible glyph — and
they had one scroll offset each, kept equal by a `scroll` listener. That
holds at rest and not in motion: a wheel gesture scrolls the textarea on the
compositor, the listener runs afterwards, and for those frames the caret sits
at the new offset with the words at the old one. Measured on a harness of the
same geometry, a 200px offset change separates caret from glyphs by ~200px
(chromium 203, firefox 202, WebKit 203) until a later frame — the caret
flying out of its own text when a user swipes a long draft quickly.
Both layers now ride one scrollport: `[data-input-scroll]` carries the
14-line cap, the auto-grow stack inside it is as tall as the whole draft, and
the textarea holds no scrollable overflow of its own. The browser applies one
offset to both layers in the same frame, so the coupling is structural rather
than maintained. The backdrop's trailing-line sentinel and the cross-engine
wrap-width premise go with the mirror: the layers now share a containing
block, which closes the WebKit 768-against-776 gap by construction.
2026-07-31 15:25:29 +08:00
|
|
|
`- offset moved: ${String(bottom.scrollTop > 0)}`,
|
|
|
|
|
`- caret sits on its own glyphs: ${String(bottom.caretGlyphGap === top.caretGlyphGap)}`,
|
|
|
|
|
`- caret and glyphs stay level when the offset changes: ${String(bottom.gapShiftOnScroll === 0)}`,
|
fix(web): scroll the composer's glyph layer with its textarea
A composer draft past the 14-line cap could not be scrolled: the caret and
the selection moved, but the words stayed frozen at line 1, so the tail of
anything longer than the cap was unreachable while writing it.
The composer paints its text in two stacked layers. The textarea owns the
value, the selection and the caret but renders its own glyphs transparent;
every visible character is painted by the decoration backdrop beneath it,
which also carries the claim-token highlight, the chips and the ghost hint.
The backdrop is `inset: 0; overflow: hidden` — clipped, not scrolled — and
nothing linked its offset to the textarea's. Below the cap both layers rest
at 0, which is why the defect hid behind every short-draft screenshot and
fixture.
InputBar now mirrors the textarea's scrollTop onto the backdrop, from a
`scroll` listener (every gesture and every caret-driven scroll) and from a
layout effect keyed on the committed draft (an edit reflows both layers
without necessarily firing a scroll event).
Scrolling is layout, so jsdom cannot show this: the unit spec stubs both
offsets and proves the mirroring paths run, while a new browser scenario
measures the user-visible fact against the built client with a DOM Range
over the backdrop's own text — after a wheel gesture over a 40-line draft
the last line is on screen and the first has scrolled out. Confirmed both
directions: with the mirroring reverted and the packages rebuilt, the
golden reads `last draft line is on screen: false` while `textarea moved:
true`.
2026-07-31 11:53:04 +08:00
|
|
|
`- first draft line has scrolled out above: ${String(bottom.firstLineOffset < 0)}`,
|
|
|
|
|
`- last draft line is on screen: ${String(bottom.lastLineOffset >= 0 && bottom.lastLineOffset < bottom.clientHeight)}`,
|
fix(web): give the backdrop the trailing-line sentinel so the layers share one extent
Review caught a real divergence the earlier measurements missed: mirroring
an offset is only correct while both layers can reach it, and for a draft
ending in a newline the backdrop could not.
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. So a draft ending in a newline made the backdrop exactly one
line shorter than the textarea — measured 628 against 652 — and the mirrored
assignment clamped, leaving the glyphs one line behind the caret at the very
bottom of the draft.
The backdrop now carries the same trailing-line sentinel the mirror div has
carried all along: its content is the decoration walk plus one newline. The
same pre-wrap collapse absorbs it when the draft does not end in a newline,
so it costs no height in the ordinary case, and it supplies the missing line
box when it does. Verified in isolation first: a bare pre-wrap div measures
180/180/198 against a textarea's 180/198/216 for zero, one and two trailing
newlines, and 180/198/216 with the sentinel.
Coverage for the shape that exposed it: the browser scenario asserts the two
extents are equal before asserting the glyphs reach the end, observing each
layer's maximum by asking for an impossible offset and reading back the
clamp rather than computing it from scrollHeight, and the golden records the
relation. The unit spec pins the backdrop's text as the draft plus exactly
one newline. Removing the sentinel fails both, the e2e with the same 628
against 652.
The scrollbar-gutter half of the same review point does not reproduce here:
both layers measure clientWidth 776 against a border box of 776 while the
draft overflows, so this engine's textarea scrollbar is an overlay and takes
no width out of the wrap.
2026-07-31 12:12:54 +08:00
|
|
|
'',
|
|
|
|
|
'## Draft ending in a newline, scrolled to the end',
|
|
|
|
|
'',
|
fix(web): bind the composer's caret to its glyphs with one scrollport
The composer paints its draft in two layers — the textarea owns the value,
the selection and the caret, the backdrop paints every visible glyph — and
they had one scroll offset each, kept equal by a `scroll` listener. That
holds at rest and not in motion: a wheel gesture scrolls the textarea on the
compositor, the listener runs afterwards, and for those frames the caret sits
at the new offset with the words at the old one. Measured on a harness of the
same geometry, a 200px offset change separates caret from glyphs by ~200px
(chromium 203, firefox 202, WebKit 203) until a later frame — the caret
flying out of its own text when a user swipes a long draft quickly.
Both layers now ride one scrollport: `[data-input-scroll]` carries the
14-line cap, the auto-grow stack inside it is as tall as the whole draft, and
the textarea holds no scrollable overflow of its own. The browser applies one
offset to both layers in the same frame, so the coupling is structural rather
than maintained. The backdrop's trailing-line sentinel and the cross-engine
wrap-width premise go with the mirror: the layers now share a containing
block, which closes the WebKit 768-against-776 gap by construction.
2026-07-31 15:25:29 +08:00
|
|
|
`- caret sits on its own glyphs: ${String(trailingNewline.caretGlyphGap === top.caretGlyphGap)}`,
|
|
|
|
|
`- the draft's own last line is on screen: ${String(
|
|
|
|
|
trailingNewline.lastLineOffset >= 0 && trailingNewline.lastLineOffset < trailingNewline.clientHeight,
|
|
|
|
|
)}`,
|
2026-07-31 16:07:55 +08:00
|
|
|
'',
|
|
|
|
|
'## Right after pasting a long block at the end',
|
|
|
|
|
'',
|
|
|
|
|
`- the composer scrolled to the caret it left: ${String(pasted.scrollTop > 0)}`,
|
|
|
|
|
`- caret and glyphs stay level when the offset changes: ${String(pasted.gapShiftOnScroll === 0)}`,
|
|
|
|
|
`- the pasted block's last line is on screen: ${String(
|
|
|
|
|
pasted.lastLineOffset >= 0 && pasted.lastLineOffset < pasted.clientHeight,
|
|
|
|
|
)}`,
|
fix(web): scroll the composer's glyph layer with its textarea
A composer draft past the 14-line cap could not be scrolled: the caret and
the selection moved, but the words stayed frozen at line 1, so the tail of
anything longer than the cap was unreachable while writing it.
The composer paints its text in two stacked layers. The textarea owns the
value, the selection and the caret but renders its own glyphs transparent;
every visible character is painted by the decoration backdrop beneath it,
which also carries the claim-token highlight, the chips and the ghost hint.
The backdrop is `inset: 0; overflow: hidden` — clipped, not scrolled — and
nothing linked its offset to the textarea's. Below the cap both layers rest
at 0, which is why the defect hid behind every short-draft screenshot and
fixture.
InputBar now mirrors the textarea's scrollTop onto the backdrop, from a
`scroll` listener (every gesture and every caret-driven scroll) and from a
layout effect keyed on the committed draft (an edit reflows both layers
without necessarily firing a scroll event).
Scrolling is layout, so jsdom cannot show this: the unit spec stubs both
offsets and proves the mirroring paths run, while a new browser scenario
measures the user-visible fact against the built client with a DOM Range
over the backdrop's own text — after a wheel gesture over a 40-line draft
the last line is on screen and the first has scrolled out. Confirmed both
directions: with the mirroring reverted and the packages rebuilt, the
golden reads `last draft line is on screen: false` while `textarea moved:
true`.
2026-07-31 11:53:04 +08:00
|
|
|
].join('\n').trimEnd()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe('web e2e: composer draft scrolling', () => {
|
|
|
|
|
let scaffold: WebScaffold
|
|
|
|
|
let browser: Browser
|
|
|
|
|
let page: Page
|
|
|
|
|
let tripwire: ReturnType<typeof watchConsole>
|
|
|
|
|
|
|
|
|
|
beforeAll(async () => {
|
|
|
|
|
scaffold = await launchWebScaffold({})
|
|
|
|
|
browser = await chromium.launch()
|
|
|
|
|
page = await newEnglishPage(browser)
|
|
|
|
|
tripwire = watchConsole(page)
|
|
|
|
|
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
|
|
|
|
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
2026-07-31 15:47:40 +08:00
|
|
|
await connectFreshWorkspace(page, scaffold.workspaceCwd, 'composer-draft-scroll')
|
fix(web): scroll the composer's glyph layer with its textarea
A composer draft past the 14-line cap could not be scrolled: the caret and
the selection moved, but the words stayed frozen at line 1, so the tail of
anything longer than the cap was unreachable while writing it.
The composer paints its text in two stacked layers. The textarea owns the
value, the selection and the caret but renders its own glyphs transparent;
every visible character is painted by the decoration backdrop beneath it,
which also carries the claim-token highlight, the chips and the ghost hint.
The backdrop is `inset: 0; overflow: hidden` — clipped, not scrolled — and
nothing linked its offset to the textarea's. Below the cap both layers rest
at 0, which is why the defect hid behind every short-draft screenshot and
fixture.
InputBar now mirrors the textarea's scrollTop onto the backdrop, from a
`scroll` listener (every gesture and every caret-driven scroll) and from a
layout effect keyed on the committed draft (an edit reflows both layers
without necessarily firing a scroll event).
Scrolling is layout, so jsdom cannot show this: the unit spec stubs both
offsets and proves the mirroring paths run, while a new browser scenario
measures the user-visible fact against the built client with a DOM Range
over the backdrop's own text — after a wheel gesture over a 40-line draft
the last line is on screen and the first has scrolled out. Confirmed both
directions: with the mirroring reverted and the packages rebuilt, the
golden reads `last draft line is on screen: false` while `textarea moved:
true`.
2026-07-31 11:53:04 +08:00
|
|
|
await page.locator('textarea:enabled').first().fill(DRAFT)
|
|
|
|
|
}, 180_000)
|
|
|
|
|
|
|
|
|
|
afterAll(async () => {
|
|
|
|
|
await browser?.close()
|
|
|
|
|
await scaffold?.close()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('caps the draft box and keeps both text layers at the start', async () => {
|
|
|
|
|
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-top'))
|
|
|
|
|
// Vacuity guard: without an overflowing draft there is nothing to scroll and
|
|
|
|
|
// every assertion below holds trivially.
|
|
|
|
|
await expect.poll(async () => (await measureComposer(page)).overflows, { timeout: 10_000 }).toBe(true)
|
|
|
|
|
// Typing the draft left the caret — and the box — at its end, so reach the
|
|
|
|
|
// start by the same gesture a user would, and leave it there for the wheel
|
|
|
|
|
// case below.
|
|
|
|
|
await page.locator('textarea:enabled').first().hover()
|
|
|
|
|
await page.mouse.wheel(0, -2000)
|
fix(web): bind the composer's caret to its glyphs with one scrollport
The composer paints its draft in two layers — the textarea owns the value,
the selection and the caret, the backdrop paints every visible glyph — and
they had one scroll offset each, kept equal by a `scroll` listener. That
holds at rest and not in motion: a wheel gesture scrolls the textarea on the
compositor, the listener runs afterwards, and for those frames the caret sits
at the new offset with the words at the old one. Measured on a harness of the
same geometry, a 200px offset change separates caret from glyphs by ~200px
(chromium 203, firefox 202, WebKit 203) until a later frame — the caret
flying out of its own text when a user swipes a long draft quickly.
Both layers now ride one scrollport: `[data-input-scroll]` carries the
14-line cap, the auto-grow stack inside it is as tall as the whole draft, and
the textarea holds no scrollable overflow of its own. The browser applies one
offset to both layers in the same frame, so the coupling is structural rather
than maintained. The backdrop's trailing-line sentinel and the cross-engine
wrap-width premise go with the mirror: the layers now share a containing
block, which closes the WebKit 768-against-776 gap by construction.
2026-07-31 15:25:29 +08:00
|
|
|
await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 }).toBe(0)
|
fix(web): scroll the composer's glyph layer with its textarea
A composer draft past the 14-line cap could not be scrolled: the caret and
the selection moved, but the words stayed frozen at line 1, so the tail of
anything longer than the cap was unreachable while writing it.
The composer paints its text in two stacked layers. The textarea owns the
value, the selection and the caret but renders its own glyphs transparent;
every visible character is painted by the decoration backdrop beneath it,
which also carries the claim-token highlight, the chips and the ghost hint.
The backdrop is `inset: 0; overflow: hidden` — clipped, not scrolled — and
nothing linked its offset to the textarea's. Below the cap both layers rest
at 0, which is why the defect hid behind every short-draft screenshot and
fixture.
InputBar now mirrors the textarea's scrollTop onto the backdrop, from a
`scroll` listener (every gesture and every caret-driven scroll) and from a
layout effect keyed on the committed draft (an edit reflows both layers
without necessarily firing a scroll event).
Scrolling is layout, so jsdom cannot show this: the unit spec stubs both
offsets and proves the mirroring paths run, while a new browser scenario
measures the user-visible fact against the built client with a DOM Range
over the backdrop's own text — after a wheel gesture over a 40-line draft
the last line is on screen and the first has scrolled out. Confirmed both
directions: with the mirroring reverted and the packages rebuilt, the
golden reads `last draft line is on screen: false` while `textarea moved:
true`.
2026-07-31 11:53:04 +08:00
|
|
|
const metrics = await measureComposer(page)
|
|
|
|
|
// The cap is the composer seat's `--dsh-composer-text-max-height` (336px =
|
|
|
|
|
// 14 x 24px lines). The count, not the pixels: it is the figma constant and
|
|
|
|
|
// survives a device-pixel-ratio change.
|
|
|
|
|
expect(metrics.visibleLines).toBe(14)
|
fix(web): bind the composer's caret to its glyphs with one scrollport
The composer paints its draft in two layers — the textarea owns the value,
the selection and the caret, the backdrop paints every visible glyph — and
they had one scroll offset each, kept equal by a `scroll` listener. That
holds at rest and not in motion: a wheel gesture scrolls the textarea on the
compositor, the listener runs afterwards, and for those frames the caret sits
at the new offset with the words at the old one. Measured on a harness of the
same geometry, a 200px offset change separates caret from glyphs by ~200px
(chromium 203, firefox 202, WebKit 203) until a later frame — the caret
flying out of its own text when a user swipes a long draft quickly.
Both layers now ride one scrollport: `[data-input-scroll]` carries the
14-line cap, the auto-grow stack inside it is as tall as the whole draft, and
the textarea holds no scrollable overflow of its own. The browser applies one
offset to both layers in the same frame, so the coupling is structural rather
than maintained. The backdrop's trailing-line sentinel and the cross-engine
wrap-width premise go with the mirror: the layers now share a containing
block, which closes the WebKit 768-against-776 gap by construction.
2026-07-31 15:25:29 +08:00
|
|
|
// One scrolling box: the textarea is as tall as the draft, so there is no
|
|
|
|
|
// second offset for the caret to hold while the glyphs hold another.
|
|
|
|
|
expect(metrics.inputScrollable).toBe(0)
|
|
|
|
|
expect(metrics.scrollTop).toBe(0)
|
fix(web): scroll the composer's glyph layer with its textarea
A composer draft past the 14-line cap could not be scrolled: the caret and
the selection moved, but the words stayed frozen at line 1, so the tail of
anything longer than the cap was unreachable while writing it.
The composer paints its text in two stacked layers. The textarea owns the
value, the selection and the caret but renders its own glyphs transparent;
every visible character is painted by the decoration backdrop beneath it,
which also carries the claim-token highlight, the chips and the ghost hint.
The backdrop is `inset: 0; overflow: hidden` — clipped, not scrolled — and
nothing linked its offset to the textarea's. Below the cap both layers rest
at 0, which is why the defect hid behind every short-draft screenshot and
fixture.
InputBar now mirrors the textarea's scrollTop onto the backdrop, from a
`scroll` listener (every gesture and every caret-driven scroll) and from a
layout effect keyed on the committed draft (an edit reflows both layers
without necessarily firing a scroll event).
Scrolling is layout, so jsdom cannot show this: the unit spec stubs both
offsets and proves the mirroring paths run, while a new browser scenario
measures the user-visible fact against the built client with a DOM Range
over the backdrop's own text — after a wheel gesture over a 40-line draft
the last line is on screen and the first has scrolled out. Confirmed both
directions: with the mirroring reverted and the packages rebuilt, the
golden reads `last draft line is on screen: false` while `textarea moved:
true`.
2026-07-31 11:53:04 +08:00
|
|
|
expect(metrics.firstLineOffset).toBeGreaterThanOrEqual(0)
|
|
|
|
|
expect(metrics.firstLineOffset).toBeLessThan(metrics.clientHeight)
|
|
|
|
|
expect(metrics.lastLineOffset).toBeGreaterThan(metrics.clientHeight)
|
|
|
|
|
expect(tripwire.pageErrors).toEqual([])
|
|
|
|
|
}, 60_000)
|
|
|
|
|
|
2026-07-31 13:13:50 +08:00
|
|
|
it('lays out all three text layers at one wrap width', async () => {
|
|
|
|
|
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-wrap-width'))
|
fix(web): bind the composer's caret to its glyphs with one scrollport
The composer paints its draft in two layers — the textarea owns the value,
the selection and the caret, the backdrop paints every visible glyph — and
they had one scroll offset each, kept equal by a `scroll` listener. That
holds at rest and not in motion: a wheel gesture scrolls the textarea on the
compositor, the listener runs afterwards, and for those frames the caret sits
at the new offset with the words at the old one. Measured on a harness of the
same geometry, a 200px offset change separates caret from glyphs by ~200px
(chromium 203, firefox 202, WebKit 203) until a later frame — the caret
flying out of its own text when a user swipes a long draft quickly.
Both layers now ride one scrollport: `[data-input-scroll]` carries the
14-line cap, the auto-grow stack inside it is as tall as the whole draft, and
the textarea holds no scrollable overflow of its own. The browser applies one
offset to both layers in the same frame, so the coupling is structural rather
than maintained. The backdrop's trailing-line sentinel and the cross-engine
wrap-width premise go with the mirror: the layers now share a containing
block, which closes the WebKit 768-against-776 gap by construction.
2026-07-31 15:25:29 +08:00
|
|
|
// A layer that breaks lines somewhere else puts the words under the wrong
|
|
|
|
|
// caret, and an 8px difference is worth 2 to 5 lines on a wrap-sensitive
|
docs: purge chain-of-thought leakage from prose
Delete design-session citations (decision/audit/plan ordinals, stack
positions), change narration, review choreography, and reviewer-addressed
justification from comments, JSDoc, docs, READMEs, Agent Notes, tests, and
generator templates; restate every affected fact as current-state contract
prose. Fix generated docs at their sources and regenerate the catalogs and
cordis-surface regions; re-paste type-equiv blocks; update every bilingual
counterpart and re-record the pairs. Record the citation rule in the
committed-artifact-citations Agent Note.
2026-08-09 15:09:19 +08:00
|
|
|
// draft. All three share a containing block — the scrollport — so a
|
|
|
|
|
// scrollbar that consumes layout space costs them the same width; with
|
|
|
|
|
// only the textarea scrolling, WebKit reserves gutter space for it alone
|
|
|
|
|
// (768 against 776) while chromium and firefox do not.
|
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.
2026-07-31 12:39:52 +08:00
|
|
|
const metrics = await measureComposer(page)
|
2026-07-31 13:13:50 +08:00
|
|
|
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)
|
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.
2026-07-31 12:39:52 +08:00
|
|
|
expect(tripwire.pageErrors).toEqual([])
|
|
|
|
|
}, 60_000)
|
|
|
|
|
|
fix(web): bind the composer's caret to its glyphs with one scrollport
The composer paints its draft in two layers — the textarea owns the value,
the selection and the caret, the backdrop paints every visible glyph — and
they had one scroll offset each, kept equal by a `scroll` listener. That
holds at rest and not in motion: a wheel gesture scrolls the textarea on the
compositor, the listener runs afterwards, and for those frames the caret sits
at the new offset with the words at the old one. Measured on a harness of the
same geometry, a 200px offset change separates caret from glyphs by ~200px
(chromium 203, firefox 202, WebKit 203) until a later frame — the caret
flying out of its own text when a user swipes a long draft quickly.
Both layers now ride one scrollport: `[data-input-scroll]` carries the
14-line cap, the auto-grow stack inside it is as tall as the whole draft, and
the textarea holds no scrollable overflow of its own. The browser applies one
offset to both layers in the same frame, so the coupling is structural rather
than maintained. The backdrop's trailing-line sentinel and the cross-engine
wrap-width premise go with the mirror: the layers now share a containing
block, which closes the WebKit 768-against-776 gap by construction.
2026-07-31 15:25:29 +08:00
|
|
|
it('the glyphs cannot lag the caret: one task moves both', async () => {
|
|
|
|
|
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-lag'))
|
|
|
|
|
// The reported symptom, isolated. A scroll offset changes and the caret's
|
|
|
|
|
// distance to its own glyphs is re-read before the task ends — before any
|
|
|
|
|
// `scroll` listener could have run. With the layers on one scrollport the
|
docs: purge chain-of-thought leakage from prose
Delete design-session citations (decision/audit/plan ordinals, stack
positions), change narration, review choreography, and reviewer-addressed
justification from comments, JSDoc, docs, READMEs, Agent Notes, tests, and
generator templates; restate every affected fact as current-state contract
prose. Fix generated docs at their sources and regenerate the catalogs and
cordis-surface regions; re-paste type-equiv blocks; update every bilingual
counterpart and re-record the pairs. Record the citation rule in the
committed-artifact-citations Agent Note.
2026-08-09 15:09:19 +08:00
|
|
|
// browser moves both, so the distance is unchanged; with the glyph layer
|
fix(web): bind the composer's caret to its glyphs with one scrollport
The composer paints its draft in two layers — the textarea owns the value,
the selection and the caret, the backdrop paints every visible glyph — and
they had one scroll offset each, kept equal by a `scroll` listener. That
holds at rest and not in motion: a wheel gesture scrolls the textarea on the
compositor, the listener runs afterwards, and for those frames the caret sits
at the new offset with the words at the old one. Measured on a harness of the
same geometry, a 200px offset change separates caret from glyphs by ~200px
(chromium 203, firefox 202, WebKit 203) until a later frame — the caret
flying out of its own text when a user swipes a long draft quickly.
Both layers now ride one scrollport: `[data-input-scroll]` carries the
14-line cap, the auto-grow stack inside it is as tall as the whole draft, and
the textarea holds no scrollable overflow of its own. The browser applies one
offset to both layers in the same frame, so the coupling is structural rather
than maintained. The backdrop's trailing-line sentinel and the cross-engine
wrap-width premise go with the mirror: the layers now share a containing
block, which closes the WebKit 768-against-776 gap by construction.
2026-07-31 15:25:29 +08:00
|
|
|
// catching up in a listener it is off by the whole delta until a later
|
|
|
|
|
// frame, which is a caret flying away from its text mid-gesture.
|
|
|
|
|
const metrics = await measureComposer(page)
|
|
|
|
|
expect(metrics.gapShiftOnScroll).toBe(0)
|
|
|
|
|
expect(tripwire.pageErrors).toEqual([])
|
|
|
|
|
}, 60_000)
|
|
|
|
|
|
fix(web): scroll the composer's glyph layer with its textarea
A composer draft past the 14-line cap could not be scrolled: the caret and
the selection moved, but the words stayed frozen at line 1, so the tail of
anything longer than the cap was unreachable while writing it.
The composer paints its text in two stacked layers. The textarea owns the
value, the selection and the caret but renders its own glyphs transparent;
every visible character is painted by the decoration backdrop beneath it,
which also carries the claim-token highlight, the chips and the ghost hint.
The backdrop is `inset: 0; overflow: hidden` — clipped, not scrolled — and
nothing linked its offset to the textarea's. Below the cap both layers rest
at 0, which is why the defect hid behind every short-draft screenshot and
fixture.
InputBar now mirrors the textarea's scrollTop onto the backdrop, from a
`scroll` listener (every gesture and every caret-driven scroll) and from a
layout effect keyed on the committed draft (an edit reflows both layers
without necessarily firing a scroll event).
Scrolling is layout, so jsdom cannot show this: the unit spec stubs both
offsets and proves the mirroring paths run, while a new browser scenario
measures the user-visible fact against the built client with a DOM Range
over the backdrop's own text — after a wheel gesture over a 40-line draft
the last line is on screen and the first has scrolled out. Confirmed both
directions: with the mirroring reverted and the packages rebuilt, the
golden reads `last draft line is on screen: false` while `textarea moved:
true`.
2026-07-31 11:53:04 +08:00
|
|
|
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()
|
|
|
|
|
await input.hover()
|
fix(web): bind the composer's caret to its glyphs with one scrollport
The composer paints its draft in two layers — the textarea owns the value,
the selection and the caret, the backdrop paints every visible glyph — and
they had one scroll offset each, kept equal by a `scroll` listener. That
holds at rest and not in motion: a wheel gesture scrolls the textarea on the
compositor, the listener runs afterwards, and for those frames the caret sits
at the new offset with the words at the old one. Measured on a harness of the
same geometry, a 200px offset change separates caret from glyphs by ~200px
(chromium 203, firefox 202, WebKit 203) until a later frame — the caret
flying out of its own text when a user swipes a long draft quickly.
Both layers now ride one scrollport: `[data-input-scroll]` carries the
14-line cap, the auto-grow stack inside it is as tall as the whole draft, and
the textarea holds no scrollable overflow of its own. The browser applies one
offset to both layers in the same frame, so the coupling is structural rather
than maintained. The backdrop's trailing-line sentinel and the cross-engine
wrap-width premise go with the mirror: the layers now share a containing
block, which closes the WebKit 768-against-776 gap by construction.
2026-07-31 15:25:29 +08:00
|
|
|
const resting = (await measureComposer(page)).caretGlyphGap
|
2026-08-19 17:55:06 +08:00
|
|
|
// One delta past the whole draft: the box clamps at its own end.
|
fix(web): scroll the composer's glyph layer with its textarea
A composer draft past the 14-line cap could not be scrolled: the caret and
the selection moved, but the words stayed frozen at line 1, so the tail of
anything longer than the cap was unreachable while writing it.
The composer paints its text in two stacked layers. The textarea owns the
value, the selection and the caret but renders its own glyphs transparent;
every visible character is painted by the decoration backdrop beneath it,
which also carries the claim-token highlight, the chips and the ghost hint.
The backdrop is `inset: 0; overflow: hidden` — clipped, not scrolled — and
nothing linked its offset to the textarea's. Below the cap both layers rest
at 0, which is why the defect hid behind every short-draft screenshot and
fixture.
InputBar now mirrors the textarea's scrollTop onto the backdrop, from a
`scroll` listener (every gesture and every caret-driven scroll) and from a
layout effect keyed on the committed draft (an edit reflows both layers
without necessarily firing a scroll event).
Scrolling is layout, so jsdom cannot show this: the unit spec stubs both
offsets and proves the mirroring paths run, while a new browser scenario
measures the user-visible fact against the built client with a DOM Range
over the backdrop's own text — after a wheel gesture over a 40-line draft
the last line is on screen and the first has scrolled out. Confirmed both
directions: with the mirroring reverted and the packages rebuilt, the
golden reads `last draft line is on screen: false` while `textarea moved:
true`.
2026-07-31 11:53:04 +08:00
|
|
|
await page.mouse.wheel(0, 2000)
|
fix(web): bind the composer's caret to its glyphs with one scrollport
The composer paints its draft in two layers — the textarea owns the value,
the selection and the caret, the backdrop paints every visible glyph — and
they had one scroll offset each, kept equal by a `scroll` listener. That
holds at rest and not in motion: a wheel gesture scrolls the textarea on the
compositor, the listener runs afterwards, and for those frames the caret sits
at the new offset with the words at the old one. Measured on a harness of the
same geometry, a 200px offset change separates caret from glyphs by ~200px
(chromium 203, firefox 202, WebKit 203) until a later frame — the caret
flying out of its own text when a user swipes a long draft quickly.
Both layers now ride one scrollport: `[data-input-scroll]` carries the
14-line cap, the auto-grow stack inside it is as tall as the whole draft, and
the textarea holds no scrollable overflow of its own. The browser applies one
offset to both layers in the same frame, so the coupling is structural rather
than maintained. The backdrop's trailing-line sentinel and the cross-engine
wrap-width premise go with the mirror: the layers now share a containing
block, which closes the WebKit 768-against-776 gap by construction.
2026-07-31 15:25:29 +08:00
|
|
|
await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 })
|
fix(web): scroll the composer's glyph layer with its textarea
A composer draft past the 14-line cap could not be scrolled: the caret and
the selection moved, but the words stayed frozen at line 1, so the tail of
anything longer than the cap was unreachable while writing it.
The composer paints its text in two stacked layers. The textarea owns the
value, the selection and the caret but renders its own glyphs transparent;
every visible character is painted by the decoration backdrop beneath it,
which also carries the claim-token highlight, the chips and the ghost hint.
The backdrop is `inset: 0; overflow: hidden` — clipped, not scrolled — and
nothing linked its offset to the textarea's. Below the cap both layers rest
at 0, which is why the defect hid behind every short-draft screenshot and
fixture.
InputBar now mirrors the textarea's scrollTop onto the backdrop, from a
`scroll` listener (every gesture and every caret-driven scroll) and from a
layout effect keyed on the committed draft (an edit reflows both layers
without necessarily firing a scroll event).
Scrolling is layout, so jsdom cannot show this: the unit spec stubs both
offsets and proves the mirroring paths run, while a new browser scenario
measures the user-visible fact against the built client with a DOM Range
over the backdrop's own text — after a wheel gesture over a 40-line draft
the last line is on screen and the first has scrolled out. Confirmed both
directions: with the mirroring reverted and the packages rebuilt, the
golden reads `last draft line is on screen: false` while `textarea moved:
true`.
2026-07-31 11:53:04 +08:00
|
|
|
.toBeGreaterThan(0)
|
|
|
|
|
const metrics = await measureComposer(page)
|
fix(web): bind the composer's caret to its glyphs with one scrollport
The composer paints its draft in two layers — the textarea owns the value,
the selection and the caret, the backdrop paints every visible glyph — and
they had one scroll offset each, kept equal by a `scroll` listener. That
holds at rest and not in motion: a wheel gesture scrolls the textarea on the
compositor, the listener runs afterwards, and for those frames the caret sits
at the new offset with the words at the old one. Measured on a harness of the
same geometry, a 200px offset change separates caret from glyphs by ~200px
(chromium 203, firefox 202, WebKit 203) until a later frame — the caret
flying out of its own text when a user swipes a long draft quickly.
Both layers now ride one scrollport: `[data-input-scroll]` carries the
14-line cap, the auto-grow stack inside it is as tall as the whole draft, and
the textarea holds no scrollable overflow of its own. The browser applies one
offset to both layers in the same frame, so the coupling is structural rather
than maintained. The backdrop's trailing-line sentinel and the cross-engine
wrap-width premise go with the mirror: the layers now share a containing
block, which closes the WebKit 768-against-776 gap by construction.
2026-07-31 15:25:29 +08:00
|
|
|
// The caret is still on its own glyphs after the gesture.
|
|
|
|
|
expect(metrics.caretGlyphGap).toBe(resting)
|
fix(web): scroll the composer's glyph layer with its textarea
A composer draft past the 14-line cap could not be scrolled: the caret and
the selection moved, but the words stayed frozen at line 1, so the tail of
anything longer than the cap was unreachable while writing it.
The composer paints its text in two stacked layers. The textarea owns the
value, the selection and the caret but renders its own glyphs transparent;
every visible character is painted by the decoration backdrop beneath it,
which also carries the claim-token highlight, the chips and the ghost hint.
The backdrop is `inset: 0; overflow: hidden` — clipped, not scrolled — and
nothing linked its offset to the textarea's. Below the cap both layers rest
at 0, which is why the defect hid behind every short-draft screenshot and
fixture.
InputBar now mirrors the textarea's scrollTop onto the backdrop, from a
`scroll` listener (every gesture and every caret-driven scroll) and from a
layout effect keyed on the committed draft (an edit reflows both layers
without necessarily firing a scroll event).
Scrolling is layout, so jsdom cannot show this: the unit spec stubs both
offsets and proves the mirroring paths run, while a new browser scenario
measures the user-visible fact against the built client with a DOM Range
over the backdrop's own text — after a wheel gesture over a 40-line draft
the last line is on screen and the first has scrolled out. Confirmed both
directions: with the mirroring reverted and the packages rebuilt, the
golden reads `last draft line is on screen: false` while `textarea moved:
true`.
2026-07-31 11:53:04 +08:00
|
|
|
// The reported symptom, stated as what the user sees: the end of the draft
|
fix(web): bind the composer's caret to its glyphs with one scrollport
The composer paints its draft in two layers — the textarea owns the value,
the selection and the caret, the backdrop paints every visible glyph — and
they had one scroll offset each, kept equal by a `scroll` listener. That
holds at rest and not in motion: a wheel gesture scrolls the textarea on the
compositor, the listener runs afterwards, and for those frames the caret sits
at the new offset with the words at the old one. Measured on a harness of the
same geometry, a 200px offset change separates caret from glyphs by ~200px
(chromium 203, firefox 202, WebKit 203) until a later frame — the caret
flying out of its own text when a user swipes a long draft quickly.
Both layers now ride one scrollport: `[data-input-scroll]` carries the
14-line cap, the auto-grow stack inside it is as tall as the whole draft, and
the textarea holds no scrollable overflow of its own. The browser applies one
offset to both layers in the same frame, so the coupling is structural rather
than maintained. The backdrop's trailing-line sentinel and the cross-engine
wrap-width premise go with the mirror: the layers now share a containing
block, which closes the WebKit 768-against-776 gap by construction.
2026-07-31 15:25:29 +08:00
|
|
|
// is on screen and its beginning is not.
|
fix(web): scroll the composer's glyph layer with its textarea
A composer draft past the 14-line cap could not be scrolled: the caret and
the selection moved, but the words stayed frozen at line 1, so the tail of
anything longer than the cap was unreachable while writing it.
The composer paints its text in two stacked layers. The textarea owns the
value, the selection and the caret but renders its own glyphs transparent;
every visible character is painted by the decoration backdrop beneath it,
which also carries the claim-token highlight, the chips and the ghost hint.
The backdrop is `inset: 0; overflow: hidden` — clipped, not scrolled — and
nothing linked its offset to the textarea's. Below the cap both layers rest
at 0, which is why the defect hid behind every short-draft screenshot and
fixture.
InputBar now mirrors the textarea's scrollTop onto the backdrop, from a
`scroll` listener (every gesture and every caret-driven scroll) and from a
layout effect keyed on the committed draft (an edit reflows both layers
without necessarily firing a scroll event).
Scrolling is layout, so jsdom cannot show this: the unit spec stubs both
offsets and proves the mirroring paths run, while a new browser scenario
measures the user-visible fact against the built client with a DOM Range
over the backdrop's own text — after a wheel gesture over a 40-line draft
the last line is on screen and the first has scrolled out. Confirmed both
directions: with the mirroring reverted and the packages rebuilt, the
golden reads `last draft line is on screen: false` while `textarea moved:
true`.
2026-07-31 11:53:04 +08:00
|
|
|
expect(metrics.lastLineOffset).toBeGreaterThanOrEqual(0)
|
|
|
|
|
expect(metrics.lastLineOffset).toBeLessThan(metrics.clientHeight)
|
|
|
|
|
expect(metrics.firstLineOffset).toBeLessThan(0)
|
|
|
|
|
expect(tripwire.pageErrors).toEqual([])
|
|
|
|
|
}, 60_000)
|
|
|
|
|
|
fix(web): bind the composer's caret to its glyphs with one scrollport
The composer paints its draft in two layers — the textarea owns the value,
the selection and the caret, the backdrop paints every visible glyph — and
they had one scroll offset each, kept equal by a `scroll` listener. That
holds at rest and not in motion: a wheel gesture scrolls the textarea on the
compositor, the listener runs afterwards, and for those frames the caret sits
at the new offset with the words at the old one. Measured on a harness of the
same geometry, a 200px offset change separates caret from glyphs by ~200px
(chromium 203, firefox 202, WebKit 203) until a later frame — the caret
flying out of its own text when a user swipes a long draft quickly.
Both layers now ride one scrollport: `[data-input-scroll]` carries the
14-line cap, the auto-grow stack inside it is as tall as the whole draft, and
the textarea holds no scrollable overflow of its own. The browser applies one
offset to both layers in the same frame, so the coupling is structural rather
than maintained. The backdrop's trailing-line sentinel and the cross-engine
wrap-width premise go with the mirror: the layers now share a containing
block, which closes the WebKit 768-against-776 gap by construction.
2026-07-31 15:25:29 +08:00
|
|
|
it('typing at the end of a scrolled draft brings the caret back into view', async () => {
|
fix(web): scroll the composer's glyph layer with its textarea
A composer draft past the 14-line cap could not be scrolled: the caret and
the selection moved, but the words stayed frozen at line 1, so the tail of
anything longer than the cap was unreachable while writing it.
The composer paints its text in two stacked layers. The textarea owns the
value, the selection and the caret but renders its own glyphs transparent;
every visible character is painted by the decoration backdrop beneath it,
which also carries the claim-token highlight, the chips and the ghost hint.
The backdrop is `inset: 0; overflow: hidden` — clipped, not scrolled — and
nothing linked its offset to the textarea's. Below the cap both layers rest
at 0, which is why the defect hid behind every short-draft screenshot and
fixture.
InputBar now mirrors the textarea's scrollTop onto the backdrop, from a
`scroll` listener (every gesture and every caret-driven scroll) and from a
layout effect keyed on the committed draft (an edit reflows both layers
without necessarily firing a scroll event).
Scrolling is layout, so jsdom cannot show this: the unit spec stubs both
offsets and proves the mirroring paths run, while a new browser scenario
measures the user-visible fact against the built client with a DOM Range
over the backdrop's own text — after a wheel gesture over a 40-line draft
the last line is on screen and the first has scrolled out. Confirmed both
directions: with the mirroring reverted and the packages rebuilt, the
golden reads `last draft line is on screen: false` while `textarea moved:
true`.
2026-07-31 11:53:04 +08:00
|
|
|
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-edit'))
|
fix(web): bind the composer's caret to its glyphs with one scrollport
The composer paints its draft in two layers — the textarea owns the value,
the selection and the caret, the backdrop paints every visible glyph — and
they had one scroll offset each, kept equal by a `scroll` listener. That
holds at rest and not in motion: a wheel gesture scrolls the textarea on the
compositor, the listener runs afterwards, and for those frames the caret sits
at the new offset with the words at the old one. Measured on a harness of the
same geometry, a 200px offset change separates caret from glyphs by ~200px
(chromium 203, firefox 202, WebKit 203) until a later frame — the caret
flying out of its own text when a user swipes a long draft quickly.
Both layers now ride one scrollport: `[data-input-scroll]` carries the
14-line cap, the auto-grow stack inside it is as tall as the whole draft, and
the textarea holds no scrollable overflow of its own. The browser applies one
offset to both layers in the same frame, so the coupling is structural rather
than maintained. The backdrop's trailing-line sentinel and the cross-engine
wrap-width premise go with the mirror: the layers now share a containing
block, which closes the WebKit 768-against-776 gap by construction.
2026-07-31 15:25:29 +08:00
|
|
|
// The other way the box moves, and the one that depends on the browser: the
|
docs: purge chain-of-thought leakage from prose
Delete design-session citations (decision/audit/plan ordinals, stack
positions), change narration, review choreography, and reviewer-addressed
justification from comments, JSDoc, docs, READMEs, Agent Notes, tests, and
generator templates; restate every affected fact as current-state contract
prose. Fix generated docs at their sources and regenerate the catalogs and
cordis-surface regions; re-paste type-equiv blocks; update every bilingual
counterpart and re-record the pairs. Record the citation rule in the
committed-artifact-citations Agent Note.
2026-08-09 15:09:19 +08:00
|
|
|
// textarea holds no scroll offset of its own, so revealing the caret after
|
|
|
|
|
// an edit is a scroll-into-view that has to walk up to the scrollport.
|
|
|
|
|
// Scroll away from the caret first, so the edit has somewhere to bring it
|
|
|
|
|
// back from.
|
fix(web): scroll the composer's glyph layer with its textarea
A composer draft past the 14-line cap could not be scrolled: the caret and
the selection moved, but the words stayed frozen at line 1, so the tail of
anything longer than the cap was unreachable while writing it.
The composer paints its text in two stacked layers. The textarea owns the
value, the selection and the caret but renders its own glyphs transparent;
every visible character is painted by the decoration backdrop beneath it,
which also carries the claim-token highlight, the chips and the ghost hint.
The backdrop is `inset: 0; overflow: hidden` — clipped, not scrolled — and
nothing linked its offset to the textarea's. Below the cap both layers rest
at 0, which is why the defect hid behind every short-draft screenshot and
fixture.
InputBar now mirrors the textarea's scrollTop onto the backdrop, from a
`scroll` listener (every gesture and every caret-driven scroll) and from a
layout effect keyed on the committed draft (an edit reflows both layers
without necessarily firing a scroll event).
Scrolling is layout, so jsdom cannot show this: the unit spec stubs both
offsets and proves the mirroring paths run, while a new browser scenario
measures the user-visible fact against the built client with a DOM Range
over the backdrop's own text — after a wheel gesture over a 40-line draft
the last line is on screen and the first has scrolled out. Confirmed both
directions: with the mirroring reverted and the packages rebuilt, the
golden reads `last draft line is on screen: false` while `textarea moved:
true`.
2026-07-31 11:53:04 +08:00
|
|
|
const input = page.locator('textarea:enabled').first()
|
|
|
|
|
await input.press('End')
|
fix(web): bind the composer's caret to its glyphs with one scrollport
The composer paints its draft in two layers — the textarea owns the value,
the selection and the caret, the backdrop paints every visible glyph — and
they had one scroll offset each, kept equal by a `scroll` listener. That
holds at rest and not in motion: a wheel gesture scrolls the textarea on the
compositor, the listener runs afterwards, and for those frames the caret sits
at the new offset with the words at the old one. Measured on a harness of the
same geometry, a 200px offset change separates caret from glyphs by ~200px
(chromium 203, firefox 202, WebKit 203) until a later frame — the caret
flying out of its own text when a user swipes a long draft quickly.
Both layers now ride one scrollport: `[data-input-scroll]` carries the
14-line cap, the auto-grow stack inside it is as tall as the whole draft, and
the textarea holds no scrollable overflow of its own. The browser applies one
offset to both layers in the same frame, so the coupling is structural rather
than maintained. The backdrop's trailing-line sentinel and the cross-engine
wrap-width premise go with the mirror: the layers now share a containing
block, which closes the WebKit 768-against-776 gap by construction.
2026-07-31 15:25:29 +08:00
|
|
|
await input.hover()
|
|
|
|
|
await page.mouse.wheel(0, -2000)
|
|
|
|
|
await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 }).toBe(0)
|
fix(web): scroll the composer's glyph layer with its textarea
A composer draft past the 14-line cap could not be scrolled: the caret and
the selection moved, but the words stayed frozen at line 1, so the tail of
anything longer than the cap was unreachable while writing it.
The composer paints its text in two stacked layers. The textarea owns the
value, the selection and the caret but renders its own glyphs transparent;
every visible character is painted by the decoration backdrop beneath it,
which also carries the claim-token highlight, the chips and the ghost hint.
The backdrop is `inset: 0; overflow: hidden` — clipped, not scrolled — and
nothing linked its offset to the textarea's. Below the cap both layers rest
at 0, which is why the defect hid behind every short-draft screenshot and
fixture.
InputBar now mirrors the textarea's scrollTop onto the backdrop, from a
`scroll` listener (every gesture and every caret-driven scroll) and from a
layout effect keyed on the committed draft (an edit reflows both layers
without necessarily firing a scroll event).
Scrolling is layout, so jsdom cannot show this: the unit spec stubs both
offsets and proves the mirroring paths run, while a new browser scenario
measures the user-visible fact against the built client with a DOM Range
over the backdrop's own text — after a wheel gesture over a 40-line draft
the last line is on screen and the first has scrolled out. Confirmed both
directions: with the mirroring reverted and the packages rebuilt, the
golden reads `last draft line is on screen: false` while `textarea moved:
true`.
2026-07-31 11:53:04 +08:00
|
|
|
await input.pressSequentially(' tail')
|
|
|
|
|
const metrics = await measureComposer(page)
|
fix(web): bind the composer's caret to its glyphs with one scrollport
The composer paints its draft in two layers — the textarea owns the value,
the selection and the caret, the backdrop paints every visible glyph — and
they had one scroll offset each, kept equal by a `scroll` listener. That
holds at rest and not in motion: a wheel gesture scrolls the textarea on the
compositor, the listener runs afterwards, and for those frames the caret sits
at the new offset with the words at the old one. Measured on a harness of the
same geometry, a 200px offset change separates caret from glyphs by ~200px
(chromium 203, firefox 202, WebKit 203) until a later frame — the caret
flying out of its own text when a user swipes a long draft quickly.
Both layers now ride one scrollport: `[data-input-scroll]` carries the
14-line cap, the auto-grow stack inside it is as tall as the whole draft, and
the textarea holds no scrollable overflow of its own. The browser applies one
offset to both layers in the same frame, so the coupling is structural rather
than maintained. The backdrop's trailing-line sentinel and the cross-engine
wrap-width premise go with the mirror: the layers now share a containing
block, which closes the WebKit 768-against-776 gap by construction.
2026-07-31 15:25:29 +08:00
|
|
|
expect(metrics.scrollTop).toBeGreaterThan(0)
|
fix(web): scroll the composer's glyph layer with its textarea
A composer draft past the 14-line cap could not be scrolled: the caret and
the selection moved, but the words stayed frozen at line 1, so the tail of
anything longer than the cap was unreachable while writing it.
The composer paints its text in two stacked layers. The textarea owns the
value, the selection and the caret but renders its own glyphs transparent;
every visible character is painted by the decoration backdrop beneath it,
which also carries the claim-token highlight, the chips and the ghost hint.
The backdrop is `inset: 0; overflow: hidden` — clipped, not scrolled — and
nothing linked its offset to the textarea's. Below the cap both layers rest
at 0, which is why the defect hid behind every short-draft screenshot and
fixture.
InputBar now mirrors the textarea's scrollTop onto the backdrop, from a
`scroll` listener (every gesture and every caret-driven scroll) and from a
layout effect keyed on the committed draft (an edit reflows both layers
without necessarily firing a scroll event).
Scrolling is layout, so jsdom cannot show this: the unit spec stubs both
offsets and proves the mirroring paths run, while a new browser scenario
measures the user-visible fact against the built client with a DOM Range
over the backdrop's own text — after a wheel gesture over a 40-line draft
the last line is on screen and the first has scrolled out. Confirmed both
directions: with the mirroring reverted and the packages rebuilt, the
golden reads `last draft line is on screen: false` while `textarea moved:
true`.
2026-07-31 11:53:04 +08:00
|
|
|
expect(metrics.lastLineOffset).toBeGreaterThanOrEqual(0)
|
|
|
|
|
expect(metrics.lastLineOffset).toBeLessThan(metrics.clientHeight)
|
|
|
|
|
expect(tripwire.pageErrors).toEqual([])
|
|
|
|
|
}, 60_000)
|
|
|
|
|
|
2026-07-31 16:07:55 +08:00
|
|
|
it('pasting a long block scrolls to the caret it leaves at the end', async () => {
|
|
|
|
|
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-paste'))
|
|
|
|
|
// The composer suppresses the native paste — the machine owns the draft and
|
|
|
|
|
// the undo log — and restores the caret programmatically, which reveals
|
docs: purge chain-of-thought leakage from prose
Delete design-session citations (decision/audit/plan ordinals, stack
positions), change narration, review choreography, and reviewer-addressed
justification from comments, JSDoc, docs, READMEs, Agent Notes, tests, and
generator templates; restate every affected fact as current-state contract
prose. Fix generated docs at their sources and regenerate the catalogs and
cordis-surface regions; re-paste type-equiv blocks; update every bilingual
counterpart and re-record the pairs. Record the citation rule in the
committed-artifact-citations Agent Note.
2026-08-09 15:09:19 +08:00
|
|
|
// nothing on its own: in chromium and WebKit the view stays put while the
|
|
|
|
|
// caret sits at the end of the pasted block, so the restore scrolls it
|
|
|
|
|
// into view; this case pins it.
|
2026-07-31 16:07:55 +08:00
|
|
|
const input = page.locator('textarea:enabled').first()
|
|
|
|
|
await input.fill('one short line')
|
|
|
|
|
await input.press('End')
|
|
|
|
|
// A real `paste` event carrying real clipboard data, dispatched at the
|
|
|
|
|
// textarea: the same event a Cmd-V delivers, and it runs the same handler.
|
|
|
|
|
await input.evaluate((el, text) => {
|
|
|
|
|
const data = new DataTransfer()
|
|
|
|
|
data.setData('text/plain', text)
|
|
|
|
|
el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true }))
|
2026-08-09 15:27:21 +08:00
|
|
|
// The engines disagree when the draft ends in a newline: the caret
|
2026-07-31 17:03:55 +08:00
|
|
|
// lands on a line with nothing on it, where chromium reports no client
|
|
|
|
|
// rects at all for the collapsed position.
|
|
|
|
|
}, `\n${DRAFT}\n`)
|
2026-07-31 16:07:55 +08:00
|
|
|
await expect.poll(async () => (await measureComposer(page)).overflows, { timeout: 10_000 }).toBe(true)
|
|
|
|
|
// The restore lands one frame after the machine commits the draft, so the
|
|
|
|
|
// box overflows before it moves; waiting on the offset is waiting for the
|
|
|
|
|
// behavior itself, and its absence fails this poll.
|
|
|
|
|
await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 }).toBeGreaterThan(0)
|
|
|
|
|
const metrics = await measureComposer(page)
|
|
|
|
|
// The caret is at the end of what was pasted, so the draft's last line is
|
|
|
|
|
// what has to be on screen.
|
|
|
|
|
expect(metrics.lastLineOffset).toBeGreaterThanOrEqual(0)
|
|
|
|
|
expect(metrics.lastLineOffset).toBeLessThan(metrics.clientHeight)
|
|
|
|
|
expect(metrics.gapShiftOnScroll).toBe(0)
|
|
|
|
|
expect(tripwire.pageErrors).toEqual([])
|
|
|
|
|
}, 60_000)
|
|
|
|
|
|
fix(web): give the backdrop the trailing-line sentinel so the layers share one extent
Review caught a real divergence the earlier measurements missed: mirroring
an offset is only correct while both layers can reach it, and for a draft
ending in a newline the backdrop could not.
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. So a draft ending in a newline made the backdrop exactly one
line shorter than the textarea — measured 628 against 652 — and the mirrored
assignment clamped, leaving the glyphs one line behind the caret at the very
bottom of the draft.
The backdrop now carries the same trailing-line sentinel the mirror div has
carried all along: its content is the decoration walk plus one newline. The
same pre-wrap collapse absorbs it when the draft does not end in a newline,
so it costs no height in the ordinary case, and it supplies the missing line
box when it does. Verified in isolation first: a bare pre-wrap div measures
180/180/198 against a textarea's 180/198/216 for zero, one and two trailing
newlines, and 180/198/216 with the sentinel.
Coverage for the shape that exposed it: the browser scenario asserts the two
extents are equal before asserting the glyphs reach the end, observing each
layer's maximum by asking for an impossible offset and reading back the
clamp rather than computing it from scrollHeight, and the golden records the
relation. The unit spec pins the backdrop's text as the draft plus exactly
one newline. Removing the sentinel fails both, the e2e with the same 628
against 652.
The scrollbar-gutter half of the same review point does not reproduce here:
both layers measure clientWidth 776 against a border box of 776 while the
draft overflows, so this engine's textarea scrollbar is an overlay and takes
no width out of the wrap.
2026-07-31 12:12:54 +08:00
|
|
|
it('a draft ending in a newline scrolls to its true end, not a line above it', async () => {
|
|
|
|
|
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-trailing-newline'))
|
2026-08-09 15:27:21 +08:00
|
|
|
// The layers reserve a final line box on different terms, so the trailing-newline case is
|
fix(web): bind the composer's caret to its glyphs with one scrollport
The composer paints its draft in two layers — the textarea owns the value,
the selection and the caret, the backdrop paints every visible glyph — and
they had one scroll offset each, kept equal by a `scroll` listener. That
holds at rest and not in motion: a wheel gesture scrolls the textarea on the
compositor, the listener runs afterwards, and for those frames the caret sits
at the new offset with the words at the old one. Measured on a harness of the
same geometry, a 200px offset change separates caret from glyphs by ~200px
(chromium 203, firefox 202, WebKit 203) until a later frame — the caret
flying out of its own text when a user swipes a long draft quickly.
Both layers now ride one scrollport: `[data-input-scroll]` carries the
14-line cap, the auto-grow stack inside it is as tall as the whole draft, and
the textarea holds no scrollable overflow of its own. The browser applies one
offset to both layers in the same frame, so the coupling is structural rather
than maintained. The backdrop's trailing-line sentinel and the cross-engine
wrap-width premise go with the mirror: the layers now share a containing
block, which closes the WebKit 768-against-776 gap by construction.
2026-07-31 15:25:29 +08:00
|
|
|
// the one that separates a height every layer agrees on from a box measured
|
|
|
|
|
// one line short of the caret's own last position.
|
fix(web): give the backdrop the trailing-line sentinel so the layers share one extent
Review caught a real divergence the earlier measurements missed: mirroring
an offset is only correct while both layers can reach it, and for a draft
ending in a newline the backdrop could not.
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. So a draft ending in a newline made the backdrop exactly one
line shorter than the textarea — measured 628 against 652 — and the mirrored
assignment clamped, leaving the glyphs one line behind the caret at the very
bottom of the draft.
The backdrop now carries the same trailing-line sentinel the mirror div has
carried all along: its content is the decoration walk plus one newline. The
same pre-wrap collapse absorbs it when the draft does not end in a newline,
so it costs no height in the ordinary case, and it supplies the missing line
box when it does. Verified in isolation first: a bare pre-wrap div measures
180/180/198 against a textarea's 180/198/216 for zero, one and two trailing
newlines, and 180/198/216 with the sentinel.
Coverage for the shape that exposed it: the browser scenario asserts the two
extents are equal before asserting the glyphs reach the end, observing each
layer's maximum by asking for an impossible offset and reading back the
clamp rather than computing it from scrollHeight, and the golden records the
relation. The unit spec pins the backdrop's text as the draft plus exactly
one newline. Removing the sentinel fails both, the e2e with the same 628
against 652.
The scrollbar-gutter half of the same review point does not reproduce here:
both layers measure clientWidth 776 against a border box of 776 while the
draft overflows, so this engine's textarea scrollbar is an overlay and takes
no width out of the wrap.
2026-07-31 12:12:54 +08:00
|
|
|
const input = page.locator('textarea:enabled').first()
|
|
|
|
|
await input.fill(DRAFT_TRAILING_NEWLINE)
|
|
|
|
|
await expect.poll(async () => (await measureComposer(page)).overflows, { timeout: 10_000 }).toBe(true)
|
|
|
|
|
await input.hover()
|
|
|
|
|
await page.mouse.wheel(0, 4000)
|
|
|
|
|
await expect.poll(async () => {
|
|
|
|
|
const m = await measureComposer(page)
|
fix(web): bind the composer's caret to its glyphs with one scrollport
The composer paints its draft in two layers — the textarea owns the value,
the selection and the caret, the backdrop paints every visible glyph — and
they had one scroll offset each, kept equal by a `scroll` listener. That
holds at rest and not in motion: a wheel gesture scrolls the textarea on the
compositor, the listener runs afterwards, and for those frames the caret sits
at the new offset with the words at the old one. Measured on a harness of the
same geometry, a 200px offset change separates caret from glyphs by ~200px
(chromium 203, firefox 202, WebKit 203) until a later frame — the caret
flying out of its own text when a user swipes a long draft quickly.
Both layers now ride one scrollport: `[data-input-scroll]` carries the
14-line cap, the auto-grow stack inside it is as tall as the whole draft, and
the textarea holds no scrollable overflow of its own. The browser applies one
offset to both layers in the same frame, so the coupling is structural rather
than maintained. The backdrop's trailing-line sentinel and the cross-engine
wrap-width premise go with the mirror: the layers now share a containing
block, which closes the WebKit 768-against-776 gap by construction.
2026-07-31 15:25:29 +08:00
|
|
|
return m.scrollTop === m.scrollMax
|
fix(web): give the backdrop the trailing-line sentinel so the layers share one extent
Review caught a real divergence the earlier measurements missed: mirroring
an offset is only correct while both layers can reach it, and for a draft
ending in a newline the backdrop could not.
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. So a draft ending in a newline made the backdrop exactly one
line shorter than the textarea — measured 628 against 652 — and the mirrored
assignment clamped, leaving the glyphs one line behind the caret at the very
bottom of the draft.
The backdrop now carries the same trailing-line sentinel the mirror div has
carried all along: its content is the decoration walk plus one newline. The
same pre-wrap collapse absorbs it when the draft does not end in a newline,
so it costs no height in the ordinary case, and it supplies the missing line
box when it does. Verified in isolation first: a bare pre-wrap div measures
180/180/198 against a textarea's 180/198/216 for zero, one and two trailing
newlines, and 180/198/216 with the sentinel.
Coverage for the shape that exposed it: the browser scenario asserts the two
extents are equal before asserting the glyphs reach the end, observing each
layer's maximum by asking for an impossible offset and reading back the
clamp rather than computing it from scrollHeight, and the golden records the
relation. The unit spec pins the backdrop's text as the draft plus exactly
one newline. Removing the sentinel fails both, the e2e with the same 628
against 652.
The scrollbar-gutter half of the same review point does not reproduce here:
both layers measure clientWidth 776 against a border box of 776 while the
draft overflows, so this engine's textarea scrollbar is an overlay and takes
no width out of the wrap.
2026-07-31 12:12:54 +08:00
|
|
|
}, { timeout: 10_000 }).toBe(true)
|
|
|
|
|
const bottom = await measureComposer(page)
|
fix(web): bind the composer's caret to its glyphs with one scrollport
The composer paints its draft in two layers — the textarea owns the value,
the selection and the caret, the backdrop paints every visible glyph — and
they had one scroll offset each, kept equal by a `scroll` listener. That
holds at rest and not in motion: a wheel gesture scrolls the textarea on the
compositor, the listener runs afterwards, and for those frames the caret sits
at the new offset with the words at the old one. Measured on a harness of the
same geometry, a 200px offset change separates caret from glyphs by ~200px
(chromium 203, firefox 202, WebKit 203) until a later frame — the caret
flying out of its own text when a user swipes a long draft quickly.
Both layers now ride one scrollport: `[data-input-scroll]` carries the
14-line cap, the auto-grow stack inside it is as tall as the whole draft, and
the textarea holds no scrollable overflow of its own. The browser applies one
offset to both layers in the same frame, so the coupling is structural rather
than maintained. The backdrop's trailing-line sentinel and the cross-engine
wrap-width premise go with the mirror: the layers now share a containing
block, which closes the WebKit 768-against-776 gap by construction.
2026-07-31 15:25:29 +08:00
|
|
|
// At the very bottom the glyphs are level with the caret, and the draft's
|
|
|
|
|
// own last line — the one before the empty final line — is on screen.
|
|
|
|
|
expect(bottom.gapShiftOnScroll).toBe(0)
|
fix(web): give the backdrop the trailing-line sentinel so the layers share one extent
Review caught a real divergence the earlier measurements missed: mirroring
an offset is only correct while both layers can reach it, and for a draft
ending in a newline the backdrop could not.
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. So a draft ending in a newline made the backdrop exactly one
line shorter than the textarea — measured 628 against 652 — and the mirrored
assignment clamped, leaving the glyphs one line behind the caret at the very
bottom of the draft.
The backdrop now carries the same trailing-line sentinel the mirror div has
carried all along: its content is the decoration walk plus one newline. The
same pre-wrap collapse absorbs it when the draft does not end in a newline,
so it costs no height in the ordinary case, and it supplies the missing line
box when it does. Verified in isolation first: a bare pre-wrap div measures
180/180/198 against a textarea's 180/198/216 for zero, one and two trailing
newlines, and 180/198/216 with the sentinel.
Coverage for the shape that exposed it: the browser scenario asserts the two
extents are equal before asserting the glyphs reach the end, observing each
layer's maximum by asking for an impossible offset and reading back the
clamp rather than computing it from scrollHeight, and the golden records the
relation. The unit spec pins the backdrop's text as the draft plus exactly
one newline. Removing the sentinel fails both, the e2e with the same 628
against 652.
The scrollbar-gutter half of the same review point does not reproduce here:
both layers measure clientWidth 776 against a border box of 776 while the
draft overflows, so this engine's textarea scrollbar is an overlay and takes
no width out of the wrap.
2026-07-31 12:12:54 +08:00
|
|
|
expect(bottom.lastLineOffset).toBeGreaterThanOrEqual(0)
|
|
|
|
|
expect(bottom.lastLineOffset).toBeLessThan(bottom.clientHeight)
|
|
|
|
|
expect(tripwire.pageErrors).toEqual([])
|
|
|
|
|
}, 60_000)
|
|
|
|
|
|
fix(web): scroll the composer's glyph layer with its textarea
A composer draft past the 14-line cap could not be scrolled: the caret and
the selection moved, but the words stayed frozen at line 1, so the tail of
anything longer than the cap was unreachable while writing it.
The composer paints its text in two stacked layers. The textarea owns the
value, the selection and the caret but renders its own glyphs transparent;
every visible character is painted by the decoration backdrop beneath it,
which also carries the claim-token highlight, the chips and the ghost hint.
The backdrop is `inset: 0; overflow: hidden` — clipped, not scrolled — and
nothing linked its offset to the textarea's. Below the cap both layers rest
at 0, which is why the defect hid behind every short-draft screenshot and
fixture.
InputBar now mirrors the textarea's scrollTop onto the backdrop, from a
`scroll` listener (every gesture and every caret-driven scroll) and from a
layout effect keyed on the committed draft (an edit reflows both layers
without necessarily firing a scroll event).
Scrolling is layout, so jsdom cannot show this: the unit spec stubs both
offsets and proves the mirroring paths run, while a new browser scenario
measures the user-visible fact against the built client with a DOM Range
over the backdrop's own text — after a wheel gesture over a 40-line draft
the last line is on screen and the first has scrolled out. Confirmed both
directions: with the mirroring reverted and the packages rebuilt, the
golden reads `last draft line is on screen: false` while `textarea moved:
true`.
2026-07-31 11:53:04 +08:00
|
|
|
it('matches the committed composer scroll geometry golden', async () => {
|
|
|
|
|
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-golden'))
|
|
|
|
|
const input = page.locator('textarea:enabled').first()
|
|
|
|
|
// Restore the pristine draft (the edit case appended to it) and return to
|
|
|
|
|
// its start, both through ordinary gestures.
|
|
|
|
|
await input.fill(DRAFT)
|
|
|
|
|
await input.hover()
|
|
|
|
|
await page.mouse.wheel(0, -2000)
|
fix(web): bind the composer's caret to its glyphs with one scrollport
The composer paints its draft in two layers — the textarea owns the value,
the selection and the caret, the backdrop paints every visible glyph — and
they had one scroll offset each, kept equal by a `scroll` listener. That
holds at rest and not in motion: a wheel gesture scrolls the textarea on the
compositor, the listener runs afterwards, and for those frames the caret sits
at the new offset with the words at the old one. Measured on a harness of the
same geometry, a 200px offset change separates caret from glyphs by ~200px
(chromium 203, firefox 202, WebKit 203) until a later frame — the caret
flying out of its own text when a user swipes a long draft quickly.
Both layers now ride one scrollport: `[data-input-scroll]` carries the
14-line cap, the auto-grow stack inside it is as tall as the whole draft, and
the textarea holds no scrollable overflow of its own. The browser applies one
offset to both layers in the same frame, so the coupling is structural rather
than maintained. The backdrop's trailing-line sentinel and the cross-engine
wrap-width premise go with the mirror: the layers now share a containing
block, which closes the WebKit 768-against-776 gap by construction.
2026-07-31 15:25:29 +08:00
|
|
|
await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 }).toBe(0)
|
fix(web): scroll the composer's glyph layer with its textarea
A composer draft past the 14-line cap could not be scrolled: the caret and
the selection moved, but the words stayed frozen at line 1, so the tail of
anything longer than the cap was unreachable while writing it.
The composer paints its text in two stacked layers. The textarea owns the
value, the selection and the caret but renders its own glyphs transparent;
every visible character is painted by the decoration backdrop beneath it,
which also carries the claim-token highlight, the chips and the ghost hint.
The backdrop is `inset: 0; overflow: hidden` — clipped, not scrolled — and
nothing linked its offset to the textarea's. Below the cap both layers rest
at 0, which is why the defect hid behind every short-draft screenshot and
fixture.
InputBar now mirrors the textarea's scrollTop onto the backdrop, from a
`scroll` listener (every gesture and every caret-driven scroll) and from a
layout effect keyed on the committed draft (an edit reflows both layers
without necessarily firing a scroll event).
Scrolling is layout, so jsdom cannot show this: the unit spec stubs both
offsets and proves the mirroring paths run, while a new browser scenario
measures the user-visible fact against the built client with a DOM Range
over the backdrop's own text — after a wheel gesture over a 40-line draft
the last line is on screen and the first has scrolled out. Confirmed both
directions: with the mirroring reverted and the packages rebuilt, the
golden reads `last draft line is on screen: false` while `textarea moved:
true`.
2026-07-31 11:53:04 +08:00
|
|
|
const top = await measureComposer(page)
|
|
|
|
|
await input.hover()
|
|
|
|
|
await page.mouse.wheel(0, 2000)
|
fix(web): bind the composer's caret to its glyphs with one scrollport
The composer paints its draft in two layers — the textarea owns the value,
the selection and the caret, the backdrop paints every visible glyph — and
they had one scroll offset each, kept equal by a `scroll` listener. That
holds at rest and not in motion: a wheel gesture scrolls the textarea on the
compositor, the listener runs afterwards, and for those frames the caret sits
at the new offset with the words at the old one. Measured on a harness of the
same geometry, a 200px offset change separates caret from glyphs by ~200px
(chromium 203, firefox 202, WebKit 203) until a later frame — the caret
flying out of its own text when a user swipes a long draft quickly.
Both layers now ride one scrollport: `[data-input-scroll]` carries the
14-line cap, the auto-grow stack inside it is as tall as the whole draft, and
the textarea holds no scrollable overflow of its own. The browser applies one
offset to both layers in the same frame, so the coupling is structural rather
than maintained. The backdrop's trailing-line sentinel and the cross-engine
wrap-width premise go with the mirror: the layers now share a containing
block, which closes the WebKit 768-against-776 gap by construction.
2026-07-31 15:25:29 +08:00
|
|
|
await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 })
|
fix(web): scroll the composer's glyph layer with its textarea
A composer draft past the 14-line cap could not be scrolled: the caret and
the selection moved, but the words stayed frozen at line 1, so the tail of
anything longer than the cap was unreachable while writing it.
The composer paints its text in two stacked layers. The textarea owns the
value, the selection and the caret but renders its own glyphs transparent;
every visible character is painted by the decoration backdrop beneath it,
which also carries the claim-token highlight, the chips and the ghost hint.
The backdrop is `inset: 0; overflow: hidden` — clipped, not scrolled — and
nothing linked its offset to the textarea's. Below the cap both layers rest
at 0, which is why the defect hid behind every short-draft screenshot and
fixture.
InputBar now mirrors the textarea's scrollTop onto the backdrop, from a
`scroll` listener (every gesture and every caret-driven scroll) and from a
layout effect keyed on the committed draft (an edit reflows both layers
without necessarily firing a scroll event).
Scrolling is layout, so jsdom cannot show this: the unit spec stubs both
offsets and proves the mirroring paths run, while a new browser scenario
measures the user-visible fact against the built client with a DOM Range
over the backdrop's own text — after a wheel gesture over a 40-line draft
the last line is on screen and the first has scrolled out. Confirmed both
directions: with the mirroring reverted and the packages rebuilt, the
golden reads `last draft line is on screen: false` while `textarea moved:
true`.
2026-07-31 11:53:04 +08:00
|
|
|
.toBeGreaterThan(0)
|
|
|
|
|
const bottom = await measureComposer(page)
|
fix(web): give the backdrop the trailing-line sentinel so the layers share one extent
Review caught a real divergence the earlier measurements missed: mirroring
an offset is only correct while both layers can reach it, and for a draft
ending in a newline the backdrop could not.
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. So a draft ending in a newline made the backdrop exactly one
line shorter than the textarea — measured 628 against 652 — and the mirrored
assignment clamped, leaving the glyphs one line behind the caret at the very
bottom of the draft.
The backdrop now carries the same trailing-line sentinel the mirror div has
carried all along: its content is the decoration walk plus one newline. The
same pre-wrap collapse absorbs it when the draft does not end in a newline,
so it costs no height in the ordinary case, and it supplies the missing line
box when it does. Verified in isolation first: a bare pre-wrap div measures
180/180/198 against a textarea's 180/198/216 for zero, one and two trailing
newlines, and 180/198/216 with the sentinel.
Coverage for the shape that exposed it: the browser scenario asserts the two
extents are equal before asserting the glyphs reach the end, observing each
layer's maximum by asking for an impossible offset and reading back the
clamp rather than computing it from scrollHeight, and the golden records the
relation. The unit spec pins the backdrop's text as the draft plus exactly
one newline. Removing the sentinel fails both, the e2e with the same 628
against 652.
The scrollbar-gutter half of the same review point does not reproduce here:
both layers measure clientWidth 776 against a border box of 776 while the
draft overflows, so this engine's textarea scrollbar is an overlay and takes
no width out of the wrap.
2026-07-31 12:12:54 +08:00
|
|
|
await input.fill(DRAFT_TRAILING_NEWLINE)
|
|
|
|
|
await input.hover()
|
|
|
|
|
await page.mouse.wheel(0, 4000)
|
|
|
|
|
await expect.poll(async () => {
|
|
|
|
|
const m = await measureComposer(page)
|
fix(web): bind the composer's caret to its glyphs with one scrollport
The composer paints its draft in two layers — the textarea owns the value,
the selection and the caret, the backdrop paints every visible glyph — and
they had one scroll offset each, kept equal by a `scroll` listener. That
holds at rest and not in motion: a wheel gesture scrolls the textarea on the
compositor, the listener runs afterwards, and for those frames the caret sits
at the new offset with the words at the old one. Measured on a harness of the
same geometry, a 200px offset change separates caret from glyphs by ~200px
(chromium 203, firefox 202, WebKit 203) until a later frame — the caret
flying out of its own text when a user swipes a long draft quickly.
Both layers now ride one scrollport: `[data-input-scroll]` carries the
14-line cap, the auto-grow stack inside it is as tall as the whole draft, and
the textarea holds no scrollable overflow of its own. The browser applies one
offset to both layers in the same frame, so the coupling is structural rather
than maintained. The backdrop's trailing-line sentinel and the cross-engine
wrap-width premise go with the mirror: the layers now share a containing
block, which closes the WebKit 768-against-776 gap by construction.
2026-07-31 15:25:29 +08:00
|
|
|
return m.scrollTop === m.scrollMax
|
fix(web): give the backdrop the trailing-line sentinel so the layers share one extent
Review caught a real divergence the earlier measurements missed: mirroring
an offset is only correct while both layers can reach it, and for a draft
ending in a newline the backdrop could not.
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. So a draft ending in a newline made the backdrop exactly one
line shorter than the textarea — measured 628 against 652 — and the mirrored
assignment clamped, leaving the glyphs one line behind the caret at the very
bottom of the draft.
The backdrop now carries the same trailing-line sentinel the mirror div has
carried all along: its content is the decoration walk plus one newline. The
same pre-wrap collapse absorbs it when the draft does not end in a newline,
so it costs no height in the ordinary case, and it supplies the missing line
box when it does. Verified in isolation first: a bare pre-wrap div measures
180/180/198 against a textarea's 180/198/216 for zero, one and two trailing
newlines, and 180/198/216 with the sentinel.
Coverage for the shape that exposed it: the browser scenario asserts the two
extents are equal before asserting the glyphs reach the end, observing each
layer's maximum by asking for an impossible offset and reading back the
clamp rather than computing it from scrollHeight, and the golden records the
relation. The unit spec pins the backdrop's text as the draft plus exactly
one newline. Removing the sentinel fails both, the e2e with the same 628
against 652.
The scrollbar-gutter half of the same review point does not reproduce here:
both layers measure clientWidth 776 against a border box of 776 while the
draft overflows, so this engine's textarea scrollbar is an overlay and takes
no width out of the wrap.
2026-07-31 12:12:54 +08:00
|
|
|
}, { timeout: 10_000 }).toBe(true)
|
|
|
|
|
const trailingNewline = await measureComposer(page)
|
2026-07-31 16:07:55 +08:00
|
|
|
// The paste path, measured the way a user meets it: a short draft, the
|
|
|
|
|
// caret at its end, one long block pasted in.
|
|
|
|
|
await input.fill('one short line')
|
|
|
|
|
await input.press('End')
|
|
|
|
|
await input.evaluate((el, text) => {
|
|
|
|
|
const data = new DataTransfer()
|
|
|
|
|
data.setData('text/plain', text)
|
|
|
|
|
el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true }))
|
2026-08-19 17:55:06 +08:00
|
|
|
// Keep a final glyph so the collapsed caret position has a client rect.
|
2026-07-31 17:26:29 +08:00
|
|
|
}, `\n${DRAFT}`)
|
2026-07-31 16:07:55 +08:00
|
|
|
await expect.poll(async () => (await measureComposer(page)).overflows, { timeout: 10_000 }).toBe(true)
|
|
|
|
|
await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 }).toBeGreaterThan(0)
|
|
|
|
|
const pasted = await measureComposer(page)
|
|
|
|
|
await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(top, bottom, trailingNewline, pasted), MODE)
|
fix(web): scroll the composer's glyph layer with its textarea
A composer draft past the 14-line cap could not be scrolled: the caret and
the selection moved, but the words stayed frozen at line 1, so the tail of
anything longer than the cap was unreachable while writing it.
The composer paints its text in two stacked layers. The textarea owns the
value, the selection and the caret but renders its own glyphs transparent;
every visible character is painted by the decoration backdrop beneath it,
which also carries the claim-token highlight, the chips and the ghost hint.
The backdrop is `inset: 0; overflow: hidden` — clipped, not scrolled — and
nothing linked its offset to the textarea's. Below the cap both layers rest
at 0, which is why the defect hid behind every short-draft screenshot and
fixture.
InputBar now mirrors the textarea's scrollTop onto the backdrop, from a
`scroll` listener (every gesture and every caret-driven scroll) and from a
layout effect keyed on the committed draft (an edit reflows both layers
without necessarily firing a scroll event).
Scrolling is layout, so jsdom cannot show this: the unit spec stubs both
offsets and proves the mirroring paths run, while a new browser scenario
measures the user-visible fact against the built client with a DOM Range
over the backdrop's own text — after a wheel gesture over a 40-line draft
the last line is on screen and the first has scrolled out. Confirmed both
directions: with the mirroring reverted and the packages rebuilt, the
golden reads `last draft line is on screen: false` while `textarea moved:
true`.
2026-07-31 11:53:04 +08:00
|
|
|
expect(tripwire.pageErrors).toEqual([])
|
|
|
|
|
}, 60_000)
|
|
|
|
|
|
|
|
|
|
it('commits exactly the fixtures it reads', async () => {
|
|
|
|
|
await assertFixtureInventory(SNAPSHOT_DIR, ['geometry.expected.md'])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', () => {
|
|
|
|
|
expect(tripwire.warnings).toEqual([])
|
|
|
|
|
expect(tripwire.pageErrors).toEqual([])
|
|
|
|
|
})
|
|
|
|
|
})
|