fix(ui): the phone's context sheet says when it scrolls #209

Merged
logan merged 2 commits from fix/207-sheet-scroll-affordance into main 2026-08-25 16:39:44 +00:00
Collaborator

What the issue was

The bottom sheet's body has scrolled since #60 and said nothing about
it. Measured at 424x439 with one row selected, the track list's menu ran
to eight items ending at y=470 against a fold at 439. It was never
unreachable — the body is overflow-y: auto and 39px of scroll brings
the last item into view — which is why it was Priority/Medium. What was
missing is the affordance, and the worst case is the one that reproduces
on main: where the cut lands on a row boundary the sheet ends in a
clean edge that reads as the end of the list.

What changed and why

One rule, wa-dialog::part(body) in components/menu-surface/.

The 85vh cap stays — the component's own comment says a surface covering
the whole screen is a page, not a sheet — so the affordance is what
changes and not the height.

The fade is two background layers, and the pair of attachments is the
feature: a shadow pinned to the box (scroll) under a cover of the
sheet's own colour painted at the end of the content (local), which
scrolls up over the shadow exactly when there is nothing more to see. So
it is absent on a menu that fits, present the moment one does not, and
gone again at the end of the list
— with no scroll listener, no
measurement and nothing reaching into wa-dialog's shadow root for the
scroller. background-attachment is Chrome 4; the reference device is
Chrome 113.

The report's other two options — a shortened last row, or a max height
that makes the cut obvious — both need height mod 48, which CSS cannot
express; and the observed case is already a cut on a row boundary.

The curve is steep rather than linear because the rows under it stay
live.
A scrim over a menu item is that item's text surface, so this
app's 4.5:1 rule reaches it, and the light ramp (bgElevated #e9ecef)
is what makes that real rather than theoretical. A first draft of 48px
linear at 0.8 put ~0.375 on the last label — 5.0:1, passing but visibly
greyed. The shipped 32px, already down to a quarter strength at 14px,
measures 9.9:1 there and spends its weight on the strip below it.

Verification

Reproduced on main first. At 424x360 (see below for why not 439):
scrollHeight 364 against clientHeight 306, last item's bottom at
y=410 in a 360px viewport, and background-image: none — no affordance
at all.

Pixel measurements, x=300, dark ramp, against the running app:

y before more below scrolled to the end
330 52,58,64 50,56,62 52,58,64
340 52,58,64 43,48,53 52,58,64
350 52,58,64 33,37,40 52,58,64
359 52,58,64 22,24,27 52,58,64

At 424x439, where main's seven-item menu fits exactly
(scrollHeight 364 == clientHeight 364), the sheet is flat 52,58,64
to its bottom edge — nothing is drawn. Light ramp checked by overriding
the two custom properties on :root.

Tiers run:

  • make ui-test1093 passed, including one new case in
    test/components/menu-surface.test.ts. Proved non-vacuous: with
    the rule reverted to its pre-fix form it fails on
    expected 'scroll' to be 'local, scroll'.
  • make e2e239 passed against make dev-headless SEED=default
    (chromium; WebKit is CI-only). The sheet is shared by fourteen menus,
    so this tier is the regression check rather than the assertion.
  • npx tsc --noEmit, make css-check — clean.
  • Screenshots at 424x360 on both ramps, cropped and scaled, since a dark
    scrim on a dark surface is invisible in a shrunk preview while being
    measurably present.

No Go, no .sql and no .templ changed, so make lint, make test,
make generate and make bindings were not run.

Deliberately not done

  • No e2e spec. The conditionality is a property of
    background-attachment and the component tier asserts exactly that;
    the rendered result needs pixels, and the only viewport on main
    where a menu overflows is 424x360 — contrived enough that the spec
    would be about the fixture rather than the app. Once #67 merges, the
    track list's menu overflows at the real 424x439 and a spec becomes
    worth writing.
  • The cap was not raised and no item was removed from any menu; the
    report rules the second out and menu-surface rules the first out.
  • No top-edge affordance. The report is about the last item; a
    symmetric shadow is two more layers for a question nobody asked.

CLAUDE.md's menu-surface section grows a seventh load-bearing point,
and the measurements are in .planning/NOTES.md.

Closes #207

