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