diff --git a/.planning/NOTES.md b/.planning/NOTES.md index f5b106b..28076da 100644 --- a/.planning/NOTES.md +++ b/.planning/NOTES.md @@ -3580,3 +3580,87 @@ per-card flag can answer at all. The general point: **two columns that agree today are not one column.** Which of them a new surface reads should be decided by which one has something that can un-set it. + +## The queue panel was a column that could not afford to be one (measured 2026-08-19) + +Plan 018, issue #24. Measured against the running app (`make +dev-headless SEED=default`, Chromium) on Playlists, sweeping the +viewport with the queue open and closed. Main panel width, and how much +of the page header survived: + +| viewport | sidebar | main (queue open) | actions clipped | +|---|---|---|---| +| 1280×800 | 200 | 759 | — | +| 1000×700 | 200 | 479 | 2 of 3 | +| **900×600** | 200 | **379** | all three | +| 800×600 | 56 | 423 | all three | +| 390×780 | — | **69** | all three | +| 320×600 | — | **0** | all three | +| 800×600 | 56 | 744 *(closed)* | New Smart Playlist, 158/162px | + +Five things came out of it that the issue did not say. + +- **The header clips at the enforced minimum with the queue closed.** + 800×600 is the only size this app promises, and "New Smart Playlist" + loses 4px of its 162 there. The queue makes it dramatic; it is not + the cause. +- **900×600 is worse than 800×600.** `AUTO_COLLAPSE_VIEWPORT` collapses + the sidebar *below* 900, so the main panel is 843px at 899 and 700px + at 900. **The worst desktop case is the top of the Compact band, not + the enforced floor** — so every viewport list that stopped at "the + minimum" was missing its own worst case. `layout-overflow.spec.ts` + carries 900 now. +- **At 320px the main panel was 0px.** The panel is `flex-shrink: 0` in + the flow of `.content-area`, so an open queue is paid for by the + content rather than covering it. Not degraded — gone. That is the + measurement #55 wanted and did not have. +- **Only Playlists overflows.** All ten primary views swept at 900×600 + and 390×780; every other header reports `scrollWidth == + clientWidth`, and Albums at 390 renders title, count and sort legibly + (checked on a screenshot, not just the number). So #69 is one view's + action set — three text buttons totalling 390px — and not a systemic + header failure. +- **Both reasons in `MinWidth`'s comment had expired.** The subtitle is + `display: none` from 899 down, and the sidebar host is + `overflow-y: auto` (at 600×460, `scrollHeight` 434 against a 332px + client, Settings reachable after scrolling). The floor is right; its + stated defence was two mechanisms that can no longer happen, which is + worse than either answer because nobody can argue with it. + +**A correction worth keeping, because it nearly went in the plan.** My +first probe for the sidebar's scroller searched +`shadowRoot.querySelectorAll('*')` and reported "no scroller — items +are unreachable", which reads exactly like a live Settings-unreachable +bug. The scroller is the **host**, and a host is not inside its own +shadow root. CLAUDE.md was right and the probe was wrong. + +**And one claim in the plan's first draft was too strong**: that the +overlay "removes the desktop half of #69". After phase 2, at 900×600, +open and closed are now *identical* (main 700, one action clipped) +where open used to be main 379 with all three clipped. The queue's +contribution is gone; the header's own overflow remains and is still a +live defect at a supported size. + +### The mode cannot be a media query + +The panel is drag-resizable 200–500px and persisted, so a viewport +breakpoint assumes the default 320 and is wrong by up to 180px for a +user who widened it — in the direction that hurts, since a wider queue +is exactly when the content can least afford it. It is computed from +`.content-area`'s width instead (which already accounts for the +sidebar's collapse), and the component test that matters widens the +panel at a *fixed* parent width and asserts the flip. + +The floor (480) is a judgement, and the measurement is why: there is no +cliff. The track list rescales its columns continuously — 213px down to +124px between main widths of 900 and 544, `rowOverflow=0` at every step +— and the album grid steps 3 columns to 2 somewhere between 564 and 644 +without breaking. So 480 is anchored at both ends instead: it keeps the +default 1100px window inline, and puts every measured-broken case on +the overlay side. + +The scrim is perceptible but subtle on a dark ramp, which is worth +knowing before someone "fixes" it as broken: sampled from 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 — because the queue is not modal. diff --git a/.planning/plans/active/018-supported-sizes-and-the-queue-model.md b/.planning/plans/active/018-supported-sizes-and-the-queue-model.md new file mode 100644 index 0000000..7a134ef --- /dev/null +++ b/.planning/plans/active/018-supported-sizes-and-the-queue-model.md @@ -0,0 +1,294 @@ +# 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:** in flight + +#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 +`` as arbitrary light-DOM markup — Playlists slots +a `
` of three `` + : nothing}
diff --git a/frontend/test/components/queue-overlay-mode.test.ts b/frontend/test/components/queue-overlay-mode.test.ts new file mode 100644 index 0000000..4c23c4d --- /dev/null +++ b/frontend/test/components/queue-overlay-mode.test.ts @@ -0,0 +1,175 @@ +/** + * #24 — the queue stops being a column when it cannot afford to be one. + * + * In flow the panel is `flex-shrink: 0`, so it takes its width *from + * the main panel* rather than covering it. Measured against the running + * app on the Playlists page, that left 379px of content at 900×600 — + * with all three of the page header's actions clipped — 69px at 390px + * wide, and **0px** at 320px, where the content was not degraded but + * gone. + * + * The rule is `available - panelWidth >= MAIN_PANEL_FLOOR`, and the + * reason it is a computed property rather than a `@media` block is the + * third test here: the panel's width is user state, drag-resizable + * between 200 and 500px and persisted, so a breakpoint on the viewport + * alone is wrong by up to 180px for a user who has widened it — in the + * direction that hurts, since a wider queue is exactly when the content + * can least afford it. + * + * The parent is `.content-area`, i.e. the viewport minus the sidebar, + * which is why these mount into a sized wrapper rather than into + * `document.body`: the width that decides this is the *parent's*, and + * `fixture()` would hand the panel the whole test window. + */ +import { describe, expect, it, afterEach } from 'vitest'; + +import '@components/queue-panel/queue-panel'; +import type { QueuePanel } from '@components/queue-panel/queue-panel'; +import { shadow } from '@test/support/render'; + +const wrappers: HTMLElement[] = []; + +afterEach(() => { + for (const w of wrappers.splice(0)) w.remove(); +}); + +/** + * Mount a panel inside a parent of a stated width. + * + * The wrapper is `position: relative` and `display: flex` because that + * is what `.content-area` is; the mode is measured from + * `parentElement.clientWidth`, so a wrapper that collapses to its + * content would measure the panel rather than the space around it. + */ +async function panelIn(parentWidth: number): Promise { + const wrapper = document.createElement('div'); + + wrapper.style.cssText = `position: relative; display: flex; width: ${parentWidth}px;`; + document.body.append(wrapper); + wrappers.push(wrapper); + + const el = document.createElement('queue-panel') as QueuePanel; + + el.open = true; + wrapper.append(el); + + await el.updateComplete; + await settle(el); + + return el; +} + +/** + * A ResizeObserver delivers on a frame, not a microtask, so the mode + * lands a frame after the width that decides it. + */ +async function settle(el: QueuePanel): Promise { + for (let frame = 0; frame < 4; frame += 1) { + await new Promise((r) => { + requestAnimationFrame(() => r(null)); + }); + await el.updateComplete; + } +} + +/** Drag the resize handle by `dx`, the way a user widens the panel. */ +async function dragHandleBy(el: QueuePanel, dx: number): Promise { + const handle = shadow(el, '.resize-handle'); + const startX = el.getBoundingClientRect().left; + + if (!handle) throw new Error('no resize handle to drag'); + + handle.dispatchEvent( + new MouseEvent('mousedown', { clientX: startX, bubbles: true }), + ); + document.dispatchEvent( + new MouseEvent('mousemove', { clientX: startX - dx, bubbles: true }), + ); + document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); + + await settle(el); +} + +describe('the queue panel decides whether it can be a column', () => { + it('stays inline while the content can spare the width', async () => { + const el = await panelIn(1080); + + expect(el.overlay).toBe(false); + expect(el.hasAttribute('overlay')).toBe(false); + }); + + it('becomes an overlay when it cannot', async () => { + const el = await panelIn(700); + + expect(el.overlay).toBe(true); + expect(el.hasAttribute('overlay')).toBe(true); + }); + + /** + * The test the media query could not have passed. The parent does not + * move; only the user's own panel width does. + */ + it('flips to overlay when the user widens the panel, at a fixed width', async () => { + const el = await panelIn(880); + + expect(el.overlay).toBe(false); + + await dragHandleBy(el, 180); + + expect(el.overlay).toBe(true); + }); + + it('gives an overlay a scrim and a named way out, and an inline panel neither', async () => { + const overlaid = await panelIn(700); + + expect(shadow(overlaid, '.scrim')).toBeTruthy(); + + const close = shadow(overlaid, '[data-testid="queue-close"]'); + + expect(close?.getAttribute('aria-label')).toBe('Close queue'); + + const inline = await panelIn(1080); + + expect(inline.shadowRoot?.querySelector('.scrim')).toBeNull(); + expect( + inline.shadowRoot?.querySelector('[data-testid="queue-close"]'), + ).toBeNull(); + }); + + it('closes on the scrim, on the close button and on Escape', async () => { + for (const close of [ + (el: QueuePanel) => shadow(el, '.scrim')?.click(), + (el: QueuePanel) => + shadow(el, '[data-testid="queue-close"]')?.click(), + () => + document.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }), + ), + ]) { + const el = await panelIn(700); + + expect(el.open).toBe(true); + + close(el); + await el.updateComplete; + + expect(el.open).toBe(false); + } + }); + + /** + * Escape belongs to the overlay, not to the queue. An inline panel is + * beside the content rather than over it, so there is nothing to + * dismiss and the key has to reach whatever else wants it. + */ + it('leaves Escape alone while inline', async () => { + const el = await panelIn(1080); + + document.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }), + ); + await el.updateComplete; + + expect(el.open).toBe(true); + }); +});