Skip to content

fix(ui-web): fade the transcript out above the composer instead of under it - #658

Merged
LivXue merged 2 commits into
mainfrom
fix/transcript_tail_fade_mask
Sep 22, 2026
Merged

LivXue merged 2 commits into
mainfrom
fix/transcript_tail_fade_mask

Conversation

@LivXue

@LivXue LivXue commented Sep 22, 2026

Copy link
Copy Markdown
Member

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:

.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

  • 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

  • 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.
  • 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.
  • 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

LivXue and others added 2 commits September 22, 2026 12:12
…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>
@LivXue
LivXue requested review from 0xKT and gloryfromca September 22, 2026 12:32

@gloryfromca gloryfromca left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@LivXue
LivXue merged commit 0565b7b into main Sep 22, 2026
23 checks passed
@LivXue
LivXue deleted the fix/transcript_tail_fade_mask branch September 22, 2026 12:44
LivXue added a commit that referenced this pull request Oct 12, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants