Repository navigation
fix(ui-web): fade the transcript out above the composer instead of under it - #658
Conversation
…der it The composer is an opaque card floating over the scroller, so a message scrolled up was cut mid-glyph at the card's top edge, and the gutter below the card left the next line legible under the field the reader types in. A mask on the scrollport takes the transcript's own alpha to zero at the top of whatever is docked, over a 40px ramp. The ramp is anchored to the number dockLift already publishes: .col spends it as bottom padding and the back-to-bottom pill as its offset, so the clear edge tracks a composer that has grown a line, queued rows and a clarify sheet rather than the resting card's height. Because .col reserves the same number, the tail of the transcript sits above the ramp at rest and the fade bites only while the reader is scrolled up, which is the only time anything was overlapping. A mask rather than the ground-coloured band this file used to carry above the composer: a band is a rectangle painted over the transcript, so it has to match the wash behind it, and on a gradient it reads as a flat seam, which is what got the old one deleted. Verified in Chrome against the stylesheet itself and against the live page. The clear edge lands on the composer's top edge in light, in dark, with a three-line composer and on the landing page, and no transcript ink is painted at or past it. Painted ink in the ramp drops 91 percent while the column above it is unchanged to the pixel, and scroll frame cost is the same with the mask on and off. Co-authored-by: Claude (claude-opus-5[1m]) <noreply@anthropic.com>
The dock's leading comment said the transcript "scrolls underneath and stays legible through the glass" and that "the old fade band above the composer is gone". The mask on .scroll makes both false: the transcript's alpha now reaches zero at the card's top edge, so nothing passes legibly behind it and there is a ramp above it again. Also drops "frosted", which the card stopped being when its blur was removed six lines below this comment. That word was already stale before this branch; it is corrected here because it is the same claim and the rest of the sentence had to be rewritten anyway. Co-authored-by: Claude (claude-opus-5[1m]) <noreply@anthropic.com>
gloryfromca
left a comment
There was a problem hiding this comment.
No blockers; this can merge as far as I am concerned.
The mask is scoped to the transcript's only scroll viewport and reuses the composer's measured --lift, so its transparent edge follows a growing composer, queued rows, and clarification sheets while the existing column padding keeps the newest content outside the ramp. The standard and WebKit declarations agree, and the fallback preserves the first frame before measurement.
I covered AGENTS.md and the Web UI context rules, the complete diff, the .scroll markup and dockLift caller path, relevant stylesheet history, browser/backward-compatibility fallbacks, test strength, and architecture boundaries. I found no weakened tests or unrelated architectural changes.
Verification:
npm test: 193 files passed, 2,615 tests passed. Happy DOM printed network-abort diagnostics during teardown, but the run completed successfully with no failed tests.npm run type-check: passed.npm run lint: passed with zero errors and four existing warnings in untouched files.npm run build: passed.git diff --check github/main...HEAD: passed.git merge-tree --write-tree HEAD github/main: clean.
…der it (#658) ## Summary The composer is an **opaque** card floating over the transcript scroller, and nothing ended the transcript's paint before it reached the card. Scrolled up, a line was cut mid-glyph at the card's top edge, and the 22px gutter below the card left the next line legible *underneath* the field the reader types in. This gives `.scroll` a mask whose alpha reaches zero at the top of whatever is docked, over a 40px ramp: ```css .scroll { --tail-fade: 40px; mask-image: linear-gradient(180deg, #000 calc(100% - var(--lift, 96px) - var(--tail-fade)), transparent calc(100% - var(--lift, 96px))); } ``` Three decisions worth stating: - **A mask, not a ground-coloured band.** A band is a rectangle painted *over* the transcript, so it has to match whatever is behind it; on this page's wash (a gradient) it reads as a flat seam. A mask takes the alpha out of the transcript itself, so there is no ground to match, and it needs no second copy per theme. `page.css` records that an earlier band above the composer was deleted for exactly that reason, which is why this does not reintroduce one. - **Anchored to `--lift`, which already exists.** `dockLift` (`features/composer/store.ts`) publishes the distance from the chat column's bottom edge up to the top of the highest docked thing. `.col` spends it as bottom padding and `.backpill` as its offset. Spending the same number here is what makes the clear edge land on the composer's top edge however tall it has grown (a longer field, queued rows, a clarify sheet), rather than on a hardcoded resting height. - **The tail is never under the ramp.** Because `.col` reserves the same `--lift`, the newest message sits above the ramp when the scroller is at rest, so the fade engages only once the reader scrolls back. Nothing is hidden at the moment a message arrives. The second commit corrects the `.dock` leading comment, which this change falsified: it claimed the transcript "scrolls underneath and stays legible through the glass" and that "the old fade band above the composer is gone". ## Type - [x] Fix - [ ] Feature - [ ] Docs - [ ] CI / tooling - [ ] Refactor - [ ] Other ## Verification Verified in headless Chrome two ways: against `page.css` itself in a probe page carrying the real DOM structure, and against the running app over CDP. Painted ink was measured, not layout rects -- masked text still occupies its box, so a rect-based measurement is byte-identical before and after and cannot see this change at all. Ink is counted as pixels differing from the page's own background (the wash is not white, so the background is sampled from an empty band). **The clear edge lands on the composer's top edge** (`aligned` compares the `getBoundingClientRect().top` of `.dock-in` against the mask's transparent stop): | case | `--lift` | composer top | mask clears at | aligned | |---|---|---|---|---| | light theme | 106px | 654 | 654 | yes | | dark theme | 106px | 654 | 654 | yes | | composer grown to three lines | 180px | 580 | 580 | yes | | landing page (`data-fresh`) | 106px | 654 | 654 | yes | | live app, session "ESG..." | 139px | 721 | 721 | yes | **Nothing is painted at or past the clear edge.** Ink in a 14px band below it, measured in the gutters either side of the card: `0` in all five cases. **The fade is the only thing that changed.** Ink inside the 28px just above the card went `1651 -> 144` (91 percent removed), while the control band 100-600px above it was `36133` before and `36133` after -- unchanged to the pixel. **No scroll cost.** 240 scroll frames x 3 interleaved runs each, mask on vs off: median frame `16.70ms` both, p95 `16.80ms` both, worst `16.77 -> 17.17ms`. **A mask creates a stacking context, so `position: fixed` descendants of the scroller would be clipped.** Enumerated all 434 descendant elements on the live page: zero are `fixed` and zero are `sticky`. The scrollbar thumbs (`.sbars`), `#deskHost`, `.backpill` and `.dock` are all siblings of `.scroll`, not descendants. This is the commit-authors' manual check; the branch adds no automated guard for it. Commands and results: - `npx vitest run scripts/gates/` -- 39 files passed, 170 tests passed, no unhandled errors. The new gate is collected by the runner: `npx vitest list --filesOnly` prints `scripts/gates/transcript-tail-fade-css.test.mjs`. - The new gate was proven not to be theatre by removing the `.scroll` block and re-running: 4 of its 6 assertions go red. The other 2 assert pre-existing invariants the pair depends on (`.col` must reserve the same `--lift`; `.col` must not carry a mask of its own) and pass on both trees. - `node ui-web/scripts/check-css.mjs` -- OK. `node ui-web/scripts/check-class-namespace.mjs` -- OK. `npx eslint scripts/gates/transcript-tail-fade-css.test.mjs` -- clean. - `node ui-web/scripts/check-page.mjs` -- OK (1,387,731 bytes, 2 scripts parse). - `node ui-web/build.py` -- `boot-snapshot: OK (241 nodes match golden)` twice. - `python3 scripts/check_commit_messages.py origin/main..HEAD`, `check_source_language.py origin/main...HEAD`, `check_large_files.py origin/main...HEAD` -- all exit 0. - Every added line is ASCII, and `git ls-files --error-unmatch` resolves the one path the new CSS comment cites (`ui-web/src/features/composer/store.ts`). Rebased onto `origin/main` after it gained #656, which touched `page.css`. Its one change there is a desk change-chip selector at line 2622 and touches none of `.scroll`, `.col`, `.dock` or `--lift`; both changes are intact at the head and all gates above were re-run after the rebase. ## Risk - [x] Security impact considered -- none. A CSS mask on an existing element; no new input surface, no new file, no change to what is sent anywhere. - [x] Backward compatibility considered -- the composer keeps its position, size, padding and hit area. The mask removes paint only; it does not change layout, so nothing reflows and nothing becomes unclickable. - [x] Rollback path is clear -- revert the two commits. No data, config or persisted state is touched; `ui-web/dist` is a gitignored build artifact. User-visible change: a message scrolled up now dissolves over the last 40px before the composer instead of being cut off at the card's edge. A reader who scrolls back to the tail gets it back, since the column's own `--lift` padding keeps the newest message clear of the ramp. Not covered, stated so it is not mistaken for done: `ui-web` has no test for this beyond the stylesheet gate (happy-dom applies no stylesheet and does no layout, so the pixels are browser-only evidence), and the top of the scroller still cuts a message at the header, which this change does not address. ## Related Issues N/A --------- Co-authored-by: Claude (claude-opus-5[1m]) <noreply@anthropic.com>
Summary
The composer is an opaque card floating over the transcript scroller, and
nothing ended the transcript's paint before it reached the card. Scrolled up, a
line was cut mid-glyph at the card's top edge, and the 22px gutter below the
card left the next line legible underneath the field the reader types in.
This gives
.scrolla mask whose alpha reaches zero at the top of whatever isdocked, over a 40px ramp:
Three decisions worth stating:
the transcript, so it has to match whatever is behind it; on this page's wash
(a gradient) it reads as a flat seam. A mask takes the alpha out of the
transcript itself, so there is no ground to match, and it needs no second copy
per theme.
page.cssrecords that an earlier band above the composer wasdeleted for exactly that reason, which is why this does not reintroduce one.
--lift, which already exists.dockLift(
features/composer/store.ts) publishes the distance from the chat column'sbottom edge up to the top of the highest docked thing.
.colspends it asbottom padding and
.backpillas its offset. Spending the same number here iswhat makes the clear edge land on the composer's top edge however tall it has
grown (a longer field, queued rows, a clarify sheet), rather than on a
hardcoded resting height.
.colreserves the same--lift, the newest message sits above the ramp when the scroller is at rest,so the fade engages only once the reader scrolls back. Nothing is hidden at
the moment a message arrives.
The second commit corrects the
.dockleading comment, which this changefalsified: it claimed the transcript "scrolls underneath and stays legible
through the glass" and that "the old fade band above the composer is gone".
Type
Verification
Verified in headless Chrome two ways: against
page.cssitself in a probe pagecarrying the real DOM structure, and against the running app over CDP. Painted
ink was measured, not layout rects -- masked text still occupies its box, so a
rect-based measurement is byte-identical before and after and cannot see this
change at all. Ink is counted as pixels differing from the page's own background
(the wash is not white, so the background is sampled from an empty band).
The clear edge lands on the composer's top edge (
alignedcompares thegetBoundingClientRect().topof.dock-inagainst the mask's transparent stop):--liftdata-fresh)Nothing is painted at or past the clear edge. Ink in a 14px band below it,
measured in the gutters either side of the card:
0in all five cases.The fade is the only thing that changed. Ink inside the 28px just above the
card went
1651 -> 144(91 percent removed), while the control band 100-600pxabove it was
36133before and36133after -- unchanged to the pixel.No scroll cost. 240 scroll frames x 3 interleaved runs each, mask on vs off:
median frame
16.70msboth, p9516.80msboth, worst16.77 -> 17.17ms.A mask creates a stacking context, so
position: fixeddescendants of thescroller would be clipped. Enumerated all 434 descendant elements on the live
page: zero are
fixedand zero aresticky. The scrollbar thumbs (.sbars),#deskHost,.backpilland.dockare all siblings of.scroll, notdescendants. This is the commit-authors' manual check; the branch adds no
automated guard for it.
Commands and results:
npx vitest run scripts/gates/-- 39 files passed, 170 tests passed, nounhandled errors. The new gate is collected by the runner:
npx vitest list --filesOnlyprintsscripts/gates/transcript-tail-fade-css.test.mjs..scrollblock andre-running: 4 of its 6 assertions go red. The other 2 assert pre-existing
invariants the pair depends on (
.colmust reserve the same--lift;.colmust not carry a mask of its own) and pass on both trees.
node ui-web/scripts/check-css.mjs-- OK.node ui-web/scripts/check-class-namespace.mjs-- OK.npx eslint scripts/gates/transcript-tail-fade-css.test.mjs-- clean.node ui-web/scripts/check-page.mjs-- OK (1,387,731 bytes, 2 scripts parse).node ui-web/build.py--boot-snapshot: OK (241 nodes match golden)twice.python3 scripts/check_commit_messages.py origin/main..HEAD,check_source_language.py origin/main...HEAD,check_large_files.py origin/main...HEAD-- all exit 0.git ls-files --error-unmatchresolves the onepath the new CSS comment cites (
ui-web/src/features/composer/store.ts).Rebased onto
origin/mainafter it gained #656, which touchedpage.css. Itsone change there is a desk change-chip selector at line 2622 and touches none of
.scroll,.col,.dockor--lift; both changes are intact at the head andall gates above were re-run after the rebase.
Risk
new input surface, no new file, no change to what is sent anywhere.
size, padding and hit area. The mask removes paint only; it does not
change layout, so nothing reflows and nothing becomes unclickable.
persisted state is touched;
ui-web/distis a gitignored build artifact.User-visible change: a message scrolled up now dissolves over the last 40px
before the composer instead of being cut off at the card's edge. A reader who
scrolls back to the tail gets it back, since the column's own
--liftpaddingkeeps the newest message clear of the ramp.
Not covered, stated so it is not mistaken for done:
ui-webhas no test forthis beyond the stylesheet gate (happy-dom applies no stylesheet and does no
layout, so the pixels are browser-only evidence), and the top of the scroller
still cuts a message at the header, which this change does not address.
Related Issues
N/A