Drop the phone's top bar; search becomes a button and a modal #167
@@ -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 `<div popover="manual">` 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 `<dialog>` /
|
||||
`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 `<dialog>` 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 `<job-band>` 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.
|
||||
|
||||
@@ -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, `<bottom-nav>` 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, `<bottom-nav>` 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 `<job-band>`
|
||||
(#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
|
||||
`<search-trigger>` is a button in the row that already says which page
|
||||
you are on and `<search-dialog>` 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
|
||||
`<div popover="manual">` 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. `<dialog>` /
|
||||
`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 `<dialog>`
|
||||
in the tree.
|
||||
|
||||
**It carries the real `<search-bar>`**, 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
|
||||
|
||||
Reference in New Issue
Block a user