## What the issue was The bottom sheet's body has scrolled since #60 and said nothing about it. Measured at 424x439 with one row selected, the track list's menu ran to eight items ending at y=470 against a fold at 439. It was never *unreachable* — the body is `overflow-y: auto` and 39px of scroll brings the last item into view — which is why it was `Priority/Medium`. What was missing is the affordance, and the worst case is the one that reproduces on `main`: where the cut lands on a row boundary the sheet ends in a clean edge that reads as the end of the list. ## What changed and why One rule, `wa-dialog::part(body)` in `components/menu-surface/`. The 85vh cap stays — the component's own comment says a surface covering the whole screen is a page, not a sheet — so the affordance is what changes and not the height. The fade is two background layers, and the *pair of attachments* is the feature: a shadow pinned to the box (`scroll`) under a cover of the sheet's own colour painted at the end of the *content* (`local`), which scrolls up over the shadow exactly when there is nothing more to see. So it is **absent on a menu that fits, present the moment one does not, and gone again at the end of the list** — with no scroll listener, no measurement and nothing reaching into `wa-dialog`'s shadow root for the scroller. `background-attachment` is Chrome 4; the reference device is Chrome 113. The report's other two options — a shortened last row, or a max height that makes the cut obvious — both need `height mod 48`, which CSS cannot express; and the observed case is already a cut on a row boundary. **The curve is steep rather than linear because the rows under it stay live.** A scrim over a menu item is that item's text surface, so this app's 4.5:1 rule reaches it, and the light ramp (`bgElevated` `#e9ecef`) is what makes that real rather than theoretical. A first draft of 48px linear at 0.8 put ~0.375 on the last label — 5.0:1, passing but visibly greyed. The shipped 32px, already down to a quarter strength at 14px, measures **9.9:1** there and spends its weight on the strip below it. ## Verification **Reproduced on `main` first.** At 424x360 (see below for why not 439): `scrollHeight` 364 against `clientHeight` 306, last item's bottom at y=410 in a 360px viewport, and `background-image: none` — no affordance at all. **Pixel measurements**, x=300, dark ramp, against the running app: | y | before | more below | scrolled to the end | |---|---|---|---| | 330 | 52,58,64 | 50,56,62 | 52,58,64 | | 340 | 52,58,64 | 43,48,53 | 52,58,64 | | 350 | 52,58,64 | 33,37,40 | 52,58,64 | | 359 | 52,58,64 | 22,24,27 | 52,58,64 | At 424x439, where `main`'s seven-item menu fits exactly (`scrollHeight` 364 == `clientHeight` 364), the sheet is flat 52,58,64 to its bottom edge — nothing is drawn. Light ramp checked by overriding the two custom properties on `:root`. **Tiers run:** - `make ui-test` — **1093 passed**, including one new case in `test/components/menu-surface.test.ts`. **Proved non-vacuous**: with the rule reverted to its pre-fix form it fails on `expected 'scroll' to be 'local, scroll'`. - `make e2e` — **239 passed** against `make dev-headless SEED=default` (chromium; WebKit is CI-only). The sheet is shared by fourteen menus, so this tier is the regression check rather than the assertion. - `npx tsc --noEmit`, `make css-check` — clean. - Screenshots at 424x360 on both ramps, cropped and scaled, since a dark scrim on a dark surface is invisible in a shrunk preview while being measurably present. No Go, no `.sql` and no `.templ` changed, so `make lint`, `make test`, `make generate` and `make bindings` were **not** run. ## Deliberately not done - **No e2e spec.** The conditionality is a property of `background-attachment` and the component tier asserts exactly that; the rendered result needs pixels, and the only viewport on `main` where a menu overflows is 424x360 — contrived enough that the spec would be about the fixture rather than the app. Once #67 merges, the track list's menu overflows at the real 424x439 and a spec becomes worth writing. - **The cap was not raised** and no item was removed from any menu; the report rules the second out and `menu-surface` rules the first out. - **No top-edge affordance.** The report is about the last item; a symmetric shadow is two more layers for a question nobody asked. `CLAUDE.md`'s `menu-surface` section grows a seventh load-bearing point, and the measurements are in `.planning/NOTES.md`. Closes #207
logan added 1 commit 2026-08-23 10:46:37 +00:00
fix(ui): the phone's context sheet says when it scrolls
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m28s
CI / e2e (pull_request) Successful in 9m40s
02e2251bb2
The bottom sheet's body has scrolled since #60 and said nothing about
it. Measured at 424x439, the track list's menu ended at y=470 with the
fold at 439 — reachable, since the body is `overflow-y: auto`, but with
no affordance saying so, and worst where the cut lands on a row
boundary and the sheet ends in a clean edge that reads as the end of
the list.

