From d6f7412e9d5349bbd5426b4f1252921949109240 Mon Sep 17 00:00:00 2001 From: Logan Date: Thu, 20 Aug 2026 20:03:28 -0400 Subject: [PATCH] docs(shell): the phone has no top bar, and why the modal is a dialog CLAUDE.md's shell prose said the phone's header "controls shrink or stand down"; there is no header there now. The search box's section gains the modal and the four rules behind it, page-header gains the count as the last thing to yield, and the top-bar-fit section gains what happens below its own band. NOTES.md gets the three measured facts, dated: `contain: paint` is why a Web Awesome popup is clipped on Chrome 113 and why no tier here can reproduce it, the arithmetic that cost the page header its count at 320px, and the shared long-lived e2e app that makes an absolute coordinate a hidden assertion about background jobs. --- .planning/NOTES.md | 79 ++++++++++++++++++++++++++++++ CLAUDE.md | 119 ++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 187 insertions(+), 11 deletions(-) diff --git a/.planning/NOTES.md b/.planning/NOTES.md index 188c78a..40ad8c7 100644 --- a/.planning/NOTES.md +++ b/.planning/NOTES.md @@ -4350,3 +4350,82 @@ The corollary for anything that draws over the shell: **run the whole e2e suite, not the spec you wrote.** A spec written for a feature asserts the feature works; what a new overlay breaks is everything else, and only the suite is looking at that. + +## `contain: paint` is why a Web Awesome popup is clipped on the device (read 2026-08-20, applied 2026-08-21) + +Recorded here because it outlives #57 and #60 both, and because the +next person to reach for a floating surface will reach for `wa-popup`. + +`wa-popup` renders `
` and feature-detects the +Popover API, falling back to `strategy: "fixed"` where there is none. +The reference device is Chrome 113 and `popover` is Chrome 114, so +every popup in the app takes the fallback there. `position: fixed` +escapes ancestor *overflow* but not `contain: paint`, which makes an +element a containing block for fixed descendants **and clips them** — +and `index.css` puts `contain: layout style paint` on `.main-panel` +and on `div.sidebar`. + +So the rule is: **a floating surface opened from inside the main panel +must be a `wa-dialog`, not a `wa-popup`,** because `` / +`showModal()` is Chrome 37 and uses the real top layer. #57's search +modal is one on that ground alone; #60 is the same finding applied to +the six context menus. + +The half that costs time is the second one. **No tier here can +reproduce the clip.** CI's Chromium and WebKit both have the Popover +API, so a popup is top-layered and correct, and a spec asserting "the +surface is not clipped" is green on the broken build. Assert the +*mechanism* — that there is a native `` in the tree at phone +width — which is the one form of the question a browser here answers +honestly. + +## Removing the phone's top bar cost the page header its count (measured 2026-08-21) + +#57 deletes the `top-bar` grid row below 600px and puts a 40px search +button in `page-header` instead. That button is 43px more than the row +has at 320px, which is a width the app promises (WCAG 1.4.10 reflow, +and `header-action-overflow.spec.ts` asks about it). + +Measured on Playlists at 320px, after the fit pass had already +collapsed all three actions into "More actions" and truncated the title +to nothing: title 0, count 50, sort 143, search 40, More 38, five 12px +gaps, 32px of gutters — **363 in 320**, with the More button ending +27px past the edge. So an *action* was clipped, which is the exact +defect #69 exists to prevent. + +What yields is the **count**, last, after everything else. It is the +only item on that row that is neither an identity (the title, which the +navigation repeats) nor an action (the sort control and the buttons, +each the only place they are said). With it gone the header is 304 in +304 and the title even comes back to 19px. + +Two things worth keeping: + +- **The failure was found by the suite, not by the spec.** `make + ui-test` (964), `tsc` in both packages, `make lint`, `make test` and + the new `phone-search.spec.ts` were all green; what failed was + `header-action-overflow.spec.ts` at 320×600, which has nothing to do + with search. That is #62's lesson holding for a second change in a + row: anything that adds to or reflows the shell has a blast radius + the spec you wrote cannot see. +- **A collapsed thing has to still be in the DOM.** Returning `nothing` + from `renderCount()` would have taken the count away for the rest of + the session the first time a 320px window appeared, because + `measureFit` starts every pass from all-visible and needs a node to + un-hide. Same shape as the action buttons, which is where the pattern + was already written down. + +## The e2e app is long-lived, so a staged job outlives the spec that staged it (measured 2026-08-21) + +`make dev-headless` runs one app across every `make e2e` invocation, and +`/__test/emit` writes to a store that nothing clears. A first draft of +`phone-search.spec.ts` asserted the content starts at y=0 with the top +bar gone; it passed alone and failed in a suite run, because +`top-bar-fit.spec.ts` had staged a long-titled scan and `` is +a real grid row whenever work is in flight. + +The fix is not `beforeEach` cleanup — it is measuring the right thing: +the content starts where the **row above it** ends, which is true with a +job running and without one. An assertion against an absolute +coordinate was quietly also asserting "and no background job exists", +which is not something that spec is about or can arrange. diff --git a/CLAUDE.md b/CLAUDE.md index ed31b8f..4a22267 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1559,8 +1559,8 @@ still permits *programmatic* scrolling, so a probe that sets **Below 600px it reflows instead, and that is the phone.** The sideways scroll above was the concession available while the shell had one layout; plan 016 B2 gives it a second. Under 600px the grid drops its -sidebar column, `` takes over as the primary navigation, -the header's controls shrink or stand down, and the shell measures +sidebar column *and* (since #57) its top-bar row, `` takes +over as the primary navigation, and the shell measures exactly 320px in a 320px viewport — so `layout-overflow.spec.ts` now asserts *nothing needs scrolling to*, which is what WCAG 1.4.10 wanted all along. 600 rather than the sidebar's 900 because 900 is a laptop: @@ -1601,9 +1601,36 @@ 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. -**The top bar decides what it can afford, and what it gives up is never -an action.** Its five children do not fit at the bottom of the Compact -band: the bar was 611px inside a 600px viewport idle and **862px while +**And below 600px there is no top bar at all** (#57). The row is gone +from the phone's grid template — not the header hidden, the row deleted +— which is 3.25em of a 439 CSS px viewport, the single biggest vertical +win the reference device has to give. Each of its five children has +somewhere else to be there: `nav-history` is the platform's own back +gesture (already gone from 899 down), the job indicator is `` +(#62, which is why this was blocked on it), the search box is a +`wa-dialog` opened from the view's own header, the library filter is +Settings → Libraries (#148), and the wordmark stays exactly where it is. + +Three things about it are load-bearing. **The header is visually hidden +rather than `display: none`**, because that `h1` is the document's +top-level heading and several pages have no other one — `page-header` +renders no `h1` when `heading` is `''`, and Settings has no +`page-header` at all. Its four *controls* are `display: none` inside it, +which is what keeps them out of the tab order: a visually-hidden +container is still focusable, and tabbing into a search box nobody can +see is worse than not having one. **The fit pass stands down**, from the +bar's computed `position` rather than from a width — with the bar out of +flow there is no content box to measure children against, and a pass +that ran would collapse the wordmark every time and report success about +a 1px box. And **`top-bar-fit.spec.ts` keeps 390 in its list and asserts +the stronger property there**: "nothing hangs out of the bar" is +trivially true of a bar with no row, and would have passed on a build +that merely broke it, so what that width asks now is that the content +starts where the row above it ends. + +**Above 600px the top bar decides what it can afford, and what it gives +up is never an action.** Its five children do not fit at the bottom of +the Compact band: the bar was 611px inside a 600px viewport idle and **862px while a scan ran**, because `job-indicator` is `hidden` when idle and 235px wide showing a real library's scan title (#143). So `services/ top-bar-fit.ts` is `page-header`'s treatment one bar up — a @@ -1624,12 +1651,17 @@ fixed whichever case happened to be idle when it was measured. **What yields is decided by the promise above, which rules out the two cheapest answers.** Hiding the library filter takes away an action — -`library-filter` is the only control in the app that calls -`setSelectedLibrary` — so it trades this promise for the same promise -(#148 is the phone already doing that). Collapsing the search box to an -icon is what #57 wants and #57 is blocked behind #62, so building it -here is building it without the thing that blocks it. The two that -yield are the two that are **not** actions: the wordmark, which the +`library-filter` was the only control in the app that called +`setSelectedLibrary` — so it trades this promise for the same promise. +That is #148, and #57 fixed it by giving the selection a *second +placement* rather than a second definition: the same component, in +Settings → Libraries under a "Showing" label, at every width. A +phone-only copy was the obvious cheaper answer and is the fault, not the +fix — "where do I change which library I am browsing" having two answers +by viewport is exactly what one control in two places avoids. +Collapsing the search box to an icon is what #57 wanted and #57 was +blocked behind #62, so building it here would have been building it +without the thing that blocked it. The two that yield are the two that are **not** actions: the wordmark, which the window's own title bar repeats and which #48 wants down to "YJ" at every width anyway, and then the job indicator's *label*, leaving the ring — which is not a new judgement, since the component already drops @@ -2403,6 +2435,23 @@ Six things about it are load-bearing: without that half it would pass vacuously on a build that renders no actions at all. +**The count is the last thing to yield, and only at 320px.** Four +things compete for that row and three of them cannot go: the title +yields first and is allowed to ellipsis away entirely, because the +navigation also says which page you are on; the sort control and the +actions are each the only place they are said, which is what the +overflow menu exists for. That leaves the count, which is the one +purely informational item there — an empty page says so in its empty +state and a full one is being looked at. It became reachable rather +than theoretical with #57, since below 600px this header also carries +the phone's search button: measured on Playlists at 320px, title 0, +count 50, sort 143, search 40, "More actions" 38, five 12px gaps and +32px of gutters — 363 in 320, with the More button ending 27px past +the edge. It is rendered and hidden with an attribute rather than +returned as `nothing`, for the reason the action buttons are: every +pass starts from all-visible and needs a node to un-hide, or the first +320px window costs the count for the rest of the session. + 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 @@ -2430,6 +2479,54 @@ term belongs in that map**, detail views included — placeholder saying there was nothing to search here, because its sibling was in the map and it was not. +**On a phone the box is a modal, and the map is what decides who gets +one** (#57). There is no header to hold it below 600px, so +`` is a button in the row that already says which page +you are on and `` is where the box goes — and both ask +`searchStore.isSearchableView()` rather than being told, which is the +whole reason the trigger is an element and not a `PageAction`. Seven +hosts each declaring a search action would be a second list of +searchable views, and putting the decision inside `page-header` would +be the phone mode for actions that component documents its refusal to +grow. + +Four things about it are load-bearing. + +**It is a `wa-dialog`, and that is a mechanism rather than a taste.** +#60 read out of the Web Awesome source that `wa-popup` renders +`
` and feature-detects the Popover API, falling +back to `strategy: "fixed"` where there is none — which is Chrome 113, +the reference device, since `popover` is Chrome 114. `position: fixed` +escapes ancestor overflow but **not** `contain: paint`, which +`.main-panel` carries, so a popup-shaped search panel opened from a +view's header is structurally clipped on that device. `` / +`showModal()` is Chrome 37 and uses the real top layer. **No tier here +can see the difference** — CI's Chromium and WebKit both have the +Popover API, so the popup would be top-layered and correct and a spec +asserting "not clipped" would pass on the broken build. The component +tier asserts the *mechanism* instead: that there is a native `` +in the tree. + +**It carries the real ``**, not a second input, which is +what keeps one debounce, one clear button and one view-scoped +placeholder. `--yj-search-max-width` is the one thing the modal changes +about it: 360px is a cap for a header, not for a control that has the +whole of a 424px screen. + +**The results are the page, not a list in the modal.** The term is +view-scoped and the view behind already filters on it and says +"Showing tracks matching …", so Enter closes and hands the screen back. +Rendering results in the dialog would be a second implementation of +every view's filtering, and one that could not offer the row actions +the view does. + +**Escape closes and keeps the term.** `search-bar`'s own input treats +Escape as *clear the search*, which is right in a header where the box +is on screen either way; in a modal it would mean dismissing the search +surface silently discarded the search. The dialog takes the key in the +capture phase on its own host, which is the only listener that runs +before the input inside `search-bar`'s shadow root. + **The window's minimum is measured, not aspirational.** `MinWidth`/ `MinHeight` are 800×600 because that is where the shell was checked to still work: below ~780 the header subtitle wraps and pushes the title