Compare commits

...
Author SHA1 Message Date
logan d6f7412e9d docs(shell): the phone has no top bar, and why the modal is a dialog
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m29s
CI / e2e (pull_request) Successful in 8m19s
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.
2026-08-20 20:03:28 -04:00
logan 1ab767a317 feat(shell): take the top bar out of the phone's layout
The row is deleted from the grid template below 600px, not the header
hidden. That is 3.25em of a 439 CSS px viewport -- the single biggest
vertical win the reference device has to give, and the reason the issue
asks for the row rather than for a smaller bar.

Each of the five things the bar held has somewhere else to be there:
nav-history is the platform's own back gesture and was already gone from
899 down, the job indicator is <job-band> (#62, which is what this was
blocked on), the search box is a modal opened from the view's own
header, the library filter is Settings -> Libraries, and the wordmark
stays where it is.

Three things 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 its heading is empty, 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 on every resize and report success about a 1px box.

**top-bar-fit.spec.ts keeps 390 and asserts the stronger property.**
"Nothing hangs out of the bar" is trivially true of a bar with no row
and would pass on a build that merely broke it, so what that width asks
now is that the content starts where the row above it ends. Measuring
against the window instead would have been asserting "and no background
job is running", which that spec is not about and cannot arrange.

Closes #57
2026-08-20 20:03:20 -04:00
logan ac8f86eb00 fix(settings): give the library selection a home that is not the top bar
library-filter is the only control in the app that calls
setSelectedLibrary, and the phone already hid it with a comment saying
it was "reachable from the drawer's Settings". It was not: Settings adds,
removes, renames and scans libraries, and does not set the view filter,
which is a different thing -- it decides what Albums, Artists and Genres
show. A phone therefore inherited whatever a desktop session last chose
and could neither change nor see it, which is #24's sentence broken in
the band it was written for.

It is a second *placement* of the same component, not a second control,
and it is at every width rather than below 600px. A phone-only copy is
the cheaper answer and is the fault rather than 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.

Closes #148
2026-08-20 20:03:08 -04:00
logan 47bd9ef211 fix(header): let the count yield before an action is clipped
Adding the phone's search button to this header is 43px more than the
row has at 320px, which is a width the app promises and which
header-action-overflow.spec.ts asks about. Measured on Playlists there,
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 and 32px of gutters -- 363 in
320, with the More button ending 27px past the edge. That is an action
clipped, which is the exact defect this pass exists to prevent.

The count is what yields, last, because it is the only item on that row
that is neither an identity nor an action. The title yields first and
may ellipsis away entirely, since the navigation also says which page
you are on; the sort control and the buttons are each the only place
they are said. An empty page says it is empty in its empty state and a
full one is being looked at. With the count gone the header is 304 in
304, and the title comes back to 19px.

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.
2026-08-20 20:03:01 -04:00
logan b801fa533a feat(shell): make search a button and a modal where searching applies
The phone's top bar is about to go, and the search box is the one thing
in it that is an action rather than chrome. It becomes a button in the
row that already says which page you are on, opening a wa-dialog with
the real search box in it.

Three decisions worth the words.

**A wa-dialog, and that is a mechanism rather than a taste.** 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. And
`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 component test asserts the *mechanism*, a native
`<dialog>` in the tree, rather than the symptom.

**An element, not a PageAction.** Two of the seven searchable views are
detail views with no page-header; they filter on the term and say so in
their own headers. Declaring search as an action would mean seven hosts
each writing it out, which is a second list of searchable views, and it
would put a phone mode for actions inside page-header, which that
component documents its refusal to grow. search-store's own map is the
condition, asked by one component placed three times.

**The modal carries the real search-bar**, so there is still one
debounce, one clear button and one view-scoped placeholder. Escape
closes it and *keeps* the term -- the input treats Escape as "clear the
search", which is right in a header where the box stays on screen and
wrong in a surface whose dismissal would then discard the search.
2026-08-20 20:02:50 -04:00
logan 8879192097 Merge pull request 'Show background jobs in the phone's layout, not a popover' (#166) from feat/62-jobs-as-a-notification into main
CI / check (push) Successful in 2m27s
CI / e2e (push) Successful in 8m14s
2026-08-20 22:32:47 +00:00
logan f76ee96ac4 docs(jobs): the phone's band, and why it is in flow
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m26s
CI / e2e (pull_request) Successful in 8m4s
CLAUDE.md's jobs section said the header indicator is "the one view of
everything at once, from every page"; that is now true on a desktop
only, and the band is the phone's half.

NOTES.md takes the measurement that decided the shape -- an overlay
band at 424x439 is a lid, not a notification -- and the corollary about
which tier can see it: ui-test, tsc, lint and the Go suite all passed
on the broken version, and what failed was three e2e specs that have
nothing to do with jobs. Run the suite, not the spec you wrote.
2026-08-20 18:17:42 -04:00
logan 23f5a0c53a feat(shell): show background jobs in the phone's layout, not a popover
The header indicator is a disclosure anchored to a bar 3.25em tall on a
screen 439 CSS px tall, and it was reported as unreadable behind other
UI. Background work is the one thing a phone should not make you open
something to see, and #57 deletes the bar it hangs from and is blocked
on it having somewhere else to live. Below 600px the indicator stands
down and <job-band> takes over.

It is the existing job-panel at `kinds="*"`, so pause, cancel, Details
and the log come along, and so does applyJobControl.

**It is in the layout, not over it**, and that was measured rather than
assumed. The first version put the panel in notification-host's fixed
band: it renders correctly, sits on top and stays inside the viewport,
and is unusable -- at 424x439 a compact panel showing two jobs is
~216px of a 439px screen, drawn over the content and swallowing every
tap under it. Four e2e specs caught it, and none of them was about
jobs: two phone-shell journeys and the header's action menu, all
failing on clicks the band was intercepting. As a grid row above the
main panel it pushes instead, which is #24's one sentence deciding a
layout question -- a band that hides the app to say the app is busy has
traded the popover's fault for a worse one.

It renders nothing above 600px, from matchMedia rather than a media
query, because that decides whether the element exists: Settings
already holds four job-panels and a fifth answering for every kind is
bottom-nav's "resolved to 2 elements" trap again. index.css keeps it
display:none off the phone for a second reason -- an in-flow grid child
with no named area is auto-placed into one of the shell's rows, which
is what the skip link is absolutely positioned to avoid.

top-bar-fit's 390px case asserted the indicator was up, so that it
could not pass by measuring the idle case under another name. At phone
width it is now deliberately away, so the assertion takes the other
branch of the same rule -- the indicator is hidden, the band has the
row, and the bar still has nothing hanging out of it -- rather than
the width being quietly dropped from the list.

The report's own symptom is deliberately not asserted anywhere: it did
not reproduce in this tier. Measured at 424x439 the popover was neither
clipped nor covered, so a spec claiming a stacking fix would be
asserting something that was never true here. The spec says so.

Closes #62
2026-08-20 18:17:35 -04:00
logan 502b814a65 feat(jobs): let a panel answer for every kind, at either density
Three properties the phone's band needs, added here so it is the same
panel rather than a second job UI -- which is what keeps
`applyJobControl` and its "you will discard hours of downloading"
confirmation in the picture.

`kinds="*"` is every kind, which is what the header indicator was for.
Spelled as a star rather than taken as the meaning of an empty
attribute, because empty is what a typo and a dropped binding both
produce and "show everything" is the wrong thing to do by accident;
empty still shows nothing.

`density` is passed to `job-row`, whose `compact` variant its own
source calls "the popover density" -- which is exactly what the band
replaces. `full` stays the default, so the four settings call sites are
untouched.

`active-only` drops terminal rows. The band is in the layout, so a
finished row there holds the content down after the work is done;
Settings keeps them, because that is where "did the last scan work" is
asked and a finished row there dismisses itself.
2026-08-20 18:17:20 -04:00
logan c19a806298 Merge pull request 'Give the seek bar's interpolation interval one owner' (#165) from fix/53-seek-bar-never-moves into main
CI / check (push) Successful in 2m28s
CI / e2e (push) Successful in 7m58s
2026-08-20 21:31:17 +00:00
logan 67eeb75e7b docs(android): the device can be driven, not just looked at
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m31s
CI / e2e (pull_request) Successful in 7m59s
The runtime call does not go over HTTP on Android — the WebView cannot
deliver a fetch() POST body to shouldInterceptRequest, so v3 routes
runtime calls through the addJavascriptInterface bridge. Two things
follow that cost an hour each before the v3 source was read:
`.playwright/init-events.js` does not transfer to the device (its
outbound half hooks fetch, and a POST to /wails/runtime answers
"missing object value" — which reads like a wrong payload and is the
interceptor getting no body at all), and hooking fetch from an eval is
too late on any platform because the bundle captured its reference at
module scope.

The recipe that does work goes in, along with how to get audio onto the
phone (scoped storage silently swallows a push into
/sdcard/Android/data/<pkg>/files, and the fixtures are 2 seconds long,
which is useless for watching a seek bar) and the permission dialog a
reinstall raises, which looks exactly like the app failing to start.

NOTES.md takes the #53 measurements: that its frontend is byte-identical
to the v0.3.1 the phone carries, that the symptom does not reproduce on
main in four scenarios, and that reverting only backend/player/ to
v0.3.1 reproduces #125 instead — with the shim that makes that a
ten-minute experiment rather than a full checkout.
2026-08-20 17:17:33 -04:00
logan fe1fbefee7 fix(player): give the seek bar's interval one owner
`handleInput()` called `stopProgress()` and mutated no reactive state,
so Lit scheduled no update, `updated()` never ran, and the tail of
`updated()` that restarts the interval never executed. Only a `change`
event or the next backend report could bring it back — so an `input`
that never commits froze the interpolation: a drag cancelled outside
the element, a pointer taken by a scroll, or a touch on the track
treated as a scrub, all ordinary gestures on a phone. While playing the
1 Hz report papered over it within a second; with reports not arriving
it was permanent.

The drag is `@state` now and `updated()` decides whether the interval
runs, so there is one place that knows. `handleChange` no longer starts
it directly for the same reason.

A flag set on `input` can strand, which would turn a stall of up to a
second into a permanent one — the failure this removes. `change` is the
ordinary end; `pointerup`/`pointercancel`/`touchend`/`touchcancel` on
the document are the ends that are not, attached with the drag and
dropped with it, because the pointer is routinely released outside the
element it started in.

The other half is that a report arriving mid-drag used to overwrite
`seekValue` and pull the thumb out from under the finger once a second.
It is skipped while dragging, and its seq is deliberately left
unrecorded so the first report after the drag still counts as fresh.

Three tests, all exercised against the fault: two fail on the old
component, and the third fails if the drag flag is left set — which is
the failure mode the fix introduces and the listeners exist to prevent.
Verified on the device too (Chrome 113): mid-drag the bar holds its
value and ignores reports, and on release it adopts the backend's real
position and resumes ticking.

Closes #164
2026-08-20 17:17:23 -04:00
logan de04339494 Merge pull request 'Android: install and launch the package the APK declares' (#163) from fix/159-android-task-app-id into main
CI / check (push) Skipped
CI / e2e (push) Skipped
Build & publish the Android APK / apk (push) Successful in 1m27s
Build & publish Arch package / arch-package (push) Successful in 2m43s
Attach the desktop build to the release / linux (push) Successful in 57s
Sync Homebrew formula / sync-formula (push) Successful in 6s
2026-08-20 19:46:35 +00:00
25 changed files with 2320 additions and 77 deletions
@@ -515,6 +515,75 @@ Four things about it, each of which costs an hour if met cold:
script. Plug in over USB for anything longer than a couple of probes.
- **The socket name carries the pid**, which changes on every launch, so
it is resolved rather than remembered.
- **A reinstall resets the runtime permissions**, and the grant dialog
is a separate activity that takes focus — so the app is up, `am start`
reports "delivered to currently running top-most instance", and
`pidof` is empty because it never got to the foreground.
`dumpsys window | grep mCurrentFocus` naming
`GrantPermissionsActivity` is the tell. `adb shell pm grant
app.yellowjacket.dev android.permission.READ_MEDIA_AUDIO` (and
`POST_NOTIFICATIONS`) ahead of the launch skips it.
### Calling a binding on the device
**The runtime call does not go over HTTP on Android**, and this is worth
knowing before an hour is spent on it. The WebView cannot deliver a
`fetch()` POST body to `shouldInterceptRequest`, so v3 routes runtime
calls through the `addJavascriptInterface` bridge instead: the
@wailsio/runtime installs a `customTransport` that calls
`window.wails.invokeAsync(id, payload)` and receives the answer on
`window._wailsAndroidCallback`. Two consequences:
- **`.playwright/init-events.js` does not transfer to the device.** Its
outbound half hooks `fetch`, which sees nothing here, and its
`call()` posts to `/wails/runtime`, which answers
`Invalid runtime call: missing object value` — the interceptor got the
URL with no body. Its *inbound* half is still right, because
`dispatchWailsEvent` is the entry point in every mode.
- **Hooking `fetch` from an eval is too late anyway**, on any platform:
the bundle captured its reference at module scope, so a wrapper
installed afterwards records nothing. That is why the harness is an
`initScript` and not a step in a spec.
What works is to borrow the bridge, chaining the runtime's own callback
so its pending calls still resolve:
```js
const pending = new Map();
const prev = window._wailsAndroidCallback;
window._wailsAndroidCallback = (id, response, error) => {
if (!pending.has(id)) return prev && prev(id, response, error);
const p = pending.get(id); pending.delete(id);
const env = JSON.parse(response || "{}");
return env.ok ? p.resolve(env.data ?? env.text) : p.reject(new Error(env.error));
};
window.__yj = { call(name, args) {
return new Promise((resolve, reject) => {
const id = "yj" + Math.random().toString(36).slice(2);
pending.set(id, { resolve, reject });
window.wails.invokeAsync(id, JSON.stringify({
object: 0, method: 0, windowName: "",
args: { "call-id": id, methodName: "yellowjacket/backend/" + name, args: args || [] },
clientId: window._wails.clientId,
}));
});
} };
```
That turns the device into a tier that can be *driven* rather than only
looked at — `__yj.call("player.Player.LoadFile", [path])` and
`__yj.call("library.Library.AddLibrary", ["/sdcard/Music/..."])` are how
#53 was measured. Names are the Go ones (`GetTracks`, not
`GetAllTracks`); an unknown one comes back as a plain
`unknown bound method name`, so a wrong guess is loud.
**Getting audio onto the phone**: `adb push` into
`/sdcard/Android/data/<pkg>/files/` looks like it works and then the
files are not there — scoped storage. `/sdcard/Music/...` plus
`pm grant … READ_MEDIA_AUDIO` does work, and `AddLibrary` takes the
plain path. The generated fixtures are **~2 seconds** each, which is
fine for a scan and useless for watching a seek bar, so synthesise a
long one: `ffmpeg -f lavfi -i sine=frequency=440:duration=240`.
**And the reason to bother: the phone is an engine, not a screen.** The
first device here renders in **Chrome 113** at 424x439 CSS px. Every
+187
View File
@@ -4242,3 +4242,190 @@ took another ~10 s to come up.
Harmless here (the emulator was up before anything used it) and a
straightforward race otherwise: `start` should wait for a device that
is an emulator, not for whatever `pick_device` returns. Filed as #162.
## The Android runtime transport is not HTTP (measured 2026-08-20)
Found while trying to drive the phone for #53. `wails3` routes runtime
calls through `addJavascriptInterface` on Android, not through
`/wails/runtime` — the WebView cannot deliver a `fetch()` POST body to
`shouldInterceptRequest`, which the v3 source says in as many words
(`application_android.go`, "The Android transport"). The runtime
installs a `customTransport` over `window.wails.invokeAsync(id,
payload)` and takes the answer on `window._wailsAndroidCallback`.
Two things follow, and both cost time before the source was read:
- **`.playwright/init-events.js` does not transfer to the device.** Its
outbound half hooks `fetch`; a POST to `/wails/runtime` answers
`Invalid runtime call: missing object value`, which reads like a
wrong payload shape and is actually the interceptor receiving a URL
with no body at all. The payload shape was right the whole time. Its
*inbound* half is still correct, because `dispatchWailsEvent` is the
entry point in every mode.
- **Hooking `fetch` from an `eval` is too late on any platform.** The
bundle captured its reference at module scope, so a wrapper installed
afterwards records nothing — which is exactly why the harness is an
`initScript`. Measured: zero calls captured while the app was
demonstrably making them.
The working recipe is in `android-tier.md`; it chains the runtime's own
callback rather than replacing it, so its pending calls still resolve.
This is what makes the device a tier that can be *driven*.
## #53's frontend is byte-identical to the build it was reported against (2026-08-20)
`git diff v0.3.1 HEAD -- frontend/src/components/audio-player/seekbar/
frontend/src/store/player-store.ts` is **empty**; the whole diff in that
area is `backend/player/`. The phone carries the released `v0.3.1`, so
whatever #53 saw, the component was not what changed — and v0.4.0 is
where the player audit (#122#127) landed.
Measured on that phone, current `main`, with a synthesised 4-minute
track: the Now Playing seek bar tracks correctly when mounted
mid-playback (`seekValue` 28 of 240), when the view is opened before
playback starts, after a tap on the track, and across an activity
recreation (same pid, bar resumes at 30 → 35). The issue's stated
symptom did not reproduce in any of them.
Reverting **only** `backend/player/` to v0.3.1 — the frontend and
everything else at HEAD — does reproduce a real position defect on the
same device: six seconds into a 20-second file with no database row,
played after a 240-second one, the bar read **01:27 of 240**. That is
#125's stale `trackLengthMs` ("cleared only by UnloadTrack, so a file
with no row inherited the previous track's duration"), and it is fixed
at HEAD. Note the *shape* of it: the fraction is roughly right and the
absolute numbers are wrong, so it presents as a clock that lies rather
than as a handle that will not move.
The one-line experiment is worth remembering: v0.3.1's `backend/player`
compiles against HEAD with a single shim
(`SetPlaybackFinishedHandler` gained a `srcErr error` parameter), which
makes "did the backend fix cause this" a ten-minute question instead of
a full checkout.
## An overlay band is not a notification, it is a lid (measured 2026-08-20)
#62 asks for background jobs to become "a notification" on the phone,
and the app has exactly one notification surface, so the first version
of the fix put `<job-panel>` in `notification-host`'s band — which is
`position: fixed` under the header. It renders correctly, it is on top,
it is inside the viewport, and it is unusable.
At the device's 424x439 viewport a **compact** panel showing two active
jobs is ~216px — half the screen — drawn over the content, with
`pointer-events: auto` so it swallows every tap underneath. Nothing in
the component tier could see it. The e2e suite could: four specs failed,
and *none* of them was about jobs — two `phone-shell` journeys into the
full-screen Now Playing and `header-action-overflow`'s phone case, all
three because the band was intercepting taps meant for the app.
`<job-band>` is in the shell's grid instead, as a row between the top
bar and the main panel, so it **pushes**. That is #24's one sentence
("no action is ever unreachable at any supported size") deciding a
layout question: a band that hides the app in order to say the app is
busy has traded the popover's fault for a worse one.
Two things fell out of it worth keeping:
- **A finished row in flow is furniture.** The overlay could afford to
keep terminal jobs around; a row that holds the content down after
the work is done cannot. `job-panel` grew `active-only` for the band,
and Settings keeps finished rows because that is where "did the last
scan work" is asked.
- **`job-row` already had the right density.** `variant="compact"` is
described in its own source as "the popover density", which is
exactly what the band is replacing — 216px against 259px for the
same two jobs, and no per-job statistics that a phone has no room
for.
## The e2e suite is the tier that sees a shell regression (2026-08-20)
Worth stating because it decided how #62 was verified. The change is
one component, one stylesheet and one line of `index.html`; `make
ui-test` (955 tests) passed on the broken overlay version and so did
`tsc`, `lint` and the whole Go suite. The failure was three specs that
have nothing to do with jobs, failing on `click()` timeouts.
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.
+139 -15
View File
@@ -593,11 +593,38 @@ rather than renaming them.
`ClearFinishedJobs` is global — a Clear under Libraries would discard
the index build's history too; a finished row dismisses itself.
The header `job-indicator` is untouched and is still the one view of
everything at once, from every page. One consequence worth knowing
before writing a spec: a section holding a `job-panel` also holds a
`job-details-drawer`, whose own header carries `.header` — so
The header `job-indicator` is still the one view of everything at
once, from every page**on a desktop.** One consequence worth
knowing before writing a spec: a section holding a `job-panel` also
holds a `job-details-drawer`, whose own header carries `.header` — so
`config-section .header` is ambiguous the moment a job exists.
**Below 600px that indicator stands down and `<job-band>` takes
over** (#62), because a popover is a *disclosure* and background work
is the one thing a phone should not make you open something to see —
and because #57 deletes the bar it is anchored to and is blocked on
it having somewhere else to live. The band is the same `job-panel`,
so `applyJobControl` and its index-build confirmation come along
rather than being reimplemented; `kinds="*"` is how it says "every
kind", which is what the indicator was for.
Three things about it are load-bearing. **It is in the layout, not
over it**, as its own grid row above the main panel: the first
version put it in `notification-host`'s fixed band, which reads fine
in a screenshot and is unusable — at 424×439 a compact panel is
~200px of a 439px screen and it *covers* what is under it, which four
e2e specs caught by failing on taps it was intercepting. **It shows
active work only** (`active-only`), because in flow a finished row is
furniture that keeps the content pushed down after the work is done;
finished rows stay where the work was started, which is #27's rule.
And **it renders nothing above 600px**, from `matchMedia` rather than
a media query, because that decides whether the element *exists*
Settings already holds four `job-panel`s and a fifth answering for
every kind is `bottom-nav`'s "resolved to 2 elements" trap again.
`index.css` keeps it `display: none` outside the phone for a second
reason: an in-flow grid child with no named area is auto-placed into
one of the shell's rows, which is what the skip link is absolutely
positioned to avoid.
- `config` — TOML-based settings. Settings page uses HTMX + templ for server-rendered HTML fragments.
- `playlist` / `smartplaylist` — Playlist CRUD and rule-based smart playlists.
- `mediacontrols` — OS media controls behind one `Handler`: MPRIS over
@@ -1532,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:
@@ -1574,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
@@ -1597,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
@@ -2376,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
@@ -2403,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
+204
View File
@@ -0,0 +1,204 @@
import { test, expect } from '../support/fixtures.js';
/**
* #62. On a phone, background work is shown in the notification band
* and the header indicator stands down.
*
* The report was that the indicator's popover "is obscured by other UI,
* so it cannot be read while jobs run". Worth saying plainly: **that
* symptom did not reproduce in this tier.** Measured at the device's
* own 424x439 viewport, the popover was neither clipped nor covered —
* `elementFromPoint` at its centre returned the indicator at every
* width tried. So this is not a fix for a stacking bug, and a spec
* asserting one would be a spec asserting something that was never
* true here.
*
* What is true regardless, and is what these assert:
*
* - a popover is a **disclosure**, and it is anchored to a bar 3.25em
* tall on a screen 439px tall. Background work is the one thing a
* phone should not make you open something to see.
* - #57 deletes that bar and is *blocked on this issue*, because the
* indicator needs somewhere else to live first. Somewhere else is
* the band, and the test that matters for #57 is that the bar no
* longer holds the indicator at all.
*
* This is the media-query tier by necessity: a query inside a shadow
* root is answered by the viewport, and `notification-host` decides
* whether the panel *exists* from `matchMedia`. The component tier
* cannot set either.
*/
type Page = import('@playwright/test').Page;
const JOBS = [
{
id: 'phone:scan',
kind: 'library-scan',
state: 'running',
title: 'Scanning Music',
current: 40,
total: 100,
caps: { pausable: true, cancellable: true },
},
{
id: 'phone:idx',
kind: 'index-build',
state: 'running',
title: 'Building the search index',
current: 2,
total: 9,
caps: { pausable: true, cancellable: true },
},
];
/** The panel the band renders. Playwright's CSS engine pierces open
* shadow roots, which is what keeps this one line. */
const bandPanel = (page: Page) => page.locator('job-band').locator('job-panel');
const PHONE = { width: 424, height: 439 };
const DESKTOP = { width: 1100, height: 800 };
test.describe('background jobs on a phone', () => {
test('are shown in the band, without opening anything', async ({
app,
testctl,
}) => {
await app.setViewportSize(PHONE);
await testctl.emit('JobsChanged', JOBS);
await expect(bandPanel(app)).toBeVisible();
// Both jobs, drawn by real `job-row`s -- asking the rows what they
// hold rather than reading the panel's text, which would pass
// whether or not a row rendered. Playwright's CSS engine pierces
// open shadow roots, which is what makes this one line;
// `querySelectorAll` does not, and stops at `job-panel`.
await expect(bandPanel(app).locator('job-row')).toHaveCount(2);
await expect(
bandPanel(app).locator('job-row').first(),
).toContainText('Scanning Music');
});
/**
* The #57 assertion. Not "the indicator is invisible" — that could be
* true because the bar overflowed — but that the shell's own rule
* puts it away at this width.
*/
test('leave the top bar, which is what #57 is waiting for', async ({
app,
testctl,
}) => {
await app.setViewportSize(PHONE);
await testctl.emit('JobsChanged', JOBS);
await expect(bandPanel(app)).toBeVisible();
await expect(app.locator('job-indicator')).toBeHidden();
});
/**
* The property the first attempt at this got wrong, so it is the one
* worth pinning: the band is **in the layout**, not over it.
*
* A fixed band reads fine in a screenshot and is unusable -- at
* 424x439 a compact panel is ~200px of a 439px screen and it covers
* what is under it. Four specs failed on that version, two
* phone-shell journeys and the header's action menu, because the
* panel was intercepting the taps. So: nothing of the app is
* underneath it, and the main panel starts below it.
*/
test('push the content down rather than covering it', async ({
app,
testctl,
}) => {
await app.setViewportSize(PHONE);
const before = await app
.getByTestId('main-content')
.evaluate((el) => el.getBoundingClientRect().top);
await testctl.emit('JobsChanged', JOBS);
await expect(bandPanel(app)).toBeVisible();
const after = await app.evaluate(() => {
const band = document.querySelector('job-band') as HTMLElement;
const main = document.querySelector(
'[data-testid="main-content"]',
) as HTMLElement;
const b = band.getBoundingClientRect();
const m = main.getBoundingClientRect();
// What the browser reports at the band's own centre. If this is
// anything but the band, the band is sitting on top of it.
const hit = document.elementFromPoint(
Math.round(b.x + b.width / 2),
Math.round(b.y + b.height / 2),
);
return {
mainTop: m.top,
bandBottom: b.bottom,
withinViewport: b.bottom <= window.innerHeight + 0.5,
hit: hit?.tagName.toLowerCase() ?? null,
};
});
expect({
pushed: after.mainTop > before,
mainClearsBand: after.mainTop >= after.bandBottom - 0.5,
withinViewport: after.withinViewport,
hit: after.hit,
}).toEqual({
pushed: true,
mainClearsBand: true,
withinViewport: true,
hit: 'job-band',
});
});
/**
* A running job repaints several times a second. The stack it sits
* beside is `role="status" aria-live="polite"`, and a progress bar
* inside a live region is a screen reader reading a number out over
* and over — so the two are siblings in the band rather than one
* list, and this is what says so.
*/
test('are not inside the live region they sit beside', async ({
app,
testctl,
}) => {
await app.setViewportSize(PHONE);
await testctl.emit('JobsChanged', JOBS);
await expect(bandPanel(app)).toBeVisible();
const insideLiveRegion = await app.evaluate(() => {
const band = document.querySelector('job-band');
// Neither the band itself nor anything it is nested in may be a
// live region -- `closest` answers both at once.
return !!band?.closest('[aria-live]') || band?.hasAttribute('aria-live');
});
expect(insideLiveRegion).toBe(false);
});
/**
* `bottom-nav` rendering its duplicate `<app-sidebar>` unconditionally
* broke 30 specs with "resolved to 2 elements" on a viewport where it
* was not even visible. Settings already holds four `job-panel`s, so
* a fifth that answers for *every* kind is the same trap — which is
* why the band decides from `matchMedia` whether the element exists
* rather than hiding it with CSS.
*/
test('do not leave a second panel behind on a desktop', async ({
app,
testctl,
}) => {
await app.setViewportSize(DESKTOP);
await testctl.emit('JobsChanged', JOBS);
await expect(app.locator('job-indicator')).toBeVisible();
await expect(bandPanel(app)).toHaveCount(0);
});
});
+322
View File
@@ -0,0 +1,322 @@
import { test, expect } from '../support/fixtures.js';
/**
* #57. Below 600px the top bar is not in the layout, and search is a
* button that opens a modal on the pages where searching means
* anything.
*
* **This is the tier that can answer it, with one honest exception.**
* The shell's breakpoints are media queries, which the component tier
* cannot set — so whether the bar is a grid row, and whether a header
* grows a search button, is a question for a real viewport. What this
* tier *cannot* answer is the reason the surface is a `wa-dialog`:
* #60 read out of the Web Awesome source that `wa-popup` falls back to
* `position: fixed` where there is no Popover API (Chrome 113, the
* reference device) and that `.main-panel`'s `contain: paint` clips a
* fixed descendant. Chromium and WebKit here both have the Popover API,
* so a popup is top-layered and correct, and **an assertion that the
* modal is not clipped would pass on the broken build.** The mechanism
* is asserted in `frontend/test/components/search-dialog.test.ts`
* instead, where "is there a native <dialog>" is a question a browser
* can answer without lying.
*
* **And it is measured per element.** `layout-overflow.spec.ts` asks
* whether the *shell* needs sideways scrolling and was green throughout
* the defect it is named for; the win this issue is for is vertical and
* belongs to one element, so it is that element's box that is read.
*/
type Page = import('@playwright/test').Page;
/** The reference device's own viewport, and a common small phone. */
const DEVICE = { width: 424, height: 439 };
const PHONE = { width: 390, height: 780 };
/**
* Where the top bar is, and how much of the screen it costs.
*
* `contentTop` is measured against the *jobs band* rather than against
* the window, because that band is a real grid row whenever work is in
* flight (#62) and the app under these specs is long-lived — a job
* staged by another file is still in the store. Measuring against zero
* makes this assertion say "and no background job is running", which is
* not what it is for and is not something it can arrange.
*/
const barBox = (page: Page) =>
page.evaluate(() => {
const bar = document.querySelector<HTMLElement>('header.top-bar')!;
const main = document.querySelector<HTMLElement>('.main-panel')!;
const band = document.querySelector<HTMLElement>('job-band');
const cs = getComputedStyle(bar);
return {
position: cs.position,
height: Math.round(bar.getBoundingClientRect().height),
/** Where the content starts, and where the row above it ends. */
contentTop: Math.round(main.getBoundingClientRect().top),
aboveBottom: Math.round(band?.getBoundingClientRect().bottom ?? 0),
};
});
test.describe('the phone has no top bar', () => {
test.beforeEach(async ({ app }) => {
await app.setViewportSize(DEVICE);
});
test.afterEach(async ({ app }) => {
await app.setViewportSize({ width: 1440, height: 900 });
});
/**
* The vertical win, measured rather than asserted by the absence of
* an element: `display: none` on the header would satisfy "the bar is
* hidden" while leaving a 3.25em grid row exactly where it was.
*/
test('gives the row back to the content', async ({ app }) => {
const box = await barBox(app);
// Out of flow, so it takes no row — and 1px rather than 0, because
// it still carries the document's h1.
expect(box.position).toBe('absolute');
expect(box.height).toBeLessThanOrEqual(1);
// The content starts where the row above it ends, and there is no
// row above it but the jobs band. On `main` at the time of writing
// the content started 52px down from that point.
expect(box.contentTop).toBe(box.aboveBottom);
});
/**
* The wordmark yields its width and not its existence, which is the
* rule `top-bar-fit.ts` already lives by one band up: with the bar
* gone, `display: none` would take this document from one top-level
* heading to none on every page whose own header has no h1 —
* Settings has no `page-header` at all.
*/
test('still has a top-level heading', async ({ app }) => {
await expect(
app.getByRole('heading', { name: 'YellowJacket', level: 1 }),
).toHaveCount(1);
});
/**
* And its four controls are gone from the tab order, not merely from
* sight. A visually-hidden container is still focusable, and tabbing
* into a search box nobody can see is worse than not having one.
*/
test('leaves nothing in the bar to tab into', async ({ app }) => {
for (const tag of [
'nav-history',
'library-filter',
'search-bar',
'job-indicator',
]) {
await expect(app.locator(`header.top-bar ${tag}`)).toBeHidden();
}
const focusable = await app.evaluate(
() =>
document
.querySelector('header.top-bar')!
.querySelectorAll('input, select, button, a[href]').length,
);
// Nothing in the bar is *rendered*, so nothing in it can be
// focused; the controls are display:none, which takes their own
// shadow content with them.
expect(focusable).toBe(0);
});
});
test.describe('search on a phone', () => {
test.beforeEach(async ({ app }) => {
await app.setViewportSize(PHONE);
});
test.afterEach(async ({ app }) => {
await app.setViewportSize({ width: 1440, height: 900 });
});
test('is a button in the view that can be searched', async ({ app }) => {
await app.getByTestId('tab-tracks').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'tracks',
);
// Scoped to the view: every cached primary view holds a
// `page-header`, and an unscoped testid is `bottom-nav`'s
// "resolved to 2 elements" trap again.
const trigger = app.locator('track-list page-header search-trigger button');
await expect(trigger).toBeVisible();
await expect(trigger).toHaveAttribute('aria-label', 'Search tracks');
});
/**
* The whole journey, which is the thing the issue asks for: a button,
* a modal, and the results on the page behind it saying what they are
* showing.
*/
test('opens a modal, filters the page, and says so', async ({ app }) => {
await app.getByTestId('tab-tracks').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'tracks',
);
await app.locator('track-list page-header search-trigger button').click();
const dialog = app.getByTestId('search-dialog');
// Attached, not visible: `wa-dialog`'s host is `display: contents`,
// so the element carrying the testid always reports hidden — what
// is visible is the native `<dialog>` inside it. That awkwardness
// is written down in CLAUDE.md and is why the assertion that this
// is really up is the role query below.
await expect(dialog).toBeAttached();
// Named, which `getByRole` can answer and the a11y snapshot cannot
// — the snapshot never prints a dialog's name, named or not. This
// is also the assertion that the dialog is genuinely showing.
await expect(
app.getByRole('dialog', { name: 'Search tracks' }),
).toBeVisible();
// Scoped: the header's own box is still in the document, hidden.
// This is the one moment there are two `search-input`s.
await dialog.getByTestId('search-input').fill('aurora');
// Enter hands the screen back, because the results are the page.
await app.keyboard.press('Enter');
await expect(dialog).not.toBeAttached();
// Polled: the box debounces by 150ms, so reading the page once
// straight after closing the dialog can capture the state before
// the term ever reached the store.
await expect
.poll(() =>
app.evaluate(
() =>
document
.querySelector('[data-testid="main-content"] track-list')
?.shadowRoot?.querySelector('page-header')
?.shadowRoot?.querySelector('[data-testid="page-search-scope"]')
?.textContent?.trim() ?? '',
),
)
.toMatch(/matching.*aurora/);
// And the button says the search is on, in its name rather than
// only in its colour.
await expect(
app.locator('track-list page-header search-trigger button'),
).toHaveAttribute('aria-label', /aurora/);
// Leave the app as the next spec expects to find it.
await app.locator('track-list page-header search-trigger button').click();
await app.getByTestId('search-dialog').getByTestId('search-input').fill('');
await app.keyboard.press('Escape');
});
/**
* Two of the seven searchable views have no `page-header` — they are
* detail views that filter on the term and say so in their own
* headers. A trigger placed only in `page-header` would leave them
* with a search they can show and no way to set it, which is #24's
* sentence broken in the band it was written for.
*/
test('reaches the playlist detail view too', async ({ app }) => {
await app.getByTestId('tab-playlists').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'playlists',
);
// `.playlist-item`, which is what the list renders. Asserted to
// exist rather than skipped on: the seed has a playlist, and a
// spec that quietly skips when its selector stops matching is a
// spec that reports success for a renamed class.
const first = app.locator('playlist-view .playlist-item').first();
await expect(first).toBeVisible();
await first.dblclick();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'playlist-details',
);
await expect(
app.locator('playlist-details search-trigger button'),
).toBeVisible();
});
/**
* A button that cannot do anything is worse than none — the rule
* `library-status-indicator` was rewritten on. Home has nothing of
* its own to search and is not in the store's map.
*/
test('offers no button where there is nothing to search', async ({ app }) => {
await app.getByTestId('tab-home').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'home',
);
await expect(
app.locator('home-view page-header search-trigger button'),
).toHaveCount(0);
});
test('offers no button on a desktop, where the header has a box', async ({
app,
}) => {
await app.setViewportSize({ width: 1440, height: 900 });
await app.getByTestId('nav-tracks').click();
await expect(
app.locator('track-list page-header search-trigger button'),
).toHaveCount(0);
await expect(app.locator('header.top-bar search-bar')).toBeVisible();
});
});
/**
* #148, which #57 inherits: `library-filter` is the only control in the
* app that calls `setSelectedLibrary`, and the bar it lived in is gone
* on a phone. #143 refused to hide it as a fit step for exactly this
* reason, so dropping it here would have been the same trade.
*/
test.describe('the library filter has a home that is not the bar', () => {
test.afterEach(async ({ app }) => {
await app.setViewportSize({ width: 1440, height: 900 });
});
test('is in Settings, and is reachable from a phone', async ({ app }) => {
await app.setViewportSize(PHONE);
await app.getByTestId('tab-more').click();
await app.getByTestId('nav-drawer').getByTestId('nav-settings').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'settings',
);
const filter = app.getByTestId('settings-library-filter');
await expect(filter).toBeVisible();
await expect(filter.locator('select')).toBeVisible();
});
test('and it is the same control at every width', async ({ app }) => {
// Not a phone-only copy: "where do I change which library I am
// browsing" having two answers by viewport is the fault, not the
// fix.
await app.setViewportSize({ width: 1440, height: 900 });
await app.getByTestId('nav-settings').click();
await expect(app.getByTestId('settings-library-filter')).toBeVisible();
await expect(app.locator('header.top-bar library-filter')).toBeVisible();
});
});
+65 -3
View File
@@ -26,10 +26,22 @@ type Page = import('@playwright/test').Page;
* 600 is the bottom of the Compact band (#24) and where the defect
* lands; 899 and 900 straddle `nav-history` appearing (68px more to
* find, at the width that just gained the sidebar's labels); 800 is the
* enforced minimum; 390 is a phone, where the answer must be that
* nothing collapses because the media queries already did the work.
* enforced minimum.
*
* **390 is kept, and what it asks changed with #57.** There is no bar
* to fit below 600px any more — it is out of the grid and visually
* hidden — so "nothing hangs out of it" is a claim about an element
* with no row, and would pass on a build that had merely broken the
* bar. Dropping the width would be dropping the one place this file
* can still say something true about a phone, so it asserts the
* *stronger* property instead, below: the bar is out of the layout
* altogether, which is the thing #57 wanted and the thing that makes
* fitting moot.
*/
const WIDTHS = [390, 600, 800, 899, 900, 1440];
const WIDTHS = [600, 800, 899, 900, 1440];
/** Where #57 leaves the bar, and where the desktop still has one. */
const PHONE_WIDTH = 390;
/**
* A scan whose title is as long as a real one gets. The label is capped
@@ -90,6 +102,56 @@ const collapsed = (page: Page) =>
}));
test.describe('the top bar fits the window', () => {
/**
* The phone's answer, which is not "it fits" (#57).
*
* The bar has no grid row below 600px, so measuring its children
* against its content box is measuring a 1px box that is already
* invisible — a fit pass would collapse the wordmark every time and
* report success about nothing, which is why `measureTopBarFit`
* declines to run at all when the bar is out of flow. What is worth
* asserting here is that the fit pass has not quietly started
* *undoing* that: a rule that put the bar back in the layout would
* pass every assertion in this file and cost a 439px screen 12% of
* its height.
*/
test(`the bar is out of the layout at ${PHONE_WIDTH}px, with a job running`, async ({
app,
testctl,
}) => {
await app.setViewportSize({ width: PHONE_WIDTH, height: 600 });
await testctl.emit('JobsChanged', [LONG_JOB]);
// Not merely hidden: `display: none` on the header would satisfy
// "invisible" and leave the 3.25em row exactly where it was. So
// the assertion is that the content starts where the row above it
// ends -- and with a job staged, the row above it is the jobs
// band, which is the whole reason this row could go.
await expect
.poll(() =>
app.evaluate(() => {
const bar = document.querySelector<HTMLElement>('header.top-bar')!;
const main = document.querySelector<HTMLElement>('.main-panel')!;
const band = document.querySelector<HTMLElement>('job-band')!;
return {
position: getComputedStyle(bar).position,
gap:
Math.round(main.getBoundingClientRect().top) -
Math.round(band.getBoundingClientRect().bottom),
};
}),
)
.toEqual({ position: 'absolute', gap: 0 });
// And the work is still visible, in the band that replaced the
// indicator (#62) — which is what made this row removable at all.
await expect(app.locator('job-indicator')).toBeHidden();
await expect(app.locator('job-band').locator('job-row')).toHaveCount(1);
await app.setViewportSize({ width: 1440, height: 900 });
});
for (const width of WIDTHS) {
test(`no control sits outside the bar at ${width}px, idle`, async ({
app,
+87 -35
View File
@@ -412,8 +412,18 @@ body div.sidebar {
=================================================================== */
@media (max-width: 599px) {
body {
/* **There is no top-bar row here (#57).** Every one of the five
things that bar held has somewhere else to be below 600px:
`nav-history` is the platform's own gesture (gone from 899
down), the job indicator is `<job-band>` (#62), the search
box is a modal opened from the view's own header
(`search-trigger`), the library filter is Settings ->
Libraries (#148), and the wordmark is below. That is 3.25em
of a 439 CSS px viewport -- the single biggest vertical win
available on the reference device, which is why #57 asks for
the row rather than for a smaller bar. */
grid-template:
"top-bar" 3.25em
"jobs-band" auto
"main-panel" 1fr
"bottom-bar" auto
"bottom-nav" auto
@@ -432,46 +442,55 @@ body div.sidebar {
grid-area: bottom-nav;
}
/* The 2em gutters are half a thumb each at this width, and the
subtitle is already gone from 900 down.
/* The bar is out of the layout, and out of it the way the *wordmark*
already goes at desktop widths: visually hidden rather than
`display: none`, because that `h1` is the document's top-level
heading and this app would otherwise have none on the pages whose
own header is empty by design (`page-header` renders no `h1` when
`heading` is '', and Settings has no `page-header` at all).
`min-width: 0` is the load-bearing half. A grid item's implicit
minimum is `auto` -- its content -- so a header whose children
ask for 580px makes the *body* 580px wide inside a 360px
viewport, and `overflow-x: hidden` then hides the right-hand
third of the app rather than fitting it. Every box between the
viewport and the content that must shrink needs this. */
Its four *controls* are `display: none` below, 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.
This is `styles/sr-only.css.ts`'s recipe again, written out
because that one is a `CSSResult` for shadow roots and this is
the light DOM. `position: absolute` is also what tells
`services/top-bar-fit.ts` there is no row to fit into. */
.top-bar {
padding-left: 0.75em;
padding-right: 0.75em;
gap: 0.5em;
min-width: 0;
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
gap: 0;
min-width: 0;
}
.top-bar nav-history,
.top-bar library-filter,
.top-bar search-bar,
.top-bar job-indicator {
display: none;
}
/* `min-width: 0` is load-bearing wherever a box sits between the
viewport and content that must shrink. A grid item's implicit
minimum is `auto` -- its content -- so one child insisting on
580px makes the *body* 580px wide inside a 360px viewport, and
`overflow-x: hidden` then hides the right-hand third of the app
rather than fitting it. */
.content-area,
.main-panel,
.bottom-bar {
min-width: 0;
}
.title {
font-size: 1.1em;
}
/* The search box is the one header control worth its width; the
library filter is a rarely-changed setting and reachable from
the drawer's Settings.
`nav-history` is already gone from 899 down. It would belong
here anyway and for a stronger reason than width: the phone has
Back as a gesture or a button the OS owns, and this app hooks it
(`popstate`), so a second Back in the chrome duplicates a
control the platform provides. */
.top-bar library-filter {
display: none;
}
/* The full-screen now-playing view *is* the transport, so the bar
repeating it underneath is 4em of a small screen spent saying
the same thing twice -- visible in a screenshot, invisible to
@@ -485,11 +504,6 @@ body div.sidebar {
body:has(#main-content[data-active-view="now-playing"]) .bottom-bar {
display: none;
}
.top-bar search-bar {
flex: 1 1 auto;
min-width: 0;
}
}
@media (max-width: 599px) {
@@ -522,3 +536,41 @@ body div.sidebar {
display: none;
}
}
/* Out of the desktop grid entirely. `job-band` renders nothing above
600px anyway, but an in-flow grid child with no named area is
auto-placed into a row of the shell -- the same trap the skip link is
absolutely positioned to avoid. */
body job-band {
display: none;
}
/* #62. The job indicator stands down on the phone, and its work is
shown in the notification band instead (notification-host).
Three reasons, and the first is the report: its popover is anchored
to the top bar, which is 3.25em here on a viewport 439 CSS px tall,
and it was reported as unreadable behind other UI. The second is
that a popover is a disclosure, and background work is the one thing
a phone should not make you disclose. The third is #57, which
deletes this bar entirely and is blocked on the indicator having
somewhere else to live -- this is that somewhere.
#57 has since done exactly that, so the indicator's own rule now
lives with the other three in the phone block above, where the bar
goes out of the layout in one statement rather than four. What stays
here is the band, and the argument for it. */
@media (max-width: 599px) {
/* The indicator's rows appear here, in the grid row above the content.
In flow rather than over it: a fixed band reads fine in a
screenshot and is unusable, because at 424x439 a compact panel
is ~200px of a 439px screen and it *covers* what is under it.
Measured, not assumed -- four e2e specs failed on that version,
two phone-shell journeys and the header's action menu, because
the panel was intercepting the taps. */
body job-band {
display: block;
grid-area: jobs-band;
background-color: var(--yj-bg-elevated, #343a40);
}
}
+21
View File
@@ -14,6 +14,13 @@
user is not walked through the header, the library filter, the
search box and eleven nav items on every navigation. -->
<a class="skip-link" href="#main-content">Skip to content</a>
<!-- Below 600px this bar is not in the layout at all (#57): index.css
takes its grid row away and leaves the element visually hidden,
carrying nothing but the `h1` below. Every control in it has
somewhere else to be there -- `nav-history` is the platform's
own back gesture, `job-indicator` is `<job-band>`, `search-bar`
is `<search-dialog>` opened from the view's own header, and
`library-filter` is Settings -> Libraries (#148). -->
<header class="top-bar">
<hgroup>
<h1 class="title">YellowJacket</h1>
@@ -30,6 +37,14 @@
<search-bar></search-bar>
<job-indicator></job-indicator>
</header>
<!-- The phone's view of background work (#62): below 600px the
indicator above stands down and its rows appear here instead,
in the layout rather than over it. `display: none` above that
width in index.css, which is also what keeps it out of the
desktop grid -- an in-flow child with no named area is
auto-placed into one of the shell's rows, which is the trap the
skip link is absolutely positioned to avoid. -->
<job-band></job-band>
<div class="sidebar">
<app-sidebar></app-sidebar>
</div>
@@ -76,6 +91,12 @@
<first-run-wizard></first-run-wizard>
<notification-host></notification-host>
<shortcuts-overlay></shortcuts-overlay>
<!-- The phone's search surface (#57). A singleton here for the
reason shortcuts-overlay is one: one instance, one document
listener, and no `data-testid="search-input"` resolving to two
elements. It renders nothing while shut, so the header's box
is still the only one on a desktop. -->
<search-dialog></search-dialog>
</body>
</html>
+10
View File
@@ -28,6 +28,11 @@ import '@components/bottom-nav/bottom-nav.ts';
import '@components/queue-panel/queue-panel.ts';
import '@components/nav-history/nav-history.ts';
import '@components/search-bar/search-bar.ts';
// The phone's search surface (#57). Eager, because below 600px it is
// the *only* way to search and a modal that has to fetch a chunk before
// it can take a keystroke is late by exactly the interval it exists to
// remove. It renders nothing until asked.
import '@components/search-dialog/search-dialog.ts';
import '@components/library-filter/library-filter.ts';
import '@components/first-run-wizard/first-run-wizard.ts';
import '@components/notifications/notification-host.ts';
@@ -38,6 +43,11 @@ import '@components/confirm-dialog/confirm-dialog.ts';
// not know what is going on. It costs a dialog and a table.
import '@components/shortcuts-overlay/shortcuts-overlay.ts';
import '@components/jobs/job-indicator.ts';
// The phone's half of the same thing (#62). Eager because it is part
// of the shell's first paint below 600px, and because a band that has
// to fetch a chunk before it can say the app is busy is late by
// exactly the interval it exists to explain.
import '@components/jobs/job-band.ts';
import '@awesome.me/webawesome/dist/styles/themes/default.css';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import { setBasePath } from '@awesome.me/webawesome/dist/webawesome.js';
@@ -22,6 +22,24 @@ export class SeekBar extends LitElement {
@state()
private seekValue: number = 0;
/**
* Whether the user is dragging the thumb right now.
*
* It is `@state` rather than a plain field because `updated()` owns
* the interval and only reactive state brings `updated()` round. A
* bare `stopProgress()` in the input handler mutated nothing, so
* nothing re-rendered, so the tail of `updated()` that restarts the
* interval never ran — and the only things that could restart it
* were a `change` event or the next backend report. Any `input`
* without a committed `change` therefore froze the interpolation:
* a drag cancelled outside the element, a pointer taken by a scroll,
* or a touch on the track treated as a scrub, which on a phone are
* ordinary gestures. While playing, the 1 Hz report papered over it
* within a second; with reports not arriving it was permanent.
*/
@state()
private dragging: boolean = false;
/** Whether the right-hand clock shows time remaining or total. */
@state()
private showRemaining: boolean = true;
@@ -133,6 +151,7 @@ export class SeekBar extends LitElement {
override disconnectedCallback() {
super.disconnectedCallback();
this.stopProgress();
this.endDrag();
}
override updated() {
@@ -154,18 +173,33 @@ export class SeekBar extends LitElement {
// A report for a track that is no longer loaded is stale by
// definition: the change id is the only thing that distinguishes
// it, since the same file can play twice in a row.
//
// A report arriving mid-drag is deliberately *not* applied: the
// thumb belongs to the finger on it, and adopting a report once a
// second pulls it back out from under them. The seq is left
// unrecorded too, so the first report after the drag still counts
// as fresh.
const position = this.player.position;
const forThisTrack =
position !== null && position.trackChangeId === currentChangeId;
if (position && forThisTrack && position.seq !== this.previousPositionSeq) {
if (
position &&
forThisTrack &&
!this.dragging &&
position.seq !== this.previousPositionSeq
) {
this.previousPositionSeq = position.seq;
this.seekValue = position.positionSeconds;
this.stopProgress();
}
// Start/stop progress interval based on playback state
if (this.isPlaying && this.hasTrack) {
// One owner for the interval, and this is it. Every other place
// that wants it started or stopped says so by changing state that
// brings us back here, so the timer cannot be left running by a
// path that forgot to stop it or stopped by a path that forgot to
// start it again.
if (this.isPlaying && this.hasTrack && !this.dragging) {
this.startProgress();
} else {
this.stopProgress();
@@ -210,18 +244,48 @@ export class SeekBar extends LitElement {
private handleChange(e: Event) {
const newSeekVal = (e.target as WaSlider).value;
this.endDrag();
this.setSeekValue(newSeekVal);
this.player.seek(newSeekVal);
}
if (this.isPlaying) {
this.startProgress();
/**
* The user is moving the thumb.
*
* This only records that fact; `updated()` decides what it means for
* the interval. `seekValue` follows the slider so the clocks track
* the thumb during the drag rather than jumping when it is released.
*/
private handleInput(e: Event) {
this.setSeekValue((e.target as WaSlider).value);
if (this.dragging) {
return;
}
this.dragging = true;
// A drag that never commits must not strand the flag, or this fix
// turns a stall of up to one second into a permanent one -- which
// is the failure it exists to remove. `change` is the ordinary
// end; these are the ones that are not, and they are on the
// document because the pointer is routinely released outside the
// element it started in. A drag's listeners belong to the drag,
// so they go on with it and come off with it.
document.addEventListener('pointerup', this.endDrag);
document.addEventListener('pointercancel', this.endDrag);
document.addEventListener('touchend', this.endDrag);
document.addEventListener('touchcancel', this.endDrag);
}
// Stops progress while user is dragging the thumb
private handleInput() {
this.stopProgress();
}
private endDrag = () => {
document.removeEventListener('pointerup', this.endDrag);
document.removeEventListener('pointercancel', this.endDrag);
document.removeEventListener('touchend', this.endDrag);
document.removeEventListener('touchcancel', this.endDrag);
this.dragging = false;
};
private setSeekValue(val: number) {
if (val < 0) val = 0;
@@ -56,6 +56,10 @@ import {
import './config-field';
import './config-section';
// The view filter's home (#148). The same component the top bar
// carries, placed a second time rather than reimplemented -- two
// definitions of "which library am I browsing" is what this is for.
import '@components/library-filter/library-filter';
import './download-clients';
import './shortcut-capture';
import { confirmAction } from '../confirm-dialog/confirm-dialog';
@@ -231,6 +235,42 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
flex-wrap: wrap;
}
/* #148, and the second half of #57.
library-filter is the only control in the app that calls
setSelectedLibrary, and it lived in the top bar -- which
#57 takes out of the layout on a phone, and which #143
already refused to hide as a fit step precisely because
hiding it takes away an action. So the selection gets a home
that does not depend on that bar existing.
At every width, not below 600px: a phone-only copy would be
a second place the control lives, and "where do I change
which library I am browsing" having two answers by size is
the fault, not the fix. */
.library-scope {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1em;
flex-wrap: wrap;
margin-bottom: 1em;
}
.library-scope .scope-label {
font-weight: 600;
font-size: 0.85em;
color: var(--yj-text-primary, #fff);
display: block;
}
.library-scope .scope-description {
font-size: 0.75em;
color: var(--yj-text-tertiary, #888);
margin: 0.35em 0 0;
max-width: 40em;
}
.save-row {
display: flex;
gap: 0.5em;
@@ -2370,6 +2410,21 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
for new and changed files."
.open=${true}
>
<div class="library-scope">
<div>
<span class="scope-label">Showing</span>
<p class="scope-description">
Which library the Albums, Artists and Genres
views show. This is a view filter, not a
setting about the libraries themselves — the
list below is where they are added, renamed
and scanned.
</p>
</div>
<library-filter data-testid="settings-library-filter">
</library-filter>
</div>
<div class="scan-actions">
<button
class="btn-primary"
+132
View File
@@ -0,0 +1,132 @@
/**
* The phone's view of background work (#62).
*
* The header `job-indicator` is a *popover*, anchored to a bar 3.25em
* tall on a screen 439 CSS px tall, and it was reported as unreadable
* behind other UI. Two things are wrong with it there regardless of
* that symptom: a popover is a **disclosure**, and background work is
* the one thing a phone should not make you open something to see; and
* #57 deletes the bar it is anchored to, and is blocked on this issue
* precisely because the indicator needs somewhere else to live first.
*
* This is that somewhere. Below 600px the indicator stands down
* (`index.css`) and its work appears here instead.
*
* Four things about it are load-bearing.
*
* **It is the existing `job-panel`, not a second job UI.** Pause,
* cancel, Details and the log all come along — and, more to the point,
* so does `applyJobControl`, which is what carries the "you will
* discard hours of downloading" confirmation for an index build. A
* host drawing its own buttons drops that silently, which is the trap
* #27 already named.
*
* **It is in the layout, not over it**, and that was measured rather
* than assumed. The first version of this put the panel in
* `notification-host`'s fixed band, which reads fine in a screenshot
* and is unusable: at 424x439 a compact panel is ~200px of a 439px
* screen, and it *covers* what is under it. Four e2e specs failed —
* two phone-shell journeys and the header's action menu — because the
* panel was intercepting the taps. A band that hides the app to tell
* you the app is busy is worse than the popover it replaced. In flow
* it pushes instead, so nothing is covered and nothing is unreachable,
* which is #24's one sentence across all three bands.
*
* **It shows active work only.** A finished row that lingers is a
* banner that stays after the work is done, which is the opposite of
* what #62 asks for ("dismissed automatically on completion") and, in
* flow, is furniture that keeps the content pushed down. Finished jobs
* are still shown where the work was started, which is #27's rule and
* unaffected.
*
* **It renders nothing at all above 600px**, from `matchMedia` rather
* than a media query, because this decides whether the element
* *exists*. `bottom-nav` learned that the expensive way: rendering its
* duplicate `<app-sidebar>` unconditionally put a second copy of every
* `nav-*` testid in the DOM and broke 30 specs on a viewport where it
* was not even visible. Settings already holds four `job-panel`s, so a
* fifth answering for *every* kind is the same trap.
*/
import { LitElement, html, css, nothing } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { jobStore } from '@store/job-store';
import { isTerminal } from '@store/job-store';
import { designTokens } from '../../styles/tokens.css';
import { PHONE_QUERY } from '../../utils/breakpoints';
import './job-panel';
@customElement('job-band')
export class JobBand extends LitElement {
@state() private phone = false;
@state() private active = 0;
private media?: MediaQueryList;
private unsubscribe?: () => void;
static override styles = [
designTokens,
css`
:host {
display: block;
min-width: 0;
}
/* The panel's own margin is for a settings section; here the
band owns the spacing. */
job-panel {
margin-top: 0;
padding: 0 0.5em 0.5em;
}
`,
];
private onMedia = (e: MediaQueryListEvent | MediaQueryList) => {
this.phone = e.matches;
};
private onJobs = () => {
this.active = jobStore.jobs.filter((job) => !isTerminal(job)).length;
};
override connectedCallback(): void {
super.connectedCallback();
this.media = window.matchMedia(PHONE_QUERY);
this.phone = this.media.matches;
this.media.addEventListener('change', this.onMedia);
// The band decides whether to render *at all*, and a panel that
// hides itself cannot tell its host that.
this.unsubscribe = jobStore.subscribe(this.onJobs);
this.onJobs();
void jobStore.init();
}
override disconnectedCallback(): void {
super.disconnectedCallback();
this.unsubscribe?.();
this.media?.removeEventListener('change', this.onMedia);
}
override render() {
// `hidden` rather than an empty render, so the grid row this
// sits in costs nothing at all while there is no work -- the
// rule `job-panel` already follows one layer down.
this.hidden = !(this.phone && this.active > 0);
if (this.hidden) return nothing;
return html`
<job-panel kinds="*" density="compact" active-only></job-panel>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'job-band': JobBand;
}
}
+41 -3
View File
@@ -51,6 +51,14 @@ export class JobPanel extends LitElement {
* literal in a template, and one of them is inside an HTMX-adjacent
* settings page where a property binding would be one more thing to
* remember.
*
* **`*` means every kind**, which is the phone's band (#62) and
* nothing else: there, this panel is standing in for the header
* indicator, whose whole job was to be the one view of everything
* at once. It is spelled `*` rather than taken as the meaning of an
* empty attribute, because empty is what a typo and a missing
* binding both produce and "show everything" is the wrong thing to
* do by accident. Empty still shows nothing.
*/
@property({ type: String })
kinds = '';
@@ -59,6 +67,31 @@ export class JobPanel extends LitElement {
@property({ type: String })
heading = '';
/**
* Row density, passed to `job-row`.
*
* `full` adds elapsed time and per-job statistics and is what a
* settings section wants, so it stays the default and the four
* existing call sites are unchanged. `compact` is what `job-row`
* itself calls "the popover density", and it is what the phone's
* band uses (#62) — there this panel *is* the popover, on a screen
* 439 CSS px tall, and the full density spent 259 of them.
*/
@property({ type: String })
density: 'compact' | 'full' = 'full';
/**
* Drop finished rows.
*
* For the phone's band (#62), which is *in the layout*: a finished
* row there is a banner that stays after the work is done and keeps
* the content pushed down. Settings keeps them, because that is
* where "did the last scan work" is asked, and a finished row there
* dismisses itself.
*/
@property({ type: Boolean, attribute: 'active-only' })
activeOnly = false;
@state()
private jobs: Job[] = [];
@@ -162,9 +195,14 @@ export class JobPanel extends LitElement {
}
private get mine(): Job[] {
const wanted = this.wanted;
const ofKind =
this.kinds.trim() === '*'
? this.jobs
: this.jobs.filter((job) =>
this.wanted.has(job.kind as JobKind),
);
return this.jobs.filter((job) => wanted.has(job.kind as JobKind));
return this.activeOnly ? ofKind.filter((job) => !isTerminal(job)) : ofKind;
}
private openDetails(id: string) {
@@ -196,7 +234,7 @@ export class JobPanel extends LitElement {
<div class="job-entry">
<job-row
.job=${job}
variant="full"
variant=${this.density}
@job-control=${applyJobControl}
></job-row>
<button
@@ -11,6 +11,7 @@ import {
contextMenuStyles,
} from '../../utils/context-menu-controller';
import { ICON_MORE_ACTIONS } from '../../utils/icon-language';
import '../search-dialog/search-trigger';
/**
* The one arrangement every primary view uses to say what it is.
@@ -161,6 +162,15 @@ export class PageHeader extends LitElement {
@state()
private collapsed: ReadonlySet<string> = new Set();
/**
* Whether the count has been given up. Derived, like `collapsed`.
*
* It is the last thing to yield and the only thing here that is
* neither an identity nor an action — see `measureFit`.
*/
@state()
private countCollapsed = false;
@state()
private menuOpen = false;
@@ -457,6 +467,20 @@ export class PageHeader extends LitElement {
${this.renderCount()}
<div class="spacer"></div>
${this.renderScope()} ${this.renderSort()}
<!-- #57. Below 600px the top bar is out of the layout,
so the search box has to be reachable from here.
It renders nothing at every other width and on
every view search-store says has nothing to
search, which is why no host declares it: the map
of searchable views already exists and this is one
more reader of it, not a second copy.
Before the actions, and never one of them: an
action can collapse into the overflow menu, and on
a phone that menu is already where the page's own
actions live -- search behind an ellipsis is the
top bar's problem moved rather than fixed. -->
<search-trigger></search-trigger>
${this.renderActions()}
<slot name="actions"></slot>
</header>
@@ -516,12 +540,6 @@ export class PageHeader extends LitElement {
if (!header) return;
if (this.actions.length === 0) {
this.commitCollapsed(new Set());
return;
}
const buttons = new Map<string, HTMLElement>();
for (const el of this.renderRoot.querySelectorAll<HTMLElement>(
@@ -534,6 +552,7 @@ export class PageHeader extends LitElement {
const more = this.moreButton;
const title = this.renderRoot.querySelector('h1');
const count = this.renderRoot.querySelector<HTMLElement>('.count');
/**
* Nothing is clipped — which is not the same as the header not
@@ -555,6 +574,8 @@ export class PageHeader extends LitElement {
if (more) more.hidden = true;
if (count) count.hidden = false;
const collapsed = new Set<string>();
if (!fits()) {
@@ -571,7 +592,42 @@ export class PageHeader extends LitElement {
}
}
this.commitCollapsed(collapsed);
this.commitCollapsed(collapsed, this.collapseCount(count, fits));
}
/**
* The last thing to give way, after every action is in the menu and
* the title has already run out.
*
* There are four things competing for this row and three of them
* cannot go. The **title** yields first and is allowed to ellipsis
* away entirely at 320px, because the navigation also says which
* page you are on. The **sort** control and the **actions** are
* each the only place they are said, so an action collapses into
* the menu rather than disappearing and the sort control stays.
* That leaves the **count**, which is the one purely informational
* item on the row — 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: below 600px
* the header also carries the phone's search button, and on
* Playlists at 320px that is 43px more than the row has. Measured
* there: 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. Something has to go, and this
* is the only candidate that is not an action.
*
* @returns whether the count was given up.
*/
private collapseCount(
count: HTMLElement | null,
fits: () => boolean,
): boolean {
if (count === null || fits()) return false;
count.hidden = true;
return true;
}
/** Lowest priority first; ties broken from the right. */
@@ -586,7 +642,9 @@ export class PageHeader extends LitElement {
.map(({ action }) => action);
}
private commitCollapsed(next: Set<string>): void {
private commitCollapsed(next: Set<string>, countHidden: boolean): void {
this.countCollapsed = countHidden;
const same =
next.size === this.collapsed.size &&
[...next].every((id) => this.collapsed.has(id));
@@ -754,7 +812,16 @@ export class PageHeader extends LitElement {
const noun = this.count === 1 ? this.countNoun : plural;
return html`<span class="count" data-testid="page-count"
// Rendered whether or not it fits, and hidden with an
// attribute -- the same shape the action buttons use, and for
// the same reason: `measureFit` starts every pass from
// all-visible, so it needs a node to un-hide. Returning
// `nothing` here would take the count away for the rest of the
// session the first time a 320px window appeared.
return html`<span
class="count"
data-testid="page-count"
?hidden=${this.countCollapsed}
>${this.count.toLocaleString()} ${noun}</span
>`;
}
@@ -27,6 +27,7 @@ import { queueStore } from '@store/queue-store';
import { creditStore } from '@store/credit-store';
import { PlayerController } from '@store/controllers/player-controller';
import { SearchController } from '@store/controllers/search-controller';
import '../search-dialog/search-trigger';
import { SelectionController } from '@utils/selection-controller';
import type { SelectionHost } from '@utils/selection-controller';
import {
@@ -1039,6 +1040,16 @@ export class PlaylistDetails
min-width: 0;
}
/* #57. This view is in search-store's map and filters on the
term, but it is a detail view and so has no page-header to
carry the phone's search button. Pushed to the end of the
header row, which is where page-header puts it too. */
.header-end {
margin-left: auto;
display: flex;
align-items: center;
}
.playlist-title {
font-size: 24px;
font-weight: 700;
@@ -1383,6 +1394,9 @@ export class PlaylistDetails
`
: ''}
</div>
<div class="header-end">
<search-trigger></search-trigger>
</div>
</div>
${searchBar}
<div
@@ -62,7 +62,10 @@ export class SearchBar extends LitElement {
gap: 8px;
height: 32px;
min-width: 200px;
max-width: 360px;
/* A cap for a header, not for the box. search-dialog gives
it the whole of a modal, where 360px of a 424px screen
would read as a control that failed to size itself. */
max-width: var(--yj-search-max-width, 360px);
width: 100%;
transition: border-color 0.15s ease;
}
@@ -0,0 +1,210 @@
/**
* The phone's search surface (#57).
*
* Below 600px there is no top bar to hold a search box the bar is out
* of the layout entirely, which is the single biggest vertical win
* available on a 439 CSS px viewport. So the box moves into a modal and
* the *trigger* moves into the row that already says which page you are
* on (`search-trigger`, beside this file).
*
* **It is a `wa-dialog`, and that is a mechanism rather than a taste.**
* #60 read this out of the Web Awesome source: `wa-popup` renders
* `<div popover="manual">` and feature-detects the Popover API, falling
* back to `strategy: "fixed"` where there is none which is the
* reference device, Chrome 113, since `popover` is Chrome 114. And
* `position: fixed` escapes ancestor *overflow* but not `contain:
* paint`, which makes an element a containing block for fixed
* descendants **and clips them**; `index.css` puts `contain: layout
* style paint` on `.main-panel`, which is the ancestor of every view.
* A popup-shaped search panel opened from a view's header would
* therefore be structurally clipped on the one device this issue is
* about, and **no tier here could see it** CI's Chromium and WebKit
* both have the Popover API, so the popup is top-layered and correct.
* `<dialog>`/`showModal()` is Chrome 37 and uses the real top layer, so
* this is immune by construction.
*
* **It carries the real `<search-bar>`**, not a second input. That is
* what keeps one debounce, one clear button, one accessible name and
* one view-scoped placeholder and it is why `store/search-store.ts`
* is still the only statement of which views can search and what they
* search. The modal is a presentation of the control, not a copy of it.
*
* **The results are the view, not a list in here.** The Direction says
* "the box and live results"; the live results already exist, because
* the term is view-scoped and the page behind this dialog filters on it
* and says so in `page-header`'s "Showing albums matching …" line.
* Rendering results in the dialog would be a second implementation of
* every view's own filtering, and a worse one it could not offer the
* row actions the view does. So Enter closes and hands the screen back.
*
* A singleton in `index.html` for the reason `shortcuts-overlay` is:
* one instance, one `data-testid`, one document listener, and no
* `data-testid="search-input"` resolving to two elements while it is
* shut.
*/
import { LitElement, css, html, nothing } from 'lit';
import { customElement, query, state } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/dialog/dialog.js';
import { designTokens } from '../../styles/tokens.css';
import { nameDialogsIn } from '@utils/name-dialog';
import { SearchController } from '@store/controllers/search-controller';
import type { SearchBar } from '../search-bar/search-bar';
import '../search-bar/search-bar';
/** The event any trigger dispatches to open this. */
export const OPEN_SEARCH_EVENT = 'open-search';
@customElement('search-dialog')
export class SearchDialog extends LitElement {
private searchCtrl = new SearchController(this);
@query('wa-dialog') private dialog?: HTMLElement & { open: boolean };
@query('search-bar') private bar?: SearchBar;
@state() private isOpen = false;
static override styles = [
designTokens,
css`
:host {
display: contents;
}
wa-dialog::part(dialog) {
background: var(--yj-bg-surface, #212529);
color: var(--yj-text-primary, #fff);
}
/* The box is the whole content, so it gets the whole width
rather than the 360px cap it wears in a header. */
search-bar {
display: block;
width: 100%;
--yj-search-max-width: none;
}
.hint {
margin: 0.75em 0 0;
font-size: var(--yj-text-sm, 0.8125rem);
color: var(--yj-text-secondary, #b3b3b3);
}
`,
];
override connectedCallback(): void {
super.connectedCallback();
document.addEventListener(OPEN_SEARCH_EVENT, this.open);
// Capture, on the host: the path runs document -> host ->
// shadow root -> the input inside `search-bar`, so a capture
// listener here is the only one that gets the key *before* the
// input's own handler. A `@keydown` in the template is a
// bubbling listener and would run after the term was cleared,
// and there is nowhere to put a `firstUpdated` hook -- the
// first render of this element produces no content at all.
this.addEventListener('keydown', this.onKeydown, true);
}
override disconnectedCallback(): void {
super.disconnectedCallback();
document.removeEventListener(OPEN_SEARCH_EVENT, this.open);
this.removeEventListener('keydown', this.onKeydown, true);
}
/**
* Not a toggle, for `shortcuts-overlay`'s reason: a dialog owns
* every unmodified key while it is up, so a second press of the
* shortcut that opened it never reaches the shortcut service.
*/
private open = (): void => {
if (this.isOpen) return;
// Nothing to search here is not an error; it is the state the
// trigger already declines to render in. Guarding here too is
// what makes the keyboard route (Ctrl+F on a phone) agree with
// the button.
if (!this.searchCtrl.isSearchableView) return;
this.isOpen = true;
void this.updateComplete.then(() => {
if (this.dialog) this.dialog.open = true;
// `wa-dialog` positions and shows in its own update, and
// `search-bar` populates its own shadow root in one more —
// the same lifecycle trap `name-dialog.ts` documents. One
// more frame, and the box has an input to focus.
requestAnimationFrame(() => this.bar?.focusInput());
});
};
private close(): void {
if (this.dialog) this.dialog.open = false;
this.isOpen = false;
}
/**
* Escape closes and **keeps the term**; Enter closes and shows the
* results.
*
* Escape is the one worth stating. `search-bar`'s input treats it
* as *clear the search*, which is right in a header the box is on
* screen either way, so clearing is the only thing left for the key
* to mean. Here it would make dismissing the search surface
* silently discard the search, and discarding is what the clear
* button inside it is for. So this runs first and closes; the term
* survives, and the page behind is still filtered by it.
*/
private onKeydown = (e: KeyboardEvent): void => {
if (!this.isOpen) return;
if (e.key === 'Escape') {
e.stopPropagation();
this.close();
return;
}
if (e.key === 'Enter') {
e.stopPropagation();
e.preventDefault();
this.close();
}
};
/**
* Web Awesome renders `label` into a heading it never points the
* `<dialog>` at. See `utils/name-dialog.ts`.
*/
override updated(): void {
nameDialogsIn(this.shadowRoot);
}
override render() {
if (!this.isOpen) return nothing;
const scope = this.searchCtrl.scopeLabel;
return html`
<wa-dialog
label=${`Search ${scope}`}
data-testid="search-dialog"
@wa-hide=${() => this.close()}
>
<search-bar></search-bar>
<p class="hint">
Results appear on the page behind this. Press Enter
or close to see them.
</p>
</wa-dialog>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'search-dialog': SearchDialog;
}
}
@@ -0,0 +1,147 @@
/**
* The phone's way into search (#57): one button, in the row that
* already says which page you are on.
*
* **Which views show it is not a decision this component makes.**
* `store/search-store.ts` has held the map of what each view searches
* since plan 007, and #57's own Findings say so "that is exactly the
* condition for showing the button". So this asks `isSearchableView`
* and renders nothing otherwise, and no second list of searchable views
* exists to fall out of step with the first.
*
* **It is an element rather than a `PageAction`**, and that is the
* whole reason it is a component at all. Two of the seven searchable
* views `playlist-details` and `smart-playlist-details` have no
* `page-header`; they filter on the term and say so in their own
* headers. Declaring search as an action would mean seven hosts each
* writing it out, which is the second list again, and it would put a
* *phone mode for actions* inside `page-header`, which that component
* documents its refusal to grow. An element three headers place is one
* statement of the rule, placed three times.
*
* It does not participate in `page-header`'s overflow measurement, for
* the reason the count and the sort control do not: it is 32px, it is
* `flex-shrink: 0`, and the header's `fits()` sees its width like any
* other child. What it must never do is collapse into the overflow
* menu on a phone that menu is the only home for the page's actions
* already, and search would be two taps behind an ellipsis.
*/
import { LitElement, css, html, nothing } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import { designTokens } from '../../styles/tokens.css';
import { PHONE_QUERY } from '@utils/breakpoints';
import { SearchController } from '@store/controllers/search-controller';
import { ICON_SEARCH } from '@utils/icon-language';
import { OPEN_SEARCH_EVENT } from './search-dialog';
@customElement('search-trigger')
export class SearchTrigger extends LitElement {
private searchCtrl = new SearchController(this);
/**
* From `matchMedia` rather than a media query, because this decides
* whether the button *exists* `job-band`'s rule, and for the same
* consequence: a header that renders it at every width puts a
* second search affordance beside the desktop's own box.
*/
@state() private phone = false;
private media?: MediaQueryList;
static override styles = [
designTokens,
css`
:host {
display: contents;
}
button {
display: inline-flex;
align-items: center;
justify-content: center;
/* The smallest a touch target should be. The header's
own action buttons are smaller because they carry a
label; this one is a glyph. */
min-width: 40px;
min-height: 40px;
padding: 0;
background: none;
border: 1px solid var(--yj-border-subtle, #555);
border-radius: 4px;
color: var(--yj-text-primary, #fff);
cursor: pointer;
flex-shrink: 0;
}
button:focus-visible {
outline: 2px solid var(--yj-accent, #ffd43b);
outline-offset: -1px;
}
/* A search that is *on* says so without a second control:
the page already carries "Showing albums matching ...",
and this is the button that reopens the box to change or
clear it. */
button.filtering {
border-color: var(--yj-accent, #ffd43b);
color: var(--yj-accent-text, #ffd43b);
}
`,
];
override connectedCallback(): void {
super.connectedCallback();
this.media = window.matchMedia(PHONE_QUERY);
this.phone = this.media.matches;
this.media.addEventListener('change', this.onMedia);
}
override disconnectedCallback(): void {
super.disconnectedCallback();
this.media?.removeEventListener('change', this.onMedia);
}
private onMedia = (e: MediaQueryListEvent): void => {
this.phone = e.matches;
};
private onClick = (): void => {
document.dispatchEvent(new CustomEvent(OPEN_SEARCH_EVENT));
};
override render() {
if (!this.phone || !this.searchCtrl.isSearchableView) return nothing;
const scope = this.searchCtrl.scopeLabel;
const term = this.searchCtrl.term;
// The name carries the state, because the colour cannot: a
// control that is a different colour and the same word is a
// control that says nothing to anyone not seeing it. Same rule
// `library-status.ts` states for a partial badge.
const label = term
? `Search ${scope}, showing matches for ${term}`
: `Search ${scope}`;
return html`
<button
data-testid="search-trigger"
class=${term ? 'filtering' : ''}
aria-label=${label}
title=${label}
@click=${this.onClick}
>
<wa-icon name=${ICON_SEARCH}></wa-icon>
</button>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'search-trigger': SearchTrigger;
}
}
@@ -18,6 +18,7 @@ import { queueStore } from '@store/queue-store';
import { creditStore } from '@store/credit-store';
import { PlayerController } from '@store/controllers/player-controller';
import { SearchController } from '@store/controllers/search-controller';
import '../search-dialog/search-trigger';
import { SelectionController } from '@utils/selection-controller';
import type { SelectionHost } from '@utils/selection-controller';
import {
@@ -359,6 +360,15 @@ export class SmartPlaylistDetails
flex-shrink: 0;
}
/* #57. Like playlist-details, this view filters on the search
term and has no page-header to carry the phone's search
button, so the action row does. */
.actions-end {
margin-left: auto;
display: flex;
align-items: center;
}
.action-button {
background: none;
border: 1px solid var(--yj-border-subtle, #555);
@@ -1300,6 +1310,9 @@ export class SmartPlaylistDetails
Edit Rules
</button>
`}
<div class="actions-end">
<search-trigger></search-trigger>
</div>
</div>
${this.editing
? html`
@@ -16,6 +16,7 @@ import { playerStore } from '@store/player-store';
import { queueStore } from '@store/queue-store';
import * as Player from '@go/player/player.js';
import type { SearchBar } from '@components/search-bar/search-bar';
import { OPEN_SEARCH_EVENT } from '@components/search-dialog/search-dialog';
// ===================================================================
// KEY STRING UTILITIES
@@ -387,14 +388,24 @@ async function dispatch(action: string): Promise<void> {
break;
// Navigation
// The key has one meaning -- *let me search this page* -- and
// two surfaces since #57. The header box is gone below 600px,
// so scoping the query to the bar is not tidiness: an unscoped
// `search-bar` also matches the one inside `search-dialog`
// while that is open, and would focus a box the user is
// already typing in while leaving the phone with nothing at
// all. The dialog declines to open on a view with nothing to
// search, which is the same condition the trigger renders on.
case 'nav.search':
case 'nav.searchAlt': {
const bar = document.querySelector(
'search-bar',
'header.top-bar search-bar',
) as SearchBar | null;
if (bar && !bar.hasAttribute('hidden')) {
if (bar && bar.checkVisibility()) {
bar.focusInput();
} else {
document.dispatchEvent(new CustomEvent(OPEN_SEARCH_EVENT));
}
break;
+10
View File
@@ -119,6 +119,16 @@ export const FIT_STEPS: readonly FitStep[] = [
* @returns the ids collapsed, in the order they were given up.
*/
export function measureTopBarFit(bar: HTMLElement): string[] {
// Below 600px there is no bar to fit (#57): `index.css` takes it
// out of the grid and leaves it visually hidden at 1px, carrying
// nothing but the document's `h1`. Measuring that reports the
// wordmark as overflowing 1px of content box and collapses it every
// time -- true, and about nothing, since the whole bar is already
// invisible. Asking the *computed position* rather than the
// viewport width is what keeps this file free of a breakpoint the
// stylesheet already owns.
if (getComputedStyle(bar).position === 'absolute') return [];
const fits = () => {
const style = getComputedStyle(bar);
const box = bar.getBoundingClientRect();
+13
View File
@@ -124,6 +124,19 @@ export const ICON_DOWNLOADING = 'download';
*/
export const ICON_MORE_ACTIONS = 'ellipsis';
/**
* Look for something.
*
* Deliberately **not** governed by the sweep in
* `icon-language.test.ts`: `magnifying-glass` has only ever meant this,
* in the header box and in Explore's own catalog search alike, so
* governing it would force a rename on two call sites that are already
* right. It is written down because #57 gave the meaning a *button* as
* well as a box, and a second surface for the same verb is exactly the
* point at which two spellings start.
*/
export const ICON_SEARCH = 'magnifying-glass';
/**
* Take this away.
*
@@ -76,6 +76,68 @@ describe('<job-panel>', () => {
expect(titles(el)).toEqual(['Building the index', 'Filling in artists']);
});
/**
* #62. The phone's band has no kinds to name: it is standing in for
* the header indicator, whose whole job was to be the one view of
* everything at once.
*/
it('answers for every kind when asked with a star', async () => {
const el = await fixture<LitElement>('job-panel', { kinds: '*' });
await snapshot([
job({ id: 'scan:1', kind: 'library-scan', title: 'Scanning Music' }),
job({ id: 'idx', kind: 'index-build', title: 'Building the index' }),
job({ id: 'dl:1', kind: 'download', title: 'Downloading Glass Harbour' }),
]);
await el.updateComplete;
expect(titles(el)).toEqual([
'Scanning Music',
'Building the index',
'Downloading Glass Harbour',
]);
});
/**
* The other half of that, and the reason it is a star rather than the
* meaning of an empty attribute: empty is what a typo and a dropped
* binding both produce, and "show everything" is the wrong thing to
* do by accident.
*/
it('still shows nothing when asked for nothing', async () => {
const el = await fixture<LitElement>('job-panel', { kinds: '' });
await snapshot([job({ id: 'scan:1', kind: 'library-scan' })]);
await el.updateComplete;
expect([el.hidden, rows(el)].map(String)).toEqual(['true', '']);
});
/**
* `full` stays the default so the four settings call sites are
* untouched; the band asks for the density `job-row` calls "the
* popover density", because on the phone this panel *is* the popover.
*/
it('passes its density to the rows, defaulting to full', async () => {
const settings = await fixture<LitElement>('job-panel', { kinds: '*' });
await snapshot([job()]);
await settings.updateComplete;
const band = await fixture<LitElement>('job-panel', {
kinds: '*',
density: 'compact',
});
await snapshot([job()]);
await band.updateComplete;
expect([
rows(settings)[0]?.getAttribute('variant'),
rows(band)[0]?.getAttribute('variant'),
]).toEqual(['full', 'compact']);
});
/**
* An idle panel in four places is four pieces of furniture describing
* an absence and `hidden` rather than an empty render, because the
@@ -0,0 +1,248 @@
/**
* The phone's search surface (#57).
*
* Two things are asserted here that the e2e tier cannot reach, and one
* that it deliberately must not be trusted with.
*
* **Which views show the trigger is `search-store`'s answer**, so this
* walks the map rather than sampling a view: the fault the issue guards
* against is a second list of searchable views, and a spec that checks
* Albums checks nothing about Playlists.
*
* **The dialog is a `<dialog>`, not a popup.** #60 established from the
* Web Awesome source that `wa-popup` falls back to `position: fixed`
* without the Popover API Chrome 113, the reference device and that
* `.main-panel`'s `contain: paint` clips a fixed descendant. Every tier
* available here has the Popover API, so a popup renders perfectly in
* CI and is clipped on the device: **an assertion that the surface is
* not clipped passes on the broken build.** So the assertion is the
* *mechanism* a real `<dialog>` in the tree which is the one form
* of this that a browser here can answer honestly.
*
* The breakpoint is stubbed rather than emulated, for the reason
* `now-playing-phone.test.ts` gives: the runner's viewport is fixed at
* 1280x800, and the component reads `matchMedia` in `connectedCallback`
* precisely so a test can answer it first.
*/
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
import '@components/search-dialog/search-dialog';
import '@components/search-dialog/search-trigger';
import { searchStore } from '@store/search-store';
import { fixture, shadow, deepShadow } from '@test/support/render';
import { flush } from '@test/support/harness';
/** Views the store says can be searched, and what they search. */
const SEARCHABLE: [string, string][] = [
['tracks', 'tracks'],
['albums', 'albums'],
['artists', 'artists'],
['genres', 'genres'],
['playlists', 'playlists'],
['playlist-details', 'tracks in this playlist'],
['smart-playlist-details', 'tracks in this smart playlist'],
];
/** Views with nothing of their own to search, or a search of their own. */
const UNSEARCHABLE = ['home', 'explore', 'settings', 'downloads', 'autotag'];
let restoreMedia: (() => void) | null = null;
/** Answer the shell's phone query with `phone` until restored. */
function stubPhone(phone: boolean): void {
const real = window.matchMedia.bind(window);
window.matchMedia = ((q: string) =>
q.includes('max-width: 599px')
? {
matches: phone,
media: q,
addEventListener() {},
removeEventListener() {},
}
: real(q)) as typeof window.matchMedia;
restoreMedia = () => {
window.matchMedia = real;
};
}
beforeEach(() => {
searchStore.setTerm('');
searchStore.setCurrentView('tracks');
});
afterEach(() => {
restoreMedia?.();
restoreMedia = null;
searchStore.setTerm('');
searchStore.setCurrentView('tracks');
});
describe('<search-trigger>', () => {
it('is offered on every view the store says can be searched', async () => {
stubPhone(true);
// One element, walked across the views: the trigger reads the store
// on every render, so remounting per view would test mounting
// rather than the condition.
const el = await fixture('search-trigger');
for (const [view] of SEARCHABLE) {
searchStore.setCurrentView(view);
await el.updateComplete;
expect(
shadow(el, '[data-testid="search-trigger"]'),
`no trigger on ${view}`,
).not.toBeNull();
}
});
it('names what the button will search', async () => {
stubPhone(true);
const el = await fixture('search-trigger');
for (const [view, scope] of SEARCHABLE) {
searchStore.setCurrentView(view);
await el.updateComplete;
expect(
shadow(el, '[data-testid="search-trigger"]')?.getAttribute(
'aria-label',
),
).toBe(`Search ${scope}`);
}
});
it('is absent where there is nothing to search', async () => {
stubPhone(true);
const el = await fixture('search-trigger');
for (const view of UNSEARCHABLE) {
searchStore.setCurrentView(view);
await el.updateComplete;
expect(
shadow(el, '[data-testid="search-trigger"]'),
`a trigger appeared on ${view}`,
).toBeNull();
}
});
it('is absent above the phone breakpoint, where the header has a box', async () => {
stubPhone(false);
const el = await fixture('search-trigger');
expect(shadow(el, '[data-testid="search-trigger"]')).toBeNull();
});
/**
* A colour is not a signal on its own. The button is the only thing
* on screen that reopens a filtered search, so the state it is in has
* to reach someone who cannot see the accent border.
*/
it('says in its name that a search is applied', async () => {
stubPhone(true);
const el = await fixture('search-trigger');
searchStore.setTerm('aurora');
await el.updateComplete;
const button = shadow(el, '[data-testid="search-trigger"]');
expect(button?.getAttribute('aria-label')).toContain('aurora');
expect(button?.className).toContain('filtering');
});
});
describe('<search-dialog>', () => {
it('opens on the event the trigger dispatches, as a real dialog', async () => {
stubPhone(true);
const el = await fixture('search-dialog');
const trigger = await fixture('search-trigger');
shadow<HTMLElement>(trigger, '[data-testid="search-trigger"]')?.click();
await flush();
await el.updateComplete;
expect(shadow(el, '[data-testid="search-dialog"]')).not.toBeNull();
// The mechanism, not the appearance: a native <dialog> is what
// reaches the top layer on Chrome 113, and a wa-popup would look
// identical in this browser while being clipped on the device.
expect(deepShadow(el, 'dialog')).not.toBeNull();
});
/**
* It carries the real box rather than a second input, which is what
* keeps one debounce, one clear button and one view-scoped
* placeholder and what keeps `search-store` the only statement of
* what a view searches.
*/
it('carries the header search box itself', async () => {
const el = await fixture('search-dialog');
document.dispatchEvent(new CustomEvent('open-search'));
await flush();
await el.updateComplete;
expect(shadow(el, 'search-bar')).not.toBeNull();
});
/**
* The one place the shortcut route and the button could disagree.
* Ctrl+F on a view with nothing to search dispatches the same event
* the button would, and the button is not there to be pressed.
*/
it('declines to open where there is nothing to search', async () => {
const el = await fixture('search-dialog');
searchStore.setCurrentView('home');
document.dispatchEvent(new CustomEvent('open-search'));
await flush();
await el.updateComplete;
expect(shadow(el, '[data-testid="search-dialog"]')).toBeNull();
});
/**
* 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 stays on screen either way. Here
* it would mean dismissing the surface silently discarded the search,
* and the page behind would refill without being asked to.
*/
it('keeps the search when it is dismissed', async () => {
const el = await fixture('search-dialog');
document.dispatchEvent(new CustomEvent('open-search'));
await flush();
await el.updateComplete;
searchStore.setTerm('aurora');
const input = deepShadow<HTMLInputElement>(el, 'input');
expect(input).not.toBeNull();
input!.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Escape',
bubbles: true,
composed: true,
}),
);
await flush();
await el.updateComplete;
expect(searchStore.getTerm()).toBe('aurora');
expect(shadow(el, '[data-testid="search-dialog"]')).toBeNull();
});
});
+105
View File
@@ -397,6 +397,111 @@ describe('<seek-bar>', () => {
expect(lastArgs('player.Player.Seek')).toEqual([42]);
});
// #164. `handleInput` used to call `stopProgress()` and mutate no
// reactive state, so Lit scheduled no update, `updated()` never ran,
// and the tail of `updated()` that restarts the interval never
// executed. Only a `change` or the next backend report could bring
// it back -- so an `input` that never commits froze the clock, which
// on a touch device is an ordinary cancelled gesture. With no
// reports arriving, that is permanent.
it('keeps ticking after a drag that never commits', async () => {
vi.useFakeTimers();
const el = await fixture('seek-bar');
emit(Events.TrackChanged, TRACK);
emit(Events.PlaybackStateChanged, { state: 'playing' });
await vi.advanceTimersByTimeAsync(2000);
await el.updateComplete;
// A touch lands on the track and is then cancelled: `input`, and
// no `change` ever follows.
const slider = shadow<HTMLElement & { value: number }>(el, 'wa-slider');
if (slider) slider.value = 20;
slider?.dispatchEvent(new Event('input'));
await el.updateComplete;
document.dispatchEvent(new Event('pointerup'));
await vi.advanceTimersByTimeAsync(0);
await el.updateComplete;
await vi.advanceTimersByTimeAsync(3000);
await el.updateComplete;
expect(text(el, '[data-testid="elapsed-time"]')).toBe('00:23');
});
// The other half of the same fix: while the thumb is held, a report
// arriving once a second used to overwrite `seekValue` and pull it
// back out from under the finger.
it('leaves the thumb where the finger is while a drag is live', async () => {
vi.useFakeTimers();
const el = await fixture('seek-bar');
emit(Events.TrackChanged, { ...TRACK, trackChangeId: 20 });
emit(Events.PlaybackStateChanged, { state: 'playing' });
await vi.advanceTimersByTimeAsync(0);
await el.updateComplete;
const slider = shadow<HTMLElement & { value: number }>(el, 'wa-slider');
if (slider) slider.value = 60;
slider?.dispatchEvent(new Event('input'));
await el.updateComplete;
emit(Events.PlaybackPositionChanged, {
positionSeconds: 4,
trackLength: 90,
trackChangeId: 20,
seq: 7,
playing: true,
});
await vi.advanceTimersByTimeAsync(0);
await el.updateComplete;
expect(text(el, '[data-testid="elapsed-time"]')).toBe('01:00');
});
// And the drag must not hold the interval hostage once it ends: the
// report that was skipped mid-drag is not recorded as seen, so the
// next one is still fresh and is applied.
it('takes the backend back as the authority once the drag commits', async () => {
vi.useFakeTimers();
const el = await fixture('seek-bar');
emit(Events.TrackChanged, { ...TRACK, trackChangeId: 21 });
emit(Events.PlaybackStateChanged, { state: 'playing' });
await vi.advanceTimersByTimeAsync(0);
await el.updateComplete;
const slider = shadow<HTMLElement & { value: number }>(el, 'wa-slider');
if (slider) slider.value = 60;
slider?.dispatchEvent(new Event('input'));
await el.updateComplete;
slider?.dispatchEvent(new Event('change'));
await el.updateComplete;
emit(Events.PlaybackPositionChanged, {
positionSeconds: 61,
trackLength: 90,
trackChangeId: 21,
seq: 9,
playing: true,
});
await vi.advanceTimersByTimeAsync(0);
await el.updateComplete;
expect(text(el, '[data-testid="elapsed-time"]')).toBe('01:01');
});
it('bounds the slider by the track length', async () => {
const el = await fixture('seek-bar');