The cap stays: `menu-surface`'s own comment says a surface covering the
whole screen is a page, not a sheet. What changes is that the body
draws a fade, from two background layers whose *attachments* are the
feature — a shadow pinned to the box (`scroll`) under a cover of the
sheet's own colour painted at the end of the content (`local`), which
scrolls up over the shadow exactly when there is nothing more to see.
So the fade is absent on a menu that fits, present the moment one does
not, and gone again at the end of the list, with no scroll listener and
nothing reaching into `wa-dialog`'s shadow root for the scroller.
`background-attachment` is Chrome 4; the reference device is Chrome 113.

The other two options in the report — a shortened last row, or a max
height that makes the cut obvious — both need `height mod 48`, which
CSS cannot express, and the observed case is exactly the one where the
cut already lands on a row boundary.

The curve is steep rather than linear because the rows under it stay
live: a scrim over a menu item is that item's text surface, so the
4.5:1 rule reaches it, and the light ramp is what makes that real.
A 48px linear scrim at 0.8 greyed the last label to 5.0:1; 32px already
down to a quarter strength at 14px measures 9.9:1 there and spends its
weight on the strip below it.

The test asserts the pair of attachments rather than the pixels, on
this file's existing grounds that no tier here renders like the device
— it fails on the pre-fix stylesheet with `expected 'scroll' to be
'local, scroll'`. The rendered result was measured in the harness and
is recorded in `.planning/NOTES.md`.

Closes #207
Author
Collaborator

CI is green — run 17668, check and e2e both success (chromium
and WebKit).

The first run failed on #138, not on this change. check reported
test/components/play-in-context.test.ts > "queues the list as
displayed and starts on that row" with

AssertionError: expected [ 12, '/music/track-11.mp3' ]
              to deeply equal [ 12, '/music/track-3.mp3' ]

which is #138's reported assertion byte-for-byte — a known flake at
~1 in 3 full-suite runs on pristine main, Reviewed/Confirmed. This
branch touches components/menu-surface/ and its test only, and
menu-surface.test.ts passed in that same run (✓ 9 tests). Re-ran the
workflow with no change to the branch; check passed and e2e followed.

Recorded here rather than silently re-run, since "a tier failed" and "a
tier failed for a reason unrelated to the diff" are different claims.

**CI is green** — run 17668, `check` and `e2e` both success (chromium and WebKit). **The first run failed on #138, not on this change.** `check` reported `test/components/play-in-context.test.ts` > "queues the list as displayed and starts on that row" with ``` AssertionError: expected [ 12, '/music/track-11.mp3' ] to deeply equal [ 12, '/music/track-3.mp3' ] ``` which is #138's reported assertion byte-for-byte — a known flake at ~1 in 3 full-suite runs on pristine `main`, `Reviewed/Confirmed`. This branch touches `components/menu-surface/` and its test only, and `menu-surface.test.ts` passed in that same run (`✓ 9 tests`). Re-ran the workflow with no change to the branch; `check` passed and `e2e` followed. Recorded here rather than silently re-run, since "a tier failed" and "a tier failed for a reason unrelated to the diff" are different claims.
logan added 1 commit 2026-08-23 11:04:31 +00:00
docs(ui): quote the shipped fade's own measurement
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m29s
CI / e2e (pull_request) Successful in 9m33s
8c85db8968
The comment on `wa-dialog::part(body)` carried bottom-edge pixels from
an intermediate probe (29,33,36) while `.planning/NOTES.md` recorded
the final sample against the shipped rule (22,24,27) — the same
gradient, read a few pixels higher up the box. A measurement written in
two places has to agree, or neither can be trusted.
logan merged commit aeb173c684 into main 2026-08-25 16:39:44 +00:00
Sign in to join this conversation.