From 20c337651f2ecfd653e0b6e8410f54d0a3efd5c6 Mon Sep 17 00:00:00 2001 From: Logan Date: Wed, 26 Aug 2026 04:44:02 -0400 Subject: [PATCH] fix(ui): the phone's nav sheet says when it scrolls Since #71 the phone's "More" is a bottom sheet, and at the reference viewport it does not fit: measured at 424x439 with the seed's eight destinations, the body is scrollHeight 412 against clientHeight 373, so 39px is below a fold nothing announces. Where the cut lands on a row boundary the sheet ends in a clean edge that reads as the end of the list, which is what #207 fixed one sheet over. The rule is that sheet's, not a second answer to the same question: #207's two background layers move into styles/sheet-scroll.css.ts and both sheets adopt them, with the colour left to each host as --yj-sheet-surface. The nav sheet paints the sidebar's --yj-bg-surface and the context sheet the menus' --yj-bg-elevated, so a shared rule that hard-coded either would draw that seam across the other one. The half that makes it visible is that nothing inside the sheet may repaint the surface. These are layers on the scroller, and app-sidebar's host carries the same grey -- in the shell its own background, in the sheet a second opaque copy of the sheet's, over the fade. With the fragment adopted and that rule missing, the running app measured a flat 52,58,64 to the bottom edge with 39px still below: the defect unchanged, with every assertion about background-attachment passing. menu-surface already meets it from the other side, where the sheet's panel is background-color: transparent. Closes #210 --- CLAUDE.md | 22 ++++++ .../src/components/bottom-nav/bottom-nav.ts | 27 ++++++++ .../components/menu-surface/menu-surface.ts | 48 +++---------- frontend/src/styles/sheet-scroll.css.ts | 68 +++++++++++++++++++ frontend/test/components/bottom-nav.test.ts | 56 +++++++++++++++ frontend/test/components/menu-surface.test.ts | 9 +++ 6 files changed, 192 insertions(+), 38 deletions(-) create mode 100644 frontend/src/styles/sheet-scroll.css.ts diff --git a/CLAUDE.md b/CLAUDE.md index 3bcf933..1ad00a7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1470,6 +1470,28 @@ live**: a scrim over a menu item is that item's text surface, and the 14px spends its weight below the last legible label, measured at 9.9:1 on the light ramp, whose `bgElevated` is `#e9ecef`. +**And the phone has two sheets, so that rule is one file both read** +(#210). `bottom-nav`'s "More" is capped at the same 85vh and overflows +for the same reason — measured at 424x439 with eight destinations, +`scrollHeight` 412 against `clientHeight` 373, and eleven items at 48px +would be 528, since #25 makes the count the user's. So the two layers +live in `styles/sheet-scroll.css.ts` and each host says only what is +local to it: the colour, handed over as `--yj-sheet-surface` on the same +box, because the nav sheet paints the sidebar's `--yj-bg-surface` and +the context sheet the menus' `--yj-bg-elevated` — a shared rule that +hard-coded either would draw that seam across the other one. + +The half that is not the fade is what makes it visible: **nothing inside +the sheet may repaint the surface**, because these are background layers +on the scroller and an opaque child covers them. `menu-surface` already +had it from the other side (`.context-menu-panel[data-sheet]` is +`background-color: transparent`); `app-sidebar`'s host paints +`--yj-bg-surface`, which in the shell is its own background and in the +sheet is a second copy of the sheet's, so `bottom-nav` turns it off. +Measured at 424x439 with the fade adopted and that rule missing: a flat +52,58,64 to the bottom edge with 39px still below, which is the defect +unchanged and every assertion about `background-attachment` passing. + **The playlist submenu is a sheet too, and it had to be.** It is a `placement="right-start"` flyout, and making the menu full-width moved its anchor — measured at x −182 to 0, entirely off-screen, so "Add to diff --git a/frontend/src/components/bottom-nav/bottom-nav.ts b/frontend/src/components/bottom-nav/bottom-nav.ts index 439f366..063641a 100644 --- a/frontend/src/components/bottom-nav/bottom-nav.ts +++ b/frontend/src/components/bottom-nav/bottom-nav.ts @@ -4,6 +4,7 @@ import '@awesome.me/webawesome/dist/components/icon/icon.js'; import '@awesome.me/webawesome/dist/components/drawer/drawer.js'; import type WaDrawer from '@awesome.me/webawesome/dist/components/drawer/drawer.js'; import { designTokens } from '../../styles/tokens.css'; +import { sheetScrollFade } from '../../styles/sheet-scroll.css'; import '../sidebar/app-sidebar.js'; import { nameDialog } from '@utils/name-dialog'; import { ICON_PLAYLIST } from '@utils/icon-language'; @@ -167,6 +168,15 @@ export class BottomNav extends LitElement { overflow: hidden; } + /* And this list does not fit (#210): measured at 424x439 with + the seed's eight destinations, the body is scrollHeight 412 + against clientHeight 373, and eleven items at 48px would be + 528 -- the count is the user's since #25. So the sheet says + where the fold is, with styles/sheet-scroll.css's two layers + rather than a second answer to the question #207 settled for + the context sheet. The colour is the local half: the sidebar + paints --yj-bg-surface, so the cover does too, or the fade + draws the menus' grey across the bottom of this one. */ wa-drawer::part(body) { padding: 0; /* A scroll that reaches the end of this list must not @@ -177,6 +187,23 @@ export class BottomNav extends LitElement { on a gesture-navigation phone -- the same allowance the bar itself makes above. */ padding-bottom: env(safe-area-inset-bottom, 0); + --yj-sheet-surface: var(--yj-bg-surface, #212529); + ${sheetScrollFade} + } + + /* And the sheet paints that surface once. The sidebar's host + paints the same grey -- which in the shell is the sidebar's + own background and here is a second, opaque copy of the + sheet's, drawn *over* the body's layers. So the fade was + painted and then covered: measured at 424x439 before this + rule, the last 32px read a flat 52,58,64 with 39px still + below. menu-surface meets the same requirement from the + other side, where .context-menu-panel[data-sheet] is + background-color: transparent; nothing changes visually + here, because the colour underneath is the one being + removed. */ + app-sidebar { + background-color: transparent; } /* A sheet is dragged at with a thumb, so it says where its top diff --git a/frontend/src/components/menu-surface/menu-surface.ts b/frontend/src/components/menu-surface/menu-surface.ts index ffdff28..424a25e 100644 --- a/frontend/src/components/menu-surface/menu-surface.ts +++ b/frontend/src/components/menu-surface/menu-surface.ts @@ -65,6 +65,7 @@ import '@awesome.me/webawesome/dist/components/popup/popup.js'; import '@awesome.me/webawesome/dist/components/dialog/dialog.js'; import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js'; +import { sheetScrollFade } from '../../styles/sheet-scroll.css'; import { PHONE_QUERY } from '@utils/breakpoints'; import { nameDialogsIn } from '@utils/name-dialog'; @@ -164,46 +165,17 @@ export class MenuSurface extends LitElement { and worse when the cut lands on a row boundary, where the sheet ends in a clean edge that reads as the end of the list. - Two layers, and the *order* is what asks the question: a - shadow pinned to the bottom of the box (attachment scroll), - and over it a cover of the sheet's own colour painted at the - end of the *content* (attachment local), which therefore - scrolls up over the shadow and hides it exactly when there is - nothing more to see. So the affordance 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 curve is steep because the rows under it stay live.** - A scrim over a menu item is that item's text surface, and - this app's rule is that text clears 4.5:1 on every surface it - can sit on -- which the light ramp, whose bgElevated is - #e9ecef, is what makes non-theoretical. A row is 48px with - its label centred, so 32px of scrim that is already down to - a quarter strength at 14px reaches y-centre at about 0.06 and - spends its weight on the strip below the last legible label. - Measured on the dark ramp at x=300, flat 52,58,64 throughout - before: 50,56,62 at y=330, 33,37,40 at y=350 and 22,24,27 at - the bottom edge, and flat again at the end of the list. The - light ramp puts 9.9:1 on the last label. */ + The two layers that say it live in styles/sheet-scroll.css + (#210), because the phone has a second sheet -- bottom-nav's + "More" -- which overflows for the same reason and must not + arrive at its own answer for what a fold looks like. What is + local to this sheet is the colour the cover is painted in: + the menus' elevated grey, handed over as --yj-sheet-surface + on the same box. */ wa-dialog::part(body) { padding: 0; - overflow-y: auto; - background: - linear-gradient( - var(--yj-bg-elevated, #343a40), - var(--yj-bg-elevated, #343a40) - ) - bottom / 100% 32px no-repeat local, - linear-gradient( - to top, - rgba(0, 0, 0, 0.6) 0%, - rgba(0, 0, 0, 0.25) 45%, - rgba(0, 0, 0, 0) 100% - ) - bottom / 100% 32px no-repeat scroll; + --yj-sheet-surface: var(--yj-bg-elevated, #343a40); + ${sheetScrollFade} } /* A sheet is dragged at with a thumb, so it says where its top diff --git a/frontend/src/styles/sheet-scroll.css.ts b/frontend/src/styles/sheet-scroll.css.ts new file mode 100644 index 0000000..c801060 --- /dev/null +++ b/frontend/src/styles/sheet-scroll.css.ts @@ -0,0 +1,68 @@ +import { css } from 'lit'; + +/** + * A bottom sheet whose body scrolls says so, in one rule both sheets + * read. + * + * The app has two sheets — `menu-surface`'s context menu (#60) and + * `bottom-nav`'s "More" navigation (#71) — and both are capped at 85vh, + * because a surface covering the whole screen is a page rather than a + * sheet. So both overflow, and both used to overflow *silently*: the + * menu at 424x439 with eight items ending at y=470 (#207), the nav + * sheet at the same viewport with `scrollHeight` 412 against + * `clientHeight` 373 (#210). Where the cut lands on a row boundary the + * sheet ends in a clean edge that reads as the end of the list. + * + * The mechanism is #207's and is unchanged by being shared: two + * background layers on the scrolling box, whose *attachments* are the + * conditionality. A cover of the sheet's own colour is painted at the + * end of the *content* (`local`) over a shadow pinned to the box + * (`scroll`), so the cover scrolls up over the shadow exactly when + * there is nothing more to see. The fade is therefore absent on a sheet + * 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 another component's shadow root for the scroller. + * `background-attachment` is Chrome 4; the reference device is + * Chrome 113. + * + * Three things about it are load-bearing. + * + * **The cover takes the sheet's own colour, from a custom property.** + * The two sheets are different greys — the nav sheet paints + * `--yj-bg-surface`, because it holds the sidebar and two greys in one + * sheet is a seam across the middle of it, while the context sheet + * paints the menus' `--yj-bg-elevated`. A shared rule that hard-coded + * either would put that seam back on the other one, so the host sets + * `--yj-sheet-surface` on the same box and this reads it. + * + * **The curve is steep because the rows under it stay live.** A scrim + * over a menu item is that item's text surface, and this app's rule is + * that text clears 4.5:1 on every surface it can sit on — which the + * light ramp, whose `bgElevated` is `#e9ecef`, makes non-theoretical. A + * row is 48px with its label centred, so 32px of scrim already down to + * a quarter strength at 14px spends its weight on the strip below the + * last legible label: measured at 9.9:1 on that label on the light ramp, + * against 5.0:1 for a linear 48px draft at 0.8. The dark-ramp pixel + * table is in `.planning/NOTES.md` (2026-08-23). + * + * **The box is declared a scroller here too.** `overflow-y: auto` is + * part of the same statement rather than left to each host: a fade over + * a box that is not the scroller is a fade that never moves, and the + * component tier asserts the pair together for that reason. + */ +export const sheetScrollFade = css` + overflow-y: auto; + background: + linear-gradient( + var(--yj-sheet-surface, #343a40), + var(--yj-sheet-surface, #343a40) + ) + bottom / 100% 32px no-repeat local, + linear-gradient( + to top, + rgba(0, 0, 0, 0.6) 0%, + rgba(0, 0, 0, 0.25) 45%, + rgba(0, 0, 0, 0) 100% + ) + bottom / 100% 32px no-repeat scroll; +`; diff --git a/frontend/test/components/bottom-nav.test.ts b/frontend/test/components/bottom-nav.test.ts index 1be0549..3a0da33 100644 --- a/frontend/test/components/bottom-nav.test.ts +++ b/frontend/test/components/bottom-nav.test.ts @@ -243,6 +243,62 @@ describe('bottom-nav', () => { expect(getComputedStyle(body).overscrollBehaviorY).toBe('contain'); }); + it('says where the fold is, in the sheet\'s own colour', async () => { + const el = await fixture