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
"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.
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.
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().
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.
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
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.
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: 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
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
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
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**: 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

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

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

Every action of the Playlists page, since on a phone they all collapse
into this menu. Three rows at 48px.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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
9aaa8befeat(shell): draw a context menu where it fits, not where it is anchoredmenu-surface, and the four things the device had to tell us9e7e7cefeat(shell): put every menu in the app through the one surface31dafb0test(shell): assert the surface, and sweep for a menu that skipped itef5574ddocs(shell): record the clip, and the four things only a device showedCLAUDE.md,.planning/NOTES.mdThe clip, measured before any code was written
Long-press on the lowest visible track row, 424x439, Chrome 113:
.main-panelcontain: contentwa-popupcomputed positionfixedhasOwnProperty('popover')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-surfaceis one element with two presentations — awa-popupabove 600px, a
wa-dialogbottom sheet below it. The host keepsrendering exactly the panel it rendered before and slots it into
whichever surface is up, so
ContextMenuControllerstill drives.activeand.anchoras though it were talking to a popup, and noitem model, keyboard model or styling moved.
showModal()is Chrome 37and 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
here. Every other dialog in this app is mounted in
index.html,outside
.main-panel, so nothing was evidence about one openedfrom inside a view. A probe
<dialog>appended totrack-list'sshadow root paints to y=439, over the mini player and the tab bar.
Ten minutes; it would otherwise have been a rebuild.
probe came out 354px wide on a 424px screen, centred, because a
native
<dialog>carriesmax-width: calc(100% - 6px - 2em)andmargin: auto. Four declarations here are pure undoing.wa-dialogfocuses
[autofocus]or itself on the frame aftershowModal(),and it cannot see our first item to prefer it — the panel is slotted,
so its own
querySelectorstops at the<slot>. Measured: the sheetopened 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().#playlist-submenuis aplacement="right-start"flyout anchored toits 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
the final build: the surface is a native
<dialog>,0 → 424wide,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 stalecontextMenuOpenbreaks) → tap "Add to Queue" → sheet closes and thequeue 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 firstconversion 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 --noEmitin bothfrontend/ande2e/. The Go targets are formalities — no Gochanged — but they ran.
Proving the specs bite. Forcing
sheet = falseand re-running: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-playlistpopup was a real menu and is converted;
now-playing's cover preview isallowlisted, 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.
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: 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
A bare
<dialog>appended totrack-list's shadow root — inside.main-panel, whose paint containment is what clips the menu — andshowModal()n. It paints over the mini player and the tab bar, so atop-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-widthand the reason four declarations inmenu-surfaceare 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
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-startplacement flips against.page-header's overflow, which #69 left at default sizesEvery action of the Playlists page, since on a phone they all collapse
into this menu. Three rows at 48px.