Compare commits

...
Author SHA1 Message Date
logan 20c337651f fix(ui): the phone's nav sheet says when it scrolls
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 3m1s
CI / e2e (pull_request) Successful in 9m56s
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
2026-08-26 04:44:02 -04:00
6 changed files with 192 additions and 38 deletions
+22
View File
@@ -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
@@ -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
@@ -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
+68
View File
@@ -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;
`;
@@ -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<Nav>('bottom-nav');
const drawer = shadow<HTMLElement & { open: boolean }>(el, 'wa-drawer');
if (!drawer) throw new Error('no drawer');
const shown = once(drawer, 'wa-after-show');
shadow<HTMLButtonElement>(el, '[data-testid="tab-more"]')?.click();
await shown;
const body = drawer.shadowRoot?.querySelector('[part~="body"]');
if (!body) throw new Error('no body part to scroll');
const style = getComputedStyle(body);
// #210. This list does not fit the phone — measured at 424x439,
// `scrollHeight` 412 against `clientHeight` 373 with the seed's
// eight destinations — and said nothing about it, which where the
// cut lands on a row boundary reads as the end of the list.
//
// The mechanism is #207's and is asserted the same way: the pair of
// attachments *is* the feature. A cover of the sheet's own colour
// painted at the end of the content (`local`) over a shadow pinned
// to the box (`scroll`), so the fade is absent on a sheet that
// fits, present the moment one does not, and gone again at the end.
expect(
style.backgroundAttachment,
'the cover must be local and the shadow must not',
).toBe('local, scroll');
expect(style.backgroundPosition).toBe('50% 100%, 50% 100%');
expect(style.backgroundSize).toBe('100% 32px, 100% 32px');
// And the colour is the local half of a shared rule: this sheet
// paints the sidebar's `--yj-bg-surface` (#212529) rather than the
// menus' elevated grey, or the fade draws the *other* sheet's
// colour across the bottom of this one — which is the seam a
// shared fragment would otherwise reintroduce.
expect(style.backgroundImage).toMatch(
/^linear-gradient\(rgb\(33, 37, 41\), rgb\(33, 37, 41\)\)/,
);
// And nothing paints over it. The sidebar's host carries the same
// grey, which inside the sheet is a second opaque copy of the
// surface drawn on top of these layers -- measured at 424x439 with
// the rule removed, the last 32px read a flat 52,58,64 with 39px
// still below, so the fade was painted and covered. That is
// `.context-menu-panel[data-sheet]`'s transparency, one sheet over.
const sidebar = shadow<HTMLElement>(el, 'app-sidebar');
if (!sidebar) throw new Error('no sidebar');
expect(getComputedStyle(sidebar).backgroundColor).toBe('rgba(0, 0, 0, 0)');
});
it('gives the sheet the whole width, which the sidebar does not take', async () => {
const el = await fixture<Nav>('bottom-nav');
@@ -254,6 +254,15 @@ describe('menu-surface', () => {
// Both sit at the bottom, or the cover hides nothing.
expect(style.backgroundPosition).toBe('50% 100%, 50% 100%');
expect(style.backgroundSize).toBe('100% 32px, 100% 32px');
// The layers are shared with `bottom-nav`'s sheet since #210, and
// the colour is what each host still says for itself: this one
// paints the menus' `--yj-bg-elevated` (#343a40). A shared rule
// that hard-coded one grey would draw a seam across the other
// sheet, which is why the fragment reads a custom property.
expect(style.backgroundImage).toMatch(
/^linear-gradient\(rgb\(52, 58, 64\), rgb\(52, 58, 64\)\)/,
);
});
/**