Android: context menus are a bottom sheet, not a popup that gets clipped #176

Merged
logan merged 5 commits from 60-context-menu-action-sheet into main 2026-08-21 07:25:55 +00:00
Collaborator

Closes #60. Taken next because a device was attached and this is the
one open item whose central claim could only be confirmed on it — the
issue's own second comment says "No tier here can reproduce the clip".

Commits

commit what
9aaa8be feat(shell): draw a context menu where it fits, not where it is anchored menu-surface, and the four things the device had to tell us
9e7e7ce feat(shell): put every menu in the app through the one surface fourteen call sites, one tag each
31dafb0 test(shell): assert the surface, and sweep for a menu that skipped it mechanism assertions + the source sweep
ef5574d docs(shell): record the clip, and the four things only a device showed CLAUDE.md, .planning/NOTES.md

The clip, measured before any code was written

Long-press on the lowest visible track row, 424x439, Chrome 113:

.main-panel 0 → 318, computed contain: content
menu panel 191 → 401, 210px tall
clipped away 83px — three of seven items
wa-popup computed position fixed
hasOwnProperty('popover') false
row height 29px (44px floor, 48px asked for)

Both halves of the issue's diagnosis are now measured rather than read
out of the Web Awesome source, and the screenshot below shows the menu
sliced off flush with the mini player's top edge.

The fix

menu-surface is one element with two presentations — a wa-popup
above 600px, a wa-dialog bottom sheet below it. The host keeps
rendering exactly the panel it rendered before and slots it into
whichever surface is up, so ContextMenuController still drives
.active and .anchor as though it were talking to a popup, and no
item model, keyboard model or styling moved. showModal() is Chrome 37
and uses the real top layer, so the sheet is immune by construction
rather than by styling.

The Direction asked for "every existing call site gains it without
changing", and I said on the issue that this is not quite reachable.

The controller does not own the markup, so a zero-edit version would
have to move Lit's own nodes. What is reachable is one tag name per
call site, and contextMenuStyles — already shared by all of them —
carrying the 48px rows once.

Four things the device said and reading could not

  1. "A dialog escapes containment" was the premise and was untested
    here.
    Every other dialog in this app is mounted in index.html,
    outside .main-panel, so nothing was evidence about one opened
    from inside a view. A probe <dialog> appended to track-list's
    shadow root paints to y=439, over the mini player and the tab bar.
    Ten minutes; it would otherwise have been a rebuild.
  2. The UA stylesheet is what makes a naive sheet look wrong. That
    probe came out 354px wide on a 424px screen, centred, because a
    native <dialog> carries max-width: calc(100% - 6px - 2em) and
    margin: auto. Four declarations here are pure undoing.
  3. A retry loop cannot beat a steal that happens later. wa-dialog
    focuses [autofocus] or itself on the frame after showModal(),
    and it cannot see our first item to prefer it — the panel is slotted,
    so its own querySelector stops at the <slot>. Measured: the sheet
    opened with focus on the <dialog> and every arrow key went nowhere.
    Lengthening the budget does not help, because the first attempt
    succeeds. Hence menu-shownMenuKeyboard.refocus().
  4. The submenu was made worse before it was made better.
    #playlist-submenu is a placement="right-start" flyout anchored to
    its row; a full-width sheet moved that anchor to x=0, so the flip put
    the picker at x −182 → 0, entirely off-screen — "Add to Playlist"
    led nowhere. Before the change the row started at x≈245 and the same
    flip landed on screen. I said on the issue I would measure this and
    file it; the measurement said fix it
    , so it is a sheet too and
    stacks over the first.

Verification

  • Device, TLP301 / Android 14 / arm64 / Chrome 113 at 424x439, on
    the final build: the surface is a native <dialog>, 0 → 424 wide,
    bottom 439 (past the main panel's 318 — no clip), panel bottom
    439 (no overflow), 7 items at 48px, focus on WA-DROPDOWN-ITEM | Play. Full cycle: open → Escape → re-open (the thing a stale
    contextMenuOpen breaks) → tap "Add to Queue" → sheet closes and the
    queue really has 2 tracks. Playlist submenu on-screen at 0 → 424.
    page-header's overflow on Playlists: dialog, full width, 3 × 48px.
  • make e2e — the whole suite, 220 tests, twice (after the first
    conversion and again after the submenu and sweep work). The desktop
    popup path is completely unaffected, which is the main risk here.
  • make ui-test (984, +8), make lint, make test, make css-check,
    make bindings-check, make skill-check, tsc --noEmit in both
    frontend/ and e2e/. The Go targets are formalities — no Go
    changed — but they ran.

Proving the specs bite. Forcing sheet = false and re-running:

× draws a native dialog, which is what escapes the clip
× marks the panel as a sheet, which is what sizes the rows
× reports a dismissal it did not initiate
× names the sheet after the menu it contains
  Tests  4 failed | 2 passed (6)

Four of six reproduce. The other two cannot — they assert the
desktop popup, which was already there — and the file says so rather
than implying all six do.

The sweep earned itself immediately. Twelve call sites were
converted by hand; it named two more. queue-panel's add-to-playlist
popup was a real menu and is converted; now-playing's cover preview is
allowlisted, since it is a hover affordance in the bottom bar that a
touch device never opens and nothing clips.

What no tier here can see

The clip itself. This runner's Chromium and CI's WebKit both have the
Popover API, so the popup is top-layered and correct — a "not clipped"
assertion passes on the broken build. That is why the specs assert the
mechanism, and why the numbers above come from the device.

Screenshots — before, after, submenu, and the page-header sheet — in a
comment below.

Closes #60. Taken next because a device was attached and this is the one open item whose central claim could only be confirmed on it — the issue's own second comment says *"No tier here can reproduce the clip"*. ## Commits | commit | what | |---|---| | `9aaa8be` feat(shell): draw a context menu where it fits, not where it is anchored | `menu-surface`, and the four things the device had to tell us | | `9e7e7ce` feat(shell): put every menu in the app through the one surface | fourteen call sites, one tag each | | `31dafb0` test(shell): assert the surface, and sweep for a menu that skipped it | mechanism assertions + the source sweep | | `ef5574d` docs(shell): record the clip, and the four things only a device showed | `CLAUDE.md`, `.planning/NOTES.md` | ## The clip, measured before any code was written Long-press on the lowest visible track row, 424x439, Chrome 113: | | | |---|---| | `.main-panel` | 0 → **318**, computed `contain: content` | | menu panel | 191 → **401**, 210px tall | | **clipped away** | **83px — three of seven items** | | `wa-popup` computed position | `fixed` | | `hasOwnProperty('popover')` | **false** | | row height | **29px** (44px floor, 48px asked for) | Both halves of the issue's diagnosis are now measured rather than read out of the Web Awesome source, and the screenshot below shows the menu sliced off flush with the mini player's top edge. ## The fix `menu-surface` is one element with two presentations — a `wa-popup` above 600px, a `wa-dialog` bottom sheet below it. The host keeps rendering exactly the panel it rendered before and slots it into whichever surface is up, so `ContextMenuController` still drives `.active` and `.anchor` as though it were talking to a popup, and no item model, keyboard model or styling moved. `showModal()` is Chrome 37 and uses the real top layer, so the sheet is immune **by construction** rather than by styling. **The Direction asked for "every existing call site gains it without changing", and I said on the issue that this is not quite reachable.** The controller does not own the markup, so a zero-edit version would have to move Lit's own nodes. What is reachable is one tag name per call site, and `contextMenuStyles` — already shared by all of them — carrying the 48px rows once. ## Four things the device said and reading could not 1. **"A dialog escapes containment" was the premise and was untested here.** Every other dialog in this app is mounted in `index.html`, *outside* `.main-panel`, so nothing was evidence about one opened from inside a view. A probe `<dialog>` appended to `track-list`'s shadow root paints to y=439, over the mini player and the tab bar. Ten minutes; it would otherwise have been a rebuild. 2. **The UA stylesheet is what makes a naive sheet look wrong.** That probe came out **354px wide on a 424px screen**, centred, because a native `<dialog>` carries `max-width: calc(100% - 6px - 2em)` and `margin: auto`. Four declarations here are pure undoing. 3. **A retry loop cannot beat a steal that happens later.** `wa-dialog` focuses `[autofocus]` or *itself* on the frame after `showModal()`, and it cannot see our first item to prefer it — the panel is slotted, so its own `querySelector` stops at the `<slot>`. Measured: the sheet opened with focus on the `<dialog>` and every arrow key went nowhere. Lengthening the budget does not help, because the first attempt *succeeds*. Hence `menu-shown` → `MenuKeyboard.refocus()`. 4. **The submenu was made worse before it was made better.** `#playlist-submenu` is a `placement="right-start"` flyout anchored to its row; a full-width sheet moved that anchor to x=0, so the flip put the picker at **x −182 → 0, entirely off-screen** — "Add to Playlist" led nowhere. Before the change the row started at x≈245 and the same flip landed on screen. **I said on the issue I would measure this and file it; the measurement said fix it**, so it is a sheet too and stacks over the first. ## Verification - **Device**, TLP301 / Android 14 / arm64 / Chrome 113 at 424x439, on the final build: the surface is a native `<dialog>`, `0 → 424` wide, bottom **439** (past the main panel's 318 — no clip), panel bottom 439 (no overflow), **7 items at 48px**, focus on `WA-DROPDOWN-ITEM | Play`. Full cycle: open → Escape → **re-open** (the thing a stale `contextMenuOpen` breaks) → tap "Add to Queue" → sheet closes and the queue really has 2 tracks. Playlist submenu on-screen at `0 → 424`. `page-header`'s overflow on Playlists: dialog, full width, 3 × 48px. - **`make e2e` — the whole suite, 220 tests, twice** (after the first conversion and again after the submenu and sweep work). The desktop popup path is completely unaffected, which is the main risk here. - `make ui-test` (984, +8), `make lint`, `make test`, `make css-check`, `make bindings-check`, `make skill-check`, `tsc --noEmit` in both `frontend/` and `e2e/`. The Go targets are formalities — no Go changed — but they ran. **Proving the specs bite.** Forcing `sheet = false` and re-running: ``` × draws a native dialog, which is what escapes the clip × marks the panel as a sheet, which is what sizes the rows × reports a dismissal it did not initiate × names the sheet after the menu it contains Tests 4 failed | 2 passed (6) ``` Four of six reproduce. **The other two cannot** — they assert the desktop popup, which was already there — and the file says so rather than implying all six do. **The sweep earned itself immediately.** Twelve call sites were converted by hand; it named two more. `queue-panel`'s add-to-playlist popup was a real menu and is converted; `now-playing`'s cover preview is allowlisted, since it is a hover affordance in the bottom bar that a touch device never opens and nothing clips. ## What no tier here can see The clip itself. This runner's Chromium and CI's WebKit both have the Popover API, so the popup is top-layered and correct — a "not clipped" assertion passes on the broken build. That is why the specs assert the *mechanism*, and why the numbers above come from the device. Screenshots — before, after, submenu, and the page-header sheet — in a comment below.
logan added 4 commits 2026-08-21 06:24:58 +00:00
On the reference device every context menu in the app is clipped, and
the two halves of that are structural rather than incidental.  Chrome
113 has no Popover API, so wa-popup takes its own documented fallback
and positions with strategy: "fixed"; .main-panel carries
contain: layout style paint, and paint containment clips fixed
descendants.  Measured at 424x439 before any of this: the main panel
spans 0-318, the open menu spanned 191-401, and three of its seven
items were cut off with no way to reach them.  Rows were 29px against
a 44px floor.

menu-surface is one element with two presentations -- a wa-popup above
600px, a wa-dialog bottom sheet below it -- so the host keeps rendering
the panel it always rendered and ContextMenuController keeps driving
.active and .anchor as though it were talking to a popup.  showModal()
is Chrome 37 and uses the real top layer, so the sheet is immune by
construction rather than by styling.

Four things needed measuring on the hardware rather than reading.

"A dialog escapes containment" was the premise and was untested here:
every other dialog in this app is mounted in index.html, outside
.main-panel.  A probe dialog appended to track-list's shadow root
paints to y=439, over the mini player and the tab bar.

A native dialog's UA stylesheet centres it and caps its width, which
drew a 354px panel in the middle of a 424px screen -- so four
declarations in this component are pure undoing.

wa-dialog focuses [autofocus] or itself on the frame after
showModal(), and it cannot see our first menu item to prefer it: the
panel is slotted, so its own querySelector stops at the <slot>.  A
longer retry budget does not fix that, because the first attempt
succeeds and is then overwritten -- hence menu-shown and
MenuKeyboard.refocus().  The budget became time-based anyway, since
what is being waited for is another component's animation.

And a dismissal has to travel back: wa-dialog closes itself on Escape,
which would leave the controller believing the menu is open.  The
failure mode there is not a stuck sheet but the *next* long-press
doing nothing, which reads as the gesture breaking.
Fourteen call sites, one tag name each and nothing else -- which is what
menu-surface's shape buys: the host's panel is slotted into whichever
presentation is up, so no item model, no keyboard model and no styling
moved.  The 48px rows come from contextMenuStyles, the one stylesheet
every one of these hosts already includes, because the panel is the
host's own light DOM and only the host's stylesheet can reach it.

Two of the fourteen were found by the source sweep rather than by the
conversion: queue-panel's add-to-playlist popup, which is a real menu.
now-playing's cover preview is allowlisted instead -- it is a hover
affordance in the bottom bar, so a touch device never opens it and
nothing clips it.

The playlist submenu had to come too, and that is the one place this
change made something worse before it made it better.  It is a
placement="right-start" flyout anchored to its row, and making the menu
full-width moved that anchor to x=0 -- so the flip put the picker at
x -182 to 0, entirely off-screen, and "Add to Playlist" led nowhere at
all.  Before the change the row started at x~245 and the same flip
landed on screen.  It is a sheet now and stacks over the first, which
is also why menu-shown does not re-assert focus while it is open.

The three hosts that do not use ContextMenuController -- page-header's
overflow menu, playlist-view's hand-rolled menu, queue-panel's picker
-- bind menu-dismiss themselves, or Escape would close the sheet and
leave their own open flag set.

page-header is included deliberately: the clipping does not bite there,
since it opens downward from the top of a full-height view, but on a
phone every action of an overflowing page lives in that menu at
wa-dropdown-item defaults.  One surface, so there is no second answer
to what a menu looks like.
No tier here can reproduce the defect: this runner's Chromium and CI's
WebKit both have the Popover API, so the popup is top-layered and looks
perfectly correct, and a spec asserting "the menu is not clipped" would
pass on the broken build.  So these assert the mechanism -- that the
surface is a native <dialog> at phone width -- which is the same move
queue-as-a-screen.spec.ts makes about containment, for the same reason.

The sweep is the more valuable half.  A thirteenth menu written as a
bare <wa-popup> would work in every tier here and be clipped on the
device, so this reads every source file and fails on one outside a
three-file allowlist, each entry carrying why.  It found two call sites
the by-hand conversion had missed.

Four of the six behavioural tests fail on the build before this change;
the two asserting the desktop popup cannot, because that behaviour was
already there.

Closes #60
docs(shell): record the clip, and the four things only a device showed
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m32s
CI / e2e (pull_request) Successful in 8m57s
ef5574d18b
CLAUDE.md gains the surface beside the keyboard model it shares, and
NOTES.md the measurements: the 83px clip with its screenshot, the probe
that established a top-layer dialog escapes paint containment from
inside a view, the UA stylesheet's 354px, the focus steal a longer
retry cannot beat, and the submenu this change pushed off-screen before
it pulled it back.

The last of those is also a note about scope: the issue was claimed
saying the submenu would be measured and filed, and the measurement
said fix it.
Author
Collaborator

All from the device — TLP301, Android 14, arm64, WebView Chrome 113 at
424x439. Not a resized browser: no other engine reproduces any of this.

The defect, and the fix

before after
before after

Before: the menu is sliced off flush with the mini player's top
edge — four items visible of the seven the DOM holds. "Add to
Favorites", "Track Details" and "Remove from Library" are not scrolled
away or behind something, they are painted nowhere. Note this is not
the "behind other UI" the report guessed at; it is a clip, which is
what the issue's second comment predicted.

After: all seven, 48px each, full width, the sheet reaching the
bottom of the screen.

The probe that settled the design before it was built

probe

A bare <dialog> appended to track-list's shadow root — inside
.main-panel, whose paint containment is what clips the menu — and
showModal()n. It paints over the mini player and the tab bar, so a
top-layer element's containing block really is the viewport even from
in there. It is also 354px wide on a 424px screen, which is the UA
stylesheet's max-width and the reason four declarations in
menu-surface are pure undoing. Both facts came from this one probe,
before any of the real code existed.

The submenu, which this change broke and then fixed

submenu

Stacked as a second sheet over the first, with "Favorites" and "New
Playlist" reachable. Between the two commits it was at x −182 → 0,
entirely off the left edge, because a full-width row moved the anchor
its right-start placement flips against.

page-header's overflow, which #69 left at default sizes

header

Every action of the Playlists page, since on a phone they all collapse
into this menu. Three rows at 48px.

All from the device — TLP301, Android 14, arm64, WebView Chrome 113 at 424x439. Not a resized browser: no other engine reproduces any of this. ### The defect, and the fix | before | after | |---|---| | ![before](https://git.ljones.me/attachments/3f3b71a9-8000-4851-ba23-584a5276930f) | ![after](https://git.ljones.me/attachments/adcb292a-1942-4afc-9fe6-c5850220d630) | **Before**: the menu is *sliced off* flush with the mini player's top edge — four items visible of the seven the DOM holds. "Add to Favorites", "Track Details" and "Remove from Library" are not scrolled away or behind something, they are painted nowhere. Note this is not the "behind other UI" the report guessed at; it is a clip, which is what the issue's second comment predicted. **After**: all seven, 48px each, full width, the sheet reaching the bottom of the screen. ### The probe that settled the design before it was built ![probe](https://git.ljones.me/attachments/3ee86a05-e3aa-4cb3-9d95-7c9a5ec40b2a) A bare `<dialog>` appended to `track-list`'s shadow root — inside `.main-panel`, whose paint containment is what clips the menu — and `showModal()`n. It paints over the mini player and the tab bar, so a top-layer element's containing block really is the viewport even from in there. It is also **354px wide on a 424px screen**, which is the UA stylesheet's `max-width` and the reason four declarations in `menu-surface` are pure undoing. Both facts came from this one probe, before any of the real code existed. ### The submenu, which this change broke and then fixed ![submenu](https://git.ljones.me/attachments/f2ad8c04-edb0-4303-8394-b460ebb68d05) Stacked as a second sheet over the first, with "Favorites" and "New Playlist" reachable. Between the two commits it was at **x −182 → 0**, entirely off the left edge, because a full-width row moved the anchor its `right-start` placement flips against. ### `page-header`'s overflow, which #69 left at default sizes ![header](https://git.ljones.me/attachments/c3eaf122-46c8-4410-ac2b-d2a060bf2512) Every action of the Playlists page, since on a phone they all collapse into this menu. Three rows at 48px.
logan added 1 commit 2026-08-21 06:39:40 +00:00
Merge remote-tracking branch 'origin/main' into 60-context-menu-action-sheet
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m29s
CI / e2e (pull_request) Successful in 8m51s
4b2eec5703
logan merged commit 14e3ab574c into main 2026-08-21 07:25:55 +00:00
Sign in to join this conversation.