From a3134f997ff50488acdba309a3fb540999d65e25 Mon Sep 17 00:00:00 2001 From: Logan Date: Wed, 19 Aug 2026 10:21:12 -0400 Subject: [PATCH 1/4] docs(planning): decide the supported sizes and the queue panel's model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #24 asks for a design pass, and #73 hangs the rest of Phase 2 off the answer, so the decision is written down before any CSS moves. Measured against the running app, and five things are not in the issue: the Playlists header clips at 800x600 with the queue *closed* — the minimum window is the only size this app promises; 900x600 is worse than 800x600, because the sidebar expands at 900, so the worst desktop case is not the minimum and every test that stops at the minimum misses it; at 320px with the queue open the main panel is 0px wide, because the panel is in the flow rather than over it; only Playlists overflows, so #69 is one view's action set and not a systemic header failure; and both reasons in MinWidth's comment describe mechanisms that no longer exist. The queue's mode cannot be a media query: its width is drag-resizable between 200 and 500px and persisted, so a fixed breakpoint assumes the default 320 and is wrong by 180px in the direction that hurts. It is computed from the measured widths instead. #69 stays its own PR on a finding rather than an estimate: page-header cannot collapse actions that arrive as arbitrary light-DOM markup through a slot, so the fix needs an actions API across all three hosts. A very small window becomes the phone layout, which already exists and is already tested, rather than the mini-player: #12 is a second always-on-top window, and making it a mode of the main window would discard navigation state on a resize and put the process-level MPRIS question on a path a drag can trigger. --- ...018-supported-sizes-and-the-queue-model.md | 255 ++++++++++++++++++ 1 file changed, 255 insertions(+) create mode 100644 .planning/plans/active/018-supported-sizes-and-the-queue-model.md 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..caed6e6 --- /dev/null +++ b/.planning/plans/active/018-supported-sizes-and-the-queue-model.md @@ -0,0 +1,255 @@ +# 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); + }); +}); From 481c9dca650238e4df9e5dac7d99890196271aa5 Mon Sep 17 00:00:00 2001 From: Logan Date: Wed, 19 Aug 2026 10:41:36 -0400 Subject: [PATCH 3/4] docs: record the size bands and what the queue model cost to find MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLAUDE.md gains the three bands as a promise (Phone <600, Compact 600-899, Desktop >=900, and "no action is ever unreachable at any supported size"), the computed queue rule and why it cannot be a media query, and the correction that 900 — not the 800x600 minimum — is the worst desktop width. NOTES.md gets the measurements, including two things worth more than the fix. 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. And the plan's first draft claimed the overlay "removes the desktop half of #69", which the screenshot disproved: open and closed are now identical at 900x600, so the queue's contribution is gone, but the header's own overflow remains and is still a live defect. Refs #24 --- .planning/NOTES.md | 84 +++++++++++++++++++ ...018-supported-sizes-and-the-queue-model.md | 49 +++++++++-- CLAUDE.md | 62 ++++++++++++++ 3 files changed, 190 insertions(+), 5 deletions(-) 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 index caed6e6..7a134ef 100644 --- a/.planning/plans/active/018-supported-sizes-and-the-queue-model.md +++ b/.planning/plans/active/018-supported-sizes-and-the-queue-model.md @@ -183,6 +183,22 @@ 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 @@ -219,16 +235,39 @@ correctness one. ## Phases 1. **This document**, linked from #24, with the matrix reported on the - issue and #55 told whether it is unblocked. *(no code)* + issue and #55 told whether it is unblocked. *(no code)* — **done** 2. **The queue's overlay mode** — computed mode attribute, scrim, Escape and scrim-click close, focus return. The inline path is - unchanged above the threshold. + unchanged above the threshold. — **done** 3. **The window minimum's comment** — replace both stale reasons with - the measured ones. No value change. + the measured ones. No value change. — **done** 4. **Verification**, below. Including the specs that must change - because they assert the old behaviour. + because they assert the old behaviour. — **done** -#69 follows as its own branch; #55 becomes unblocked at phase 2. +#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. ## Verification, and what each tier cannot see diff --git a/CLAUDE.md b/CLAUDE.md index 9b408d5..a2b6346 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1328,6 +1328,68 @@ is 32px each. Which four is plan 016's committed subset, and everything else — Settings included, because a phone still needs it — is behind "More". +**There are three supported size bands, and the queue is part of the +promise.** Plan 018 (#24) wrote them down: **Phone** below 600 (bottom +nav, reflows, fits 320px exactly), **Compact** 600–899 (icon sidebar), +**Desktop** from 900 (labelled sidebar) — plus one sentence across all +three, *no action is ever unreachable at any supported size*. The bands +themselves already existed; what was new is that they are a promise and +that the queue panel is inside it. + +**900 is the worst desktop width, not the 800×600 minimum.** The +sidebar collapses to icons *below* 900, so the main panel is 843px at +899 and 700px at 900 — the narrowest content area any desktop width +produces is at the top of the Compact band, not at the enforced floor. +Every viewport list that stopped at "the minimum" was therefore missing +its own worst case, which is why `layout-overflow.spec.ts` carries 900 +now. And **both reasons in `MinWidth`'s comment had expired** — the +subtitle is `display: none` from 899 down and the sidebar host scrolls +(`overflow-y: auto`; at 600×460 its `scrollHeight` is 434 against a +332px client) — so 800×600 is a *comfort* floor for desktop chrome and +not a correctness one. Below it the phone layout takes over, which is +also why a very small window reflows rather than becoming a +mini-player: **#12 is a second always-on-top window, not a mode of this +one**, and making it a mode would discard navigation state on a resize +and put the process-level MPRIS question on a path a drag can trigger. + +**The queue panel is a column only while the content can spare the +width, and that cannot be a media query.** In flow the host is +`flex-shrink: 0`, so an open queue is paid for by the main panel: it +left 379px at 900×600 (with all three of the Playlists header's actions +clipped), 69px at 390, and **0px** at 320 — the content was not +degraded but gone. It goes to an overlay with a scrim when +`available - panelWidth < 480`, where `available` is +`.content-area`'s width and therefore already accounts for the +sidebar's collapse. + +Four things about it are load-bearing. **The mode is computed, not +breakpointed**, because the panel's width is user state — drag-resizable +200–500px and persisted — so a viewport breakpoint silently assumes the +default 320 and is wrong by up to 180px in the direction that hurts; +widening the panel at a fixed window size must flip it, and +`queue-overlay-mode.test.ts` is written around exactly that. **480 is a +judgement and says so**: there is no cliff to derive it from (the track +list rescales continuously, 213px to 124px columns with no row +overflow), so it is anchored to keep the default 1100px window inline +and put every measured-broken case on the overlay side. **The scrim +covers the content area only** — not the sidebar or the transport — +because the queue is not modal, and it is subtle on a dark ramp by +arithmetic rather than by accident (33,37,41 → 18,20,23). And **the +overlay is a presentation, not a fork**: #55 asks for one component +with two mount points, so the roving tab stop, Alt+Arrow reorder, drag +reorder, selection semantics and `virtualizer.requestUpdate()` all come +along untouched. Escape closes it and returns focus, and is attached +only while the overlay is up — it is a dismissal, not a shortcut, which +is why it is not a panel-scoped binding. + +What this does **not** fix is `page-header` overflowing on its own: +at 900×600 "New Smart Playlist" is still clipped to 114 of 162px with +the queue *closed*. That is #69, and it cannot be fixed in +`page-header` alone — actions arrive through `` as +arbitrary light-DOM markup with their own handlers, so collapsing them +into a "More actions" menu needs an actions *API* (data, not markup) +across all three hosts that slot them. + **The phone section of `index.css` is last on purpose.** A media query adds no specificity, so a `@media (max-width: 599px)` block placed above the plain rules it overrides loses to them — which is how phase 1 From ede183d0264b812300ee6eae934d48f9c862c5a1 Mon Sep 17 00:00:00 2001 From: Logan Date: Wed, 19 Aug 2026 10:41:43 -0400 Subject: [PATCH 4/4] test(shell): check 900x600, which is narrower than the minimum MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sidebar collapses to icons *below* 900, so the main panel is 843px at 899 and 700px at 900: the narrowest content area any desktop width produces is at the top of the Compact band, not at the enforced floor. A viewport list that stopped at "the minimum" was missing its own worst case. MinWidth's comment loses both reasons it used to give, because neither mechanism can happen any more — the subtitle is display:none from 899 down, and the sidebar host is overflow-y:auto (at 600x460 its scrollHeight is 434 against a 332px client, and Settings is reachable after scrolling). The value does not change: 800x600 is where desktop chrome stops being comfortable, not where the app breaks, and below 600 the phone layout takes over. A floor defended by two expired mechanisms is a number nobody can argue with, which is worse than either answer. Closes #24 --- backend/config/window.go | 32 ++++++++++++++++++++++++------- e2e/specs/layout-overflow.spec.ts | 6 ++++++ 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/backend/config/window.go b/backend/config/window.go index 03f9876..2e61c05 100644 --- a/backend/config/window.go +++ b/backend/config/window.go @@ -13,13 +13,31 @@ const ( // enforces this at runtime; it is also the floor below which a // reported size is treated as bogus and not persisted. // - // 800x600 is where the shell was measured to still work, rather - // than a round number: below ~780 the header's subtitle wraps and - // pushes the title out of the 4em top bar, and below ~600 tall the - // eleven sidebar items no longer fit at once. The previous - // 512x384 was aspirational — at 700x480 the sidebar overflowed - // behind the player bar with no scroll and Settings and Jobs could - // not be reached at all. + // **Both reasons this comment used to give have expired**, and the + // value is right for a third one. It said the floor was 800x600 + // because "below ~780 the header's subtitle wraps and pushes the + // title out of the 4em top bar" and "below ~600 tall the eleven + // sidebar items no longer fit at once". Neither mechanism can + // happen now: the subtitle is display:none from 899px down + // (index.css), and the sidebar host is overflow-y:auto — measured + // at 600x460, its scrollHeight is 434 against a 332px client and + // Settings is reachable after scrolling. A floor defended by two + // mechanisms that no longer exist is a number nobody can argue + // with, which is worse than either answer. + // + // It stays 800x600 because that is where the *desktop* chrome + // stops being comfortable — the Compact band of plan 018's size + // matrix (#24) — and not because the app breaks below it. It does + // not: under 600px wide the phone layout takes over (bottom-nav, + // no sidebar) and the shell fits 320px exactly, which is what + // makes this a comfort floor rather than a correctness one, and + // why a very small window reflows instead of becoming a + // mini-player (#12 is a second always-on-top window, not a mode of + // this one). + // + // The previous 512x384 was aspirational — at 700x480 the sidebar + // overflowed behind the player bar with no scroll and Settings and + // Jobs could not be reached at all. MinWidth = 800 // MinHeight is the smallest allowed window height in pixels. MinHeight = 600 diff --git a/e2e/specs/layout-overflow.spec.ts b/e2e/specs/layout-overflow.spec.ts index f5cd20a..da0a296 100644 --- a/e2e/specs/layout-overflow.spec.ts +++ b/e2e/specs/layout-overflow.spec.ts @@ -26,6 +26,12 @@ const MIN_VIEWPORT = { width: 800, height: 600 }; const VIEWPORTS = [ { name: '1440×900', width: 1440, height: 900 }, { name: '1024×768', width: 1024, height: 768 }, + // Not the minimum, and that is the point (#24). The sidebar collapses + // to icons *below* 900, so the main panel is 843px at 899 and 700px + // at 900 — the narrowest content area any desktop width produces is + // here, not at the enforced floor. A list that stopped at the minimum + // was missing its own worst case. + { name: '900×600 (the widest sidebar, so the narrowest content)', width: 900, height: 600 }, { name: `the minimum (${MIN_VIEWPORT.width}×${MIN_VIEWPORT.height})`, ...MIN_VIEWPORT }, ];