diff --git a/CLAUDE.md b/CLAUDE.md index 878294c..115ac5f 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