The `page-header` paragraph already stated "the header asks for a sort, it does not perform one"; actions now follow the same division and it belongs beside it — the header decides what fits, the host decides what happens. Plan 018 moves to completed/ because #69 was the last thing it owed: its size matrix promised "no action is ever unreachable at any supported size" and the residual 114/162px clip was that promise outstanding. Its recap also corrects a claim the plan made — the queue and the actions were not the only two things competing for the header's width, since every child of that flex row was flex-shrink: 0 and the actions come last.
16 KiB
018 — Supported sizes, and what the queue panel is
Issue: #24 (Area/Shell-Nav, Priority/High, Reviewed/Confirmed)
Unblocks: #55 (queue as a screen) — a real Gitea dependency
Relates: #69 (page-header overflow), #12 (mini-player), #51 (small-screen umbrella)
Status: complete — #24 shipped as PR #132, and the matrix's last
unkept promise closed with #69.
#73 puts this first in Phase 2 and hangs the rest of the phase off it, so the decision has to be written down and arguable before any CSS moves. This document is the decision. Everything below the matrix is either a measurement or an argument for one of the four choices #24 asks for.
What is actually wrong, measured
Against the running app (make dev-headless SEED=default, Chromium),
Playlists, sweeping the viewport with the queue open and closed. The
number that matters is how much of the page header survives.
| viewport | sidebar | queue | main panel | header needs | actions clipped |
|---|---|---|---|---|---|
| 1280×800 | 200 | open 321 | 759 | 759 | — |
| 1000×700 | 200 | open 321 | 479 | 747 | New Playlist, New Smart Playlist |
| 900×600 | 200 | open 321 | 379 | 747 | all three |
| 800×600 | 56 | open 321 | 423 | 747 | all three |
| 700×600 | 56 | open 321 | 323 | 747 | all three |
| 390×780 | — | open 321 | 69 | 747 | all three |
| 320×600 | — | open 321 | 0 | 747 | all three |
| 900×600 | 200 | closed | 700 | 747 | New Smart Playlist |
| 800×600 | 56 | closed | 744 | 747 | New Smart Playlist (158/162px) |
| 320×600 | — | closed | 320 | 747 | all three |
Five things in that table are not in the issue.
The header clips at the supported minimum with the queue closed.
At 800×600 — the size backend/config/window.go enforces and the only
size this app promises — "New Smart Playlist" loses 4px of its 162.
#24 reads as a queue-panel bug; the queue makes it dramatic, but the
header overflows on its own at the minimum window.
900×600 is worse than 800×600, because the sidebar expands at 900.
AUTO_COLLAPSE_VIEWPORT collapses the sidebar to icons below 900, so
at 899px the main panel is 843px and at 900px it is 700px. The worst
desktop case is therefore not the minimum window; it is the pixel
immediately above the collapse. Anything that tests "the minimum" and
stops has not tested the worst case, which is what
layout-overflow.spec.ts does today.
At phone widths the queue is not a drawer, it is an amputation.
queue-panel's host is flex-shrink: 0; width: 0, going to
width: var(--queue-width, 320px) under [open] — it is in the flow
of .content-area, so it takes its width from the main panel rather
than covering it. At 390px that leaves 69px of the page; at 320px it
leaves 0px, and the app is not degraded but gone. This is the
measurement #55 needs and did not have.
Only Playlists overflows. Sweeping all ten primary views at 900×600
and at 390×780, every other header reports scrollWidth == clientWidth, and Albums at 390px renders title, count and sort
legibly (checked on a screenshot, not just the number). #69 is
therefore one view's action set — three text buttons totalling 390px —
and not a systemic header failure, though the rule still belongs in
page-header.
Both reasons in MinWidth's comment are stale. It says the floor is
800×600 because "below ~780 the header's subtitle wraps" and "below
~600 tall the eleven sidebar items no longer fit". The subtitle is
display: none below 900 (index.css), and the sidebar host is
overflow-y: auto — at 600×460 its scrollHeight is 434 against a
332px client, and Settings is reachable after scrolling. Neither
mechanism can happen any more. That does not mean the floor should
move; it means its stated reason no longer supports it, which is worse
than either answer.
(Care needed: my first probe for the sidebar scroller searched
shadowRoot.querySelectorAll('*') and reported "items are
unreachable", because the scroller is the host and a host is not in
its own shadow root. The claim in CLAUDE.md is correct.)
Decision 1 — the supported size matrix
Three bands. Two of them already exist and are already argued; what is new is that they are written down as a promise, and that the queue is part of it.
| band | width | navigation | queue | promise |
|---|---|---|---|---|
| Phone | < 600 | bottom-nav + drawer |
overlay, full width | reflows; nothing needs sideways scrolling; fits 320px |
| Compact | 600 – 899 | icon sidebar | overlay + scrim | nothing is clipped or unreachable at any width in the band |
| Desktop | ≥ 900 | labelled sidebar | inline where it fits (see decision 2), else overlay | as Compact |
And one promise across all three: no action is ever unreachable. That is the sentence #69 asks for and it is the one the matrix exists to make checkable.
400% zoom keeps the meaning it already has: WCAG 1.4.10 names 320px
as the reflow target, the phone band covers it, and
layout-overflow.spec.ts already asserts a 320px viewport needs no
sideways scrolling. What changes is that the queue must be part of
that assertion — it is not today, and with the queue open at 320px the
main panel is 0px wide, which no current test can see.
The window minimum stays 800×600, and its comment gets the real reason. The old mechanisms are gone, but the floor is still where the Compact band's chrome stops being comfortable, and lowering it would mean promising the desktop layout at sizes where only the phone layout works. The interesting consequence is decision 4.
Decision 2 — the queue is an overlay when it cannot afford to be a column
The rule. The queue panel renders inline — in the flow, as today — only while
viewport − sidebar − queueWidth ≥ 480
and as an overlay with a scrim otherwise.
Why it cannot be a media query, which is the load-bearing half:
the queue's width is user state. It is drag-resizable between 200 and
500px and persisted (--queue-width, MIN_WIDTH/MAX_WIDTH in
queue-panel.ts). A breakpoint at a fixed viewport width silently
assumes the default 320, and is wrong by 180px for a user who has
dragged the panel wide — in the direction that hurts, since a wider
queue is exactly when the content can least afford it. So the mode is
computed from the measured widths and published as an attribute, the
way data-active-view already is, and the CSS keys off that.
Why 480, honestly. There is no cliff to derive it from. The track
list rescales its columns continuously — at main widths from 900 down
to 544 its --grid-cols shrink from 213px to 124px with
rowOverflow=0 throughout — and the album grid steps 3 columns to 2
somewhere between 564 and 644 without breaking. So this is a judgement,
anchored on two things: it keeps the default window (1100 wide, main
= 580) inline, because the inline queue is a desktop affordance people
choose and turning it into an overlay for the common case would be a
regression in feel; and it puts every case measured as broken —
900×600 at main=379, and every phone width — on the overlay side.
1024×768 lands at main=504 and stays inline.
The scrim is the other half of the issue's complaint ("make the
queue obviously an overlay over the content so it reads as something
to close"). An overlay queue gets a scrim, closes on scrim click and on
Escape, and returns focus to #queue-button.
What must not change: #55's Direction is explicit — one component,
two mount points, do not fork it. The overlay is a presentation of
the same queue-panel, so the roving tab stop, Alt+Arrow reorder, drag
reorder, selection semantics and the virtualizer.requestUpdate() on
selection and current-track change all come along untouched. This
decision deliberately stops short of #55's detail-view mount, but it is
the shape that makes it possible, and it unblocks it.
Decision 3 — #69 is its own PR, and here is the finding that decides it
page-header cannot collapse its own actions, and that is not an
effort estimate but a fact about the API. Actions arrive through
<slot name="actions"> as arbitrary light-DOM markup — Playlists slots
a <div class="header-actions"> of three <button>s with click
handlers, drag handlers and a conditional class. A component cannot
move another component's light-DOM children into a dropdown and keep
their behaviour; there is nothing generic to render as a menu item.
So the overflow rule needs an actions API — hosts declaring
{icon, label, handler, priority} data that page-header can render
either as buttons or as menu items — which is a change to all three
hosts that slot actions, not a rule added in one place. That is a
different piece of work from this one, it is independently verifiable,
and the desktop half of #69's symptom is removed by decision 2 anyway
(the queue stops eating the header's width).
It therefore stays #69, gets the finding above recorded on it, and follows immediately after this. What this plan owes it is the promise in the matrix — no action unreachable at any supported size — and the measurement that the only offender today is Playlists.
And the promise is not kept yet, which is the honest version of a claim this document made in its first draft. "Decision 2 removes the desktop half of #69's symptom" was too strong. Measured after phase 2, at 900×600 on Playlists:
| before | after | |
|---|---|---|
| queue open | main 379px, all three actions clipped | main 700px, one clipped |
| queue closed | main 700px, one clipped | unchanged |
So the queue's contribution is gone — open and closed are now identical, which is the whole of what this decision owed — and the residual "New Smart Playlist: 114/162px" is the header overflowing on its own, at a size the queue never touched. #69 is still a live defect at a supported size, and the matrix's promise is what will close it.
Decision 4 — a very small window becomes the phone layout, not the mini-player
#24 asks whether a very small window should switch to the mini-player (#12) "or simply refuse to go there". Both options in the question are worse than the one the codebase already has.
#12 is a second window, not a mode. Its findings say so: v3
supports multiple windows, AlwaysOnTop is a window option, and the
frontend would need an entry branch mounting only the mini-player root
for a second window loading the same bundle. Turning the main window
into a mini-player at some width conflates the two: it would throw away
the user's navigation state on a resize, and it puts the MPRIS question
(#12's own open question — media controls are process-level and must
not be per-window) on a code path that a drag can trigger by accident.
And "refuses" is unnecessary, because the reflow already exists.
The phone band is real, tested, and reached by width alone — a desktop
window narrowed below 600px already gets bottom-nav and the phone
shell. That is a better answer than refusing: it is strictly more
usable than a hard minimum, it costs nothing new, and it is the same
code Android runs, so it stays exercised.
So: the main window reflows and never becomes a mini-player; #12 stays a separate always-on-top window and is not blocked by, or coupled to, this decision. The window minimum stays 800×600 for the reason in decision 1 — but the phone band is what happens below it, not a refusal, which is why the minimum is a comfort floor rather than a correctness one.
Phases
- This document, linked from #24, with the matrix reported on the issue and #55 told whether it is unblocked. (no code) — done
- The queue's overlay mode — computed mode attribute, scrim, Escape and scrim-click close, focus return. The inline path is unchanged above the threshold. — done
- The window minimum's comment — replace both stale reasons with the measured ones. No value change. — done
- Verification, below. Including the specs that must change because they assert the old behaviour. — done
#69 follows as its own branch; #55 became unblocked at phase 2.
What landed, measured
Main panel width with the queue open, before and after:
| viewport | before | after | mode |
|---|---|---|---|
| 1280×800 | 759 | 759 | inline |
| 1100×720 (default window) | 579 | 579 | inline |
| 1024×768 | 503 | 503 | inline |
| 900×600 | 379 | 700 | overlay |
| 800×600 | 423 | 744 | overlay |
| 390×780 | 69 | 390 | overlay |
| 320×600 | 0 | 320 | overlay |
The scrim is perceptible but subtle on a dark ramp, which is worth knowing before someone "fixes" it: sampled from the screenshots at 900×600, the main panel's background goes 33,37,41 → 18,20,23 and a row's text 242 → 133. It covers the content area only — not the sidebar or the transport — on purpose: the queue is not modal, and leaving the navigation live means the scrim reads as "this is over the content" (which is what #24 asked for) without pretending the rest of the app is unavailable.
What #69 did with the promise, and one thing this plan got wrong
#69 landed on its own branch as decision 3 said it would, and the matrix's no action is ever unreachable at any supported size is now kept rather than promised. Measured on Playlists, actions clipped:
| viewport | before #24 | after #24 | after #69 |
|---|---|---|---|
| 900×600, queue open | all three | one (114/162px) | none |
| 900×600, queue closed | one | one | none |
| 800×600, queue closed | one (158/162px) | one | none |
| 390×780 | all three | all three | none |
| 320×600 | all three | all three | none |
The shape was the one decision 3 predicted — an actions API first, an overflow rule second — and all three hosts that slot actions migrated.
What this document got wrong is smaller and worth keeping. Decision
1 says the header's minimum is a comfort floor and that only the
queue and the actions compete for the header's width. They are not the
only two: every child of that flex row was flex-shrink: 0, so
whatever came last lost, and the actions come last. At 320px the sort
control alone is 172px of the header — so with every action already
collapsed into the menu, the menu button was 76px off the right edge.
The promise was still broken with nothing left to collapse.
That is why #69 also had to decide what gives way: the title (which the navigation also states) and, below 600px, the word "Sort:" (which the direction arrow implies). Neither is an action, which is the rule the matrix actually encodes — an action is a capability and everything else on that row is a label.
Verification, and what each tier cannot see
make ui-test— the queue panel's mode logic is component-tier work and belongs there. It cannot see the shell: the threshold is computed from the sidebar and viewport, which do not exist in that tier.make e2e—layout-overflow.spec.tsgains the queue-open case at every band (it has none today, which is why main=0px at 320px has never failed anything) and gains 900×600, since the minimum is not the worst case.queue-toggle-state.spec.tsandphone-shell.spec.tsboth touch the panel and must be re-read before editing.- Screenshots at every band, read by a human. This is not optional
here:
layout-overflow.spec.tsasserts the shell needs no sideways scrolling and passes on a build whose album header clips its own buttons (measured this session at 390px; filed on #66). Clipping inside a component is invisible to it, and clipping is this issue. make ui-visualcannot help at all — the component tier renders the token fallbacks, because the theme only reaches:rootin the real app.- Accessible names via
page.getByRole(...), never a shadow-root query. A drawer with a scrim is exactly the shape that grows a nameless control, and this repo has shipped one three times.