From 3fa7c7734b384fa8a87893822076af7e99c1ae79 Mon Sep 17 00:00:00 2001 From: Logan Date: Wed, 19 Aug 2026 13:18:24 -0400 Subject: [PATCH 1/2] docs: record the page-header actions rule, and complete plan 018 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ...018-supported-sizes-and-the-queue-model.md | 35 ++++++++- CLAUDE.md | 73 +++++++++++++++++++ 2 files changed, 107 insertions(+), 1 deletion(-) rename .planning/plans/{active => completed}/018-supported-sizes-and-the-queue-model.md (89%) diff --git a/.planning/plans/active/018-supported-sizes-and-the-queue-model.md b/.planning/plans/completed/018-supported-sizes-and-the-queue-model.md similarity index 89% rename from .planning/plans/active/018-supported-sizes-and-the-queue-model.md rename to .planning/plans/completed/018-supported-sizes-and-the-queue-model.md index 7a134ef..ed69908 100644 --- a/.planning/plans/active/018-supported-sizes-and-the-queue-model.md +++ b/.planning/plans/completed/018-supported-sizes-and-the-queue-model.md @@ -3,7 +3,8 @@ **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 +**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 @@ -269,6 +270,38 @@ 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 diff --git a/CLAUDE.md b/CLAUDE.md index 0f75b48..be86733 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1980,6 +1980,79 @@ that corrects itself a moment later is worse than saying nothing. And the field, the direction and their persistence, so the control cannot disagree with the list. +**And an action is data, on that same rule: the header decides what +fits, the host decides what happens.** Playlists slotted three buttons +totalling 390px into a header that gets 700px at 900×600, so "New Smart +Playlist" rendered **114 of its 162px** with the queue closed — and on a +phone none of them could be reached at all, which is what #69 reported. +A host passes `PageAction[]` (`{id, label, icon, onSelect, priority, +drop?}`) and `page-header` renders each one as a button or as an item in +one "More actions" menu. + +**It could not have been a rule added in one place**, and that is a fact +about the API rather than an effort estimate: actions used to arrive +through `` as arbitrary light-DOM markup, and a +component cannot move another component's light-DOM children into a +dropdown and keep their behaviour — there is nothing generic in markup +to render as a menu item. The slot survives for markup a data list +cannot express, at the stated cost that **a slotted action does not +collapse** and must therefore fit at 800×600. + +Six things about it are load-bearing: + +- **The fit is measured, never breakpointed.** A ResizeObserver drives + it, and each pass starts from *all visible* and hides the + lowest-priority action until it fits — so the collapsed set is a pure + function of the current width rather than of how the window got + there. A rule that only ever added to the set would never give a + button back, and one that adjusted by a step would need a hysteresis + band to stop it oscillating on the pixel where a button exactly fits. +- **"Fits" means nothing is clipped, which is not the same as the + header not overflowing.** The title can ellipsis, and the moment it + can it absorbs the pressure: `scrollWidth` reports a header that fits + perfectly while the heading reads "Playlis…". That is this bug moved + from the button to the title, invisible to the same measurement that + missed it the first time — so the heading's own truncation counts as + not fitting, and an action is collapsed before the title gives way. + Below that, at 320px, the title *is* what yields: the navigation also + says which page you are on, and an action has nowhere else to be said. +- **The measurement flips `hidden` on the rendered nodes rather than + re-rendering between steps.** Reading `scrollWidth` forces layout, + which is the point; awaiting a Lit update between steps instead lets + the intermediate all-visible state paint, so the fix would flash the + overflow it exists to prevent. +- **Priority is what a *capability* costs, not what a button is worth.** + New Playlist is highest because it is the **drop target** and a closed + menu cannot be one; that is also why `PageAction.drop` carries the + host's own `dragover`/`dragleave`/`drop` handlers rather than the + header owning a notion of dropping, and why the affordance is simply + absent from the overflow rather than approximated there. +- **`aria-controls` names a panel that is always in the DOM** — + `config-section`'s rule, and `wa-popup` hides it when inactive — and + the keyboard model is `MenuKeyboard`, shared with every other menu in + the app so this is not a second one. +- **It is checked per button, because `layout-overflow.spec.ts` cannot + see this.** That spec asserts the *shell* needs no sideways + scrolling and passed on the broken build; clipping *inside* a + component is invisible to it, which is exactly why the defect + survived a spec named for it. + `e2e/specs/header-action-overflow.spec.ts` measures each button + against its header at 900×600, 800×600, 390×780 and 320×600, and + asserts buttons **plus** menu account for every declared action — + without that half it would pass vacuously on a build that renders no + actions at all. + +One thing it deliberately does **not** grow is a phone mode for the +actions. `PHONE_COLUMN_IDS` is the precedent for "what is drawn and +what can be sorted are different questions", but it exists because the +track list's columns cannot be derived from a width; these can, and a +second declaration of what a phone shows is a second thing to keep in +step. What the header *does* state at phone width is one word: below +600px the sort control's "Sort:" label is visually hidden — 172px of a +320px header for a label the adjacent direction arrow implies — and it +stays in the accessibility tree, because it is the select's accessible +name and hiding it outright is `config-field`'s bug one component over. + **The header search box is view-scoped, and now says so.** It sits in the app header and reads as global; typing `tide` on Playlists answered "No playlists match your search" with three *Tideline* tracks in the -- 2.54.0 From f9679165505c9f8fcc2bd8ada50d850a247e9793 Mon Sep 17 00:00:00 2001 From: Logan Date: Wed, 19 Aug 2026 13:18:55 -0400 Subject: [PATCH 2/2] fix(page-header): collapse the actions that do not fit into a menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Playlists slotted three buttons totalling 390px into a header that gets 700px at 900x600, so "New Smart Playlist" rendered 114 of its 162px with the queue closed, and 158 of 162 at the 800x600 enforced minimum. On a phone none of the three could be reached at all, which is what the Android report said. Plan 018's size matrix promises the opposite: no action is ever unreachable at any supported size. The header could not fix that for slotted markup, and that is a fact about the API rather than an effort estimate — a component cannot move another component's light-DOM children into a dropdown and keep their behaviour, and arbitrary markup offers nothing generic to render as a menu item. So a host passes `PageAction[]` and the header chooses the rendering; the slot survives for markup a data list cannot express, at the stated cost that a slotted action does not collapse. All three hosts that slot actions migrated, which also normalises the plain- + + + + `; + } + + private renderActionButton(a: PageAction) { + const drop = a.drop; + + return html` + + `; + } + + // ================================================================= + // The overflow menu + // ================================================================= + + private onActionSelect(a: PageAction): void { + if (a.disabled === true) return; + + this.closeMenu(); + a.onSelect(); + } + + private onMoreClick = (): void => { + if (this.menuOpen) { + this.closeMenu(); + + return; + } + + this.menuOpen = true; + + void this.updateComplete.then(() => { + if (!this.menuOpen) return; + + this.popup?.reposition(); + this.menuKeyboard.open(this.menuPanel ?? null, this.moreButton); + this.attachOutsideClose(); + }); + }; + + private closeMenu(): void { + if (!this.menuOpen) return; + + this.detachOutsideClose(); + this.menuKeyboard.close(); + this.menuOpen = false; + } + + /** + * A click anywhere else closes it. `composedPath` rather than + * `contains`, because the trigger and the panel are both inside + * this shadow root and a click retargets at the host. + */ + private onOutsideDown = (e: Event): void => { + if (e.composedPath().includes(this.menuPanel as EventTarget)) return; + if (e.composedPath().includes(this.moreButton as EventTarget)) return; + + this.closeMenu(); + }; + + private attachOutsideClose(): void { + if (this.outsideCloseAttached) return; + + this.outsideCloseAttached = true; + document.addEventListener('mousedown', this.onOutsideDown, true); + } + + private detachOutsideClose(): void { + if (!this.outsideCloseAttached) return; + + this.outsideCloseAttached = false; + document.removeEventListener('mousedown', this.onOutsideDown, true); + } + private renderCount() { if (this.count === null) return nothing; @@ -258,7 +785,10 @@ export class PageHeader extends LitElement { if (this.sortOptions.length === 1) { return html`
- Sort: ${this.sortOptions[0]?.label} + Sort: ${this.sortOptions[0]?.label} ${this.renderDirectionButton(ascending)}
`; @@ -267,7 +797,7 @@ export class PageHeader extends LitElement { return html`