Compare commits

..
Author SHA1 Message Date
logan 70ab3ddf94 docs: record two measurements from the queue selection work
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m41s
CI / e2e (pull_request) Successful in 8m1s
The first is a second instance of a rule CLAUDE.md already states, with
numbers: a virtualized list can be repainting for a reason you are about
to delete, and here there are two such reasons — so removing either
alone changes nothing observable, and removing both leaves the highlight
seconds late rather than absent. That is the shape a poll cannot see,
which is the general lesson worth keeping.

The second is the hit-scan, because it stopped a wrong fix: the queue
panel is 12% link and the track list 21%, which is the opposite of the
assumption the fix was being built on.
2026-08-19 23:00:03 -04:00
logan 4f7529c315 test(queue): pin the panel's mouse model, and bound the highlight
Single click selects, ctrl and shift extend, double click plays from
that row — all four already worked, and nothing in either tier pinned
any of them, which is why the report could be made and could not be
settled. `queue-reorder.spec.ts` covers the keyboard and
`queue-overlay.spec.ts` the panel's mode; the pointer path had no
coverage at all, so "selection is broken here" and "selection is fine
here" were equally consistent with a green suite.

Measured with real mouse events rather than dispatched ones, because a
synthetic click aimed at the row bypasses the only thing that could be
swallowing it: click row 1 selects 1, ctrl+click 4 gives 1 and 4,
shift+click 7 extends to 1,4,5,6,7, a plain click collapses to one, and
a double click on row 3 leaves the backend playing row 3.

The three candidates the issue lists are all answered. The repaint was
already correct, and already correct on the day the issue was filed.
`resolveTrackIndexFromEvent` reads data-index, and DOM order matches
data order. A row control does swallow the click — `explore-link` stops
propagation on purpose, so a click on a name navigates and selects
nothing — but a hit-scan across a row makes the queue 12% link against
the track list's 21%, so the panel called broken is *less* covered by
links than the list called correct. That measurement killed the fix
this started out as.

Two traps are written into the spec because both faked a defect while
measuring. Fixture tracks are 2 seconds, so "double click row 3" read a
moment later reports whatever auto-advance moved on to — recorded twice
as an off-by-one that is not one, which is what `LONG_TRACK` exists
for. And the selection assertions are bounded at 500ms rather than
polled with the default 5s: `queue-panel` repaints two ways, the
explicit `requestUpdate()` and a per-render `keyFunction` arrow, and
with *both* removed the highlight still arrives — at 134ms, 3.9s and
5.8s against 5-17ms healthy. Four seconds is indistinguishable from
broken to a user and invisible to a generous poll.

Mutation-tested rather than trusted: `playAtIndex(index + 1)` fails both
double-click tests, treating every click as ctrl+click fails both
selection tests, and removing both repaint mechanisms fails all three
selection tests — the last only because of the bound.

Closes #43
2026-08-19 22:59:55 -04:00
logan bb21072386 Merge pull request 'The top bar decides what it can afford to show' (#149) from fix/143-top-bar-fits-its-window into main
CI / check (push) Successful in 2m34s
CI / e2e (push) Successful in 7m59s
Fits the top bar to its window by measuring it, at every supported
width and with work in flight.

Closes #143
2026-08-20 02:11:54 +00:00
logan ead1354e4d docs: record how the top bar decides what to drop
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m35s
CI / e2e (pull_request) Successful in 8m38s
The shell section already states the three size bands and the promise
that no action is unreachable at any of them; how the header chooses
what to give up belongs beside them, because the promise is what
decides it.

Two measured facts go to NOTES.md rather than here. `scrollWidth`
counts a box's left padding and not its right, so the obvious fit
predicate under-reports by a gutter and passed on a bar with a control
jammed against the window edge. And the overflow is 11px idle and 262px
while working, which is why the issue was filed twice with different
numbers — a seeded app that has finished scanning is idle by the time
you resize it.

Closes #143
2026-08-19 21:35:12 -04:00
logan ae85df0dad fix(shell): give the top bar a measured fit at every width
The bar was 611px inside a 600px viewport at the bottom of the Compact
band, and 862px while a scan with a real library's title ran, because
`job-indicator` is `hidden` when idle and 235px wide when it is not.
`body` is `overflow-x: auto`, so a user got a horizontal scrollbar on a
shell #24 promised would not need one — and the band is 600 to 899 with
work in flight, not the 600 to 610 the idle measurement suggested.

`services/top-bar-fit.ts` is `page-header`'s treatment one bar up: a
ResizeObserver, every pass starting from all-visible, hiding the
lowest-priority child until it fits. Measured rather than breakpointed
because three of the five children are as wide as their content — the
library filter by the longest library name, the indicator by the
running job's title, the search box by its view-scoped placeholder — so
any width picked is right for one library, one job and one view.

What yields is decided by #24's own sentence, which rules out the two
cheapest candidates in the Direction. Hiding the library filter takes
away an action, since it is the only control in the app that selects a
library (filed as #148, which is the phone already doing it), and
collapsing search to an icon is #57's, which is blocked behind #62. So
the wordmark yields first — a brand the window title bar repeats, and
visually-hidden rather than `display: none` because that h1 is the
document's heading — and then the indicator's label, leaving the ring,
which the component already does below 600px and whose live region
announces the state either way.

"Fits" is the children against the content box, not `scrollWidth`
against `clientWidth`: `scrollWidth` counts the left padding and not
the right, so the first version read 700/700 with the indicator sitting
in the whole right gutter. And the bar does not resize when a job
starts, which is the case this is for, so every child is observed too.

Pinned before it was fixed, as the issue asks. On the unfixed build the
new spec fails at 600 idle and at 600, 800 and 900 with a job, and
passes at 390, 899 and 1440; `layout-overflow.spec.ts` gains 600x600
and failed there. That spec asserts on the *shell*, so it was green
throughout this defect — the per-child measurement is #69's lesson, and
it is what caught the gutter case above.

Closes #143
2026-08-19 21:35:03 -04:00
logan 6e7e349e63 Merge pull request 'Fold the Jobs tab into the places the work is started' (#147) from feat/27-jobs-into-settings into main
CI / check (push) Successful in 2m34s
CI / e2e (push) Successful in 7m25s
2026-08-20 01:02:41 +00:00
logan f9ba9a87d7 docs: note that CI's two engines share one app
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m31s
CI / e2e (pull_request) Successful in 7m32s
Which is why a shared-selector fault can be green on chromium and red
on webkit in the same run, and how to reproduce it locally.
2026-08-19 20:44:16 -04:00
logan e4efec6f0c fix(e2e): the third spec that located a disclosure by class
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Canceled after 8s
CI / e2e (pull_request) Canceled after 0s
`config-section .header` is ambiguous once a section holds a job, and
`failure-voice.spec.ts` was the one I did not grep for. It passed on
chromium and failed on webkit in the same CI run, which is the tell:
the two engines share one app, so the second one runs with a finished
scan the first one left behind.

The NOTES entry already says a class name is not a selector's contract;
this is the same fix, by role and name.
2026-08-19 20:44:02 -04:00
logan 99f355b2fc docs: record where background jobs went, and two traps
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m37s
CI / e2e (pull_request) Failing after 7m35s
The retired-destination note sits beside #25's storage paragraph
because it is the exception to it: an absent key is free, a value is
not.

`config-section .header` resolving to two elements is in NOTES because
it cannot be reproduced by opening Settings and looking -- the drawer
only exists once a job does.
2026-08-19 20:27:55 -04:00
logan c79d4d47a3 test: cover jobs in Settings, and unpick two shared selectors
The spec worth having is not that the tab is gone -- that is one line
of a table -- but that nothing became unreachable when it went. #24
promises that no action is ever unreachable at any supported size, and
deleting a destination is exactly the change that quietly breaks it.

Two existing selectors had to give. `config-section .header` is
ambiguous the moment a section holds a job, because `job-details-drawer`
carries that class too -- so `settings-reach.spec.ts` locates a
disclosure by role and name instead. And `page-header`'s and
`offline-icons`'s view lists lose an entry each.

Closes #27
2026-08-19 20:27:48 -04:00
logan 8efed2dd2b refactor(shell): retire the Jobs destination
Nothing it carried is gone -- the two commits before this put all of it
somewhere the work is already being done.

A retired destination is the one shape #25's storage decision does not
make free. A visibility entry is a map key and an unknown key is
dropped on load; a launch page is a *value*, and an unknown one fails
validation -- which on the load path means the app refuses to start for
whoever had Jobs selected. `RetiredViews` is that list, read by
`ApplyDefaults`, which treats a retired name as a zero value. An
unknown-but-not-retired name still errors, because that is a typo and
saying so is the useful answer.
2026-08-19 20:27:40 -04:00
logan 12af6ec1f7 feat(settings): scan from Settings, and watch every job in place
Scanning goes back where libraries are managed -- the Jobs tab's own
comment said the per-library controls had been taken out of Settings to
build it. "Scan All" and "Full Rescan" join "Add Library", "Scan now"
joins the per-library overflow menu beside Rename and Remove, and a
library being scanned says so where its track count goes.

Each surface then gets the job rows for its own kind: scans under
Libraries, index and enrichment under Search Index, downloads under the
download clients, and the autotag apply in the Autotag view -- where
stopping a run matters most, since applying rewrites tags on disk and
that view had no cancel at all.

The autotag panel shares the header's grid row through a wrapper rather
than taking a third row: it is display:none while nothing is applying,
which is nearly always, and a grid row would still spend the
container's gap on it.

The Launch Page select is derived from `VIEW_META` instead of listing
its ten options, since removing a destination is exactly the change
that leaves two hand-written copies disagreeing.
2026-08-19 20:27:33 -04:00
logan f3d1ae1c8c feat(jobs): show background work where the work is started
Reading the app before moving anything turned up that four of the five
job kinds already have a home showing their work: Settings → Search
Index draws per-tier index progress, `downloads-view` draws every
download's lifecycle state, `autotag-view` draws its own apply ring,
and only `library-scan` had nowhere but the Jobs tab. What none of the
four had is the *generic* affordances — pause, cancel, Details, the
log, and a finished job you can dismiss.

So this is a panel embedded beside each of them rather than one
"Background jobs" section in Settings, which would have been the tab
again under another name.

Three rules in it. The controls are `applyJobControl`, not a
reimplementation, which is what keeps the index build's "you will
discard hours of downloading" confirmation alive across the move. A
panel with nothing to say is `hidden` rather than empty, host margin
included, because an idle panel in four places is four pieces of
furniture describing an absence. And there is no "Clear finished":
`ClearFinishedJobs` is global, so a Clear under Libraries would discard
the index build's history too.

`JobKind` also gains `download`, which the backend has had all along.
2026-08-19 20:27:22 -04:00
logan 5af545e38d Merge pull request 'Configurable sidebar destinations, Downloads gated on a client, Autotag off by default' (#145) from feat/25-configurable-sidebar-tabs into main
CI / check (push) Successful in 2m33s
CI / e2e (push) Successful in 7m1s
2026-08-19 23:46:23 +00:00
logan 9da3967dd9 docs: record that destinations are configuration
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m36s
CI / e2e (pull_request) Successful in 7m5s
Beside the three navigation paragraphs, since it is the fourth thing
the shell states about where the user can go.

Two notes are measured facts rather than design: a default expressed as
an *absent key* survives an existing seed, where one expressed as a zero
value does not; and a spec can no longer assume a destination has a nav
item.
2026-08-19 19:34:00 -04:00
logan c8d94a8203 test(e2e): cover configurable destinations; stop assuming a nav item
The assertions are about the navigation, not about the setting: "the
config was saved" is the plumbing, and #69 and #72 both shipped green
under specs that measured exactly that.

Four existing specs reached a view by clicking its nav item, which since
this change is not guaranteed to exist -- Autotag is hidden by default
and Downloads is absent without a download client -- so they timed out
waiting for a locator that will never resolve. `navigateTo` dispatches
the app's own `navigate` event, which is what every nav item, card and
detail view dispatches, so it is the mechanism rather than a test-only
door. Click the item when the nav is the subject.

Closes #25
2026-08-19 19:33:56 -04:00
logan 43d78a731a feat(shell): draw only the destinations the user kept
The navigation reads the resolved map from the backend rather than
holding a copy of the defaults, which would be the copy that shipped in
the binary rather than the one being edited.

Hiding takes away the nav item and nothing else: `navigate` still
resolves a hidden view, which detail views and the launch page depend
on. No special case was needed for the highlight, because #72 moved
that onto `active-view-store` -- the sidebar asks `isActive(id)` per
*rendered* item, so a hidden view lights nothing exactly as a detail
view does.

Downloads is gated at the nav on `downloadStore.available` rather than
in the config, so switching it on in Settings still means what it says
once a client exists, and the tab appears without a restart. `available`
is false until the providers have loaded, which makes the item appear on
a fresh launch rather than appearing and then vanishing.

The tab bar honours the toggles too, and the reason is local rather than
a general rule about phones: "More" opens the *same* `<app-sidebar>`,
which filters, so an unfiltered bar would contradict its own drawer one
tap away. Which four tabs is still plan 016's subset; this only removes
from it, and "More" is never filtered.

`services/view-meta.ts` is the destination list, on `shortcut-meta.ts`'s
pattern, because Settings is now a second reader of the same labels in
the same order.

Two existing sidebar tests had to say which world they describe: eleven
destinations now assumes a configured download client.
2026-08-19 19:33:44 -04:00
logan a3926704cc feat(config): make the shell's destinations configurable
The sidebar's eleven entries are more than most libraries need, and
Autotag rewrites tags on disk, which is not what a fresh install should
be one click from.

Stored as a map keyed by view id, where an absent key means that view's
own default. A `HiddenViews []string` cannot express "Autotag off by
default" -- its zero value is *hide nothing* -- and a boolean per view
turns a view that later stops existing into stored garbage. With a map,
an unknown key is dropped on load, a view added later gets its own
default, and no install needs migrating in either direction. Same
polarity as AllowMeteredCatalogDownload: the zero value is the intended
answer.

`Views` is also what DefaultPage now validates against, so which views
exist and which may be the launch page are one list rather than two.

Two states the user could not get out of are refused rather than
allowed: Settings is never hideable, and the launch page is not
hideable while it is the launch page. Both refuse in the *config*, not
in the UI, because `config.toml` is hand-editable. On load the launch
page is instead un-hidden -- there is nobody to tell, and the honest
reading of "my launch page is Autotag" is that this user wants Autotag,
not that their launch page should be silently reset.
2026-08-19 19:33:32 -04:00
logan ea53d4f15b Merge pull request 'Global back and forward, and the launch entry that made Back a lie' (#144) from feat/6-global-back-forward into main
CI / check (push) Successful in 2m23s
CI / e2e (push) Successful in 7m3s
The stack was always global; the affordance was not. Adds the control, and fixes the second launch navigation that left a fresh session one entry deep.

Closes #6
Closes #142
2026-08-19 22:27:39 +00:00
logan ea16e07c46 docs: record the back/forward chrome and the two launch navigations
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m31s
CI / e2e (pull_request) Successful in 7m4s
Both belong beside the rules that already keep the history stack
honest: the depth counting, because forward is the case one counter
cannot express, and the second launch navigation, because it is what
silently defeated the first rule on the list.
2026-08-19 18:09:10 -04:00
logan 4f47c85208 test(e2e): cover global back/forward, including the launch entry
Five journeys the control has to get right: nothing offered at the root
in either direction, walking both ways with the availability changing
as it goes, reaching the detail view a tab click left behind (which is
the report), the forward list being dropped when the user navigates
from the middle, and the control standing down below 900px.

The first fails on the build before the launch entry was fixed --
enabled, and doing nothing when pressed. It is the assertion that pins
that defect, which otherwise has no visible symptom on desktop at all.
2026-08-19 18:09:10 -04:00
logan 603728a3fb fix(shell): let the landing page replace the launch entry
The app navigates twice on startup and both are deliberate: the eager
`navigate -> home` that paints without waiting for the backend, and the
configured page `GetDefaultPage()` resolves to a moment later. Only the
first replaced the launch entry, so the second stacked on it and a
fresh session was already one entry deep before the user had touched
anything.

The first back press therefore replayed home over home. On desktop that
was invisible until this branch drew a Back button, which rendered live
at the root and did nothing; on Android `webView.canGoBack()` was true,
so the press that should have exited the app was swallowed -- the exact
fault the replace-the-launch-entry rule exists to prevent, defeated by
there being two launch navigations rather than one.

Guarded on still being at index 0 rather than on a flag: that call is
asynchronous and the user can navigate while it is in flight, so past
the root this is an ordinary navigation and a slow answer cannot
overwrite an entry they made.

Closes #142
2026-08-19 18:09:10 -04:00
logan 018d857746 feat(shell): global back and forward in the top bar
The history stack has been global since the Android back gesture landed
-- every navigation is an entry and `popstate` restores any of them in
either direction. What the report describes as "back is tab-scoped" is
that the only way back was a detail view's own button, which leaves the
screen with the view it belongs to: click over to Tracks and the album
you were reading is still one entry away with nothing on screen saying
so.

`<nav-history>` is that affordance, plus `nav.back` / `nav.forward` on
Alt+Left / Alt+Right -- the browser's own combination, and clear of the
bare arrows that seek, since a binding matches on its full canonical
string.

Forward is not back negated, which is why the old `pushedEntries`
counter is gone rather than extended: `popstate` carries no direction
and fires identically both ways, so one counter decremented on every
pop reads a forward as a second back. Each entry carries its index and
the shell keeps the current one and a high-water mark, which also
survives a jump of more than one.

The buttons dispatch the events the rest of the app already dispatches
rather than calling `history` themselves -- the shell owns the guard
that stops a press at the root leaving the app, and a second caller
reaching for history is how the old `navStack` came to disagree with
the platform.

Below 900px the control stands down: the top bar is what runs out of
room first below that, and nothing becomes unreachable -- the shortcuts
are global at every width and the phone has the platform's gesture.

Closes #6
2026-08-19 18:08:55 -04:00
logan a7ac2b4a3e Merge pull request 'Publish the active view from the shell, so both navs survive the back path' (#141) from fix/72-active-view-broadcast into main
CI / check (push) Successful in 2m29s
CI / e2e (push) Successful in 6m59s
The shell knew which view was active and never said so on the back path, so both navs highlighted the view just left.

Closes #72
2026-08-19 21:10:54 +00:00
logan d347809e6e docs: record the one statement of which view is active
CI / e2e (push) Skipped
CI / check (push) Skipped
CI / check (pull_request) Successful in 2m29s
CI / e2e (pull_request) Successful in 6m53s
It belongs beside the two rules that already keep the history stack and
the in-app back buttons agreeing, and for the same reason: a second
component-local idea of where the user is, is how they came to
disagree.
2026-08-19 17:00:39 -04:00
logan a5ffcc22e3 test(e2e): assert the nav highlight, not the shell's own bookkeeping
`back-navigation.spec.ts` covered exactly the journeys #72 breaks and
was green throughout it, because every assertion in it was
`data-active-view` — which the shell sets on every path including the
back one, and which was the one thing already correct. The same trap
`layout-overflow.spec.ts` set for #69: a spec named for the behaviour,
measuring the plumbing.

The assertions go here rather than in a second file, or the first would
carry on passing vacuously. They are `aria-current="page"` through
`getByRole`, which is the accessible fact — `.active` is a class and
could be restyled without breaking anything real — and the role query
resolves to whichever nav is in the accessibility tree at that
viewport, so one helper covers the sidebar and the tab bar.

Three of the four fail on the build before the fix. The fourth, the
parent staying lit while a detail view is open, passed by accident and
says so.
2026-08-19 17:00:39 -04:00
logan f18691560d fix(shell): publish the active view, so both navs follow the back path
The nav components learned where the user was from the `navigate`
CustomEvent, which only the outbound path dispatches: `popstate` calls
`handleNavigate()` directly. So a back-navigation left both of them
highlighting the view just left — desktop included, at any width, on
any back across two primary views. Opening a detail view was the same
cause wearing a different symptom: `app-sidebar` guarded on its own
item list and kept its highlight, `bottom-nav` did not and lit nothing.

It cannot be fixed by re-dispatching `navigate` — `index.ts` is that
event's document listener, so that is an infinite loop, and "please go
to X" is not the statement being made. `activeViewStore` is the shell
saying "the active view is now X", once per navigation, `popstate`
included; both navs read it through a controller and hold no
`activeView` of their own.

A store rather than an event because a component that mounts *after* a
navigation still has to know: `bottom-nav`'s drawer builds its
`app-sidebar` on open, and that copy had heard nothing at all, so the
drawer opened on Home from any page in the app.

Closes #72
2026-08-19 17:00:39 -04:00
logan c84a9069ef Merge pull request 'Collapse the page header actions that do not fit, instead of clipping them' (#136) from fix/69-page-header-action-overflow into main
CI / check (push) Successful in 2m33s
CI / e2e (push) Successful in 6m53s
Closes #69
2026-08-19 19:29:15 +00:00
logan f967916550 fix(page-header): collapse the actions that do not fit into a menu
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m36s
CI / e2e (pull_request) Successful in 7m17s
Playlists slotted three buttons totalling 390px into a header that gets
700px at 900x600, so "New Smart Playlist" rendered 114 of its 162px
with the queue closed, and 158 of 162 at the 800x600 enforced minimum.
On a phone none of the three could be reached at all, which is what the
Android report said. Plan 018's size matrix promises the opposite: no
action is ever unreachable at any supported size.

The header could not fix that for slotted markup, and that is a fact
about the API rather than an effort estimate — a component cannot move
another component's light-DOM children into a dropdown and keep their
behaviour, and arbitrary markup offers nothing generic to render as a
menu item. So a host passes `PageAction[]` and the header chooses the
rendering; the slot survives for markup a data list cannot express, at
the stated cost that a slotted action does not collapse.

All three hosts that slot actions migrated, which also normalises the
plain-<button>/<wa-button> split between them onto one shape the header
styles — and lets it measure a button that has already upgraded, rather
than a wa-button whose shadow DOM arrives in its own first update.

Four things in it are load-bearing:

- Every measuring pass starts from all-visible, so the collapsed set is
  a pure function of the current width and an action comes back when
  the window grows. It flips `hidden` imperatively rather than
  re-rendering between steps, or the intermediate state paints and the
  fix flashes the overflow it exists to prevent.
- "Fits" means nothing is clipped, not that the header does not
  overflow. Once the title can ellipsis it absorbs the pressure and
  scrollWidth reports a perfect fit while the heading reads "Playlis…"
  — this bug moved from the button to the title, and invisible to the
  same measurement that missed it the first time.
- New Playlist has the highest priority because it is the drop target
  and a closed menu cannot be one. `PageAction.drop` therefore carries
  the host's own handlers; the affordance is absent from the overflow
  rather than approximated there.
- The overflow trigger is a named button with aria-expanded and an
  aria-controls naming a panel that is always in the DOM, and the
  keyboard model is the shared `MenuKeyboard`.

`layout-overflow.spec.ts` passes on the broken build — it asserts the
shell needs no sideways scrolling, and clipping inside a component is
invisible to it, which is why this defect survived a spec named for it.
The new spec measures each button against its own header at four
viewports and asserts buttons plus menu account for every declared
action, without which it would pass vacuously on a build rendering none.

Closes #69
2026-08-19 15:08:57 -04:00
logan 3fa7c7734b docs: record the page-header actions rule, and complete plan 018
The `page-header` paragraph already stated "the header asks for a sort,
it does not perform one"; actions now follow the same division and it
belongs beside it — the header decides what fits, the host decides what
happens.

Plan 018 moves to completed/ because #69 was the last thing it owed:
its size matrix promised "no action is ever unreachable at any
supported size" and the residual 114/162px clip was that promise
outstanding. Its recap also corrects a claim the plan made — the queue
and the actions were not the only two things competing for the header's
width, since every child of that flex row was flex-shrink: 0 and the
actions come last.
2026-08-19 15:08:57 -04:00
logan cceeb40b16 Merge pull request 'Six quick fixes off the tracker: tooling, a latent index bug, and two touch affordances' (#139) from fix/quick-wins-batch into main
CI / check (push) Successful in 2m34s
CI / e2e (push) Successful in 6m44s
Closes #130
Closes #131
Closes #119
Closes #118
Closes #68
Closes #61
2026-08-19 19:08:46 +00:00
yonlu 2926ecd4b4 docs(notes): record that no test tier can see a hover media query
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 3m2s
CI / e2e (pull_request) Successful in 6m33s
Both browser tiers are blind to `(hover: hover)` gating, in different
ways and without failing: CDP media emulation does not reach ui-test's
iframe, and e2e's phone specs reach phone width with setViewportSize,
which changes no media feature but width. Written down with what does
work — a device-descriptor context — because the next person to gate an
affordance this way will otherwise re-derive it, and the tempting
conclusion from a green suite is that the gate is covered.
2026-08-19 14:08:38 -04:00
yonlu ff3c4003cb Merge branch 'fix/61-mini-player-plain-text' into fix/quick-wins-batch 2026-08-19 14:08:15 -04:00
yonlu def596a99e Merge branch 'fix/68-hover-affordances-pointer' into fix/quick-wins-batch 2026-08-19 14:08:14 -04:00
yonlu 14f78c0b57 Merge branch 'fix/118-in-library-clear' into fix/quick-wins-batch 2026-08-19 14:08:14 -04:00
yonlu 7cea238e71 Merge branch 'fix/119-dev-headless-port' into fix/quick-wins-batch 2026-08-19 14:08:13 -04:00
yonlu 4f2f1827ab Merge branch 'fix/131-codegen-check-scope' into fix/quick-wins-batch 2026-08-19 14:08:13 -04:00
yonlu e454e4074b Merge branch 'fix/130-issue-claim-user' into fix/quick-wins-batch 2026-08-19 14:08:12 -04:00
yonlu c518ac8c73 feat(now-playing): plain text instead of links in the phone mini player
CI / check (push) Skipped
CI / e2e (push) Skipped
The bottom bar's title, artist and "Playing from X" all navigate. In a
bar sized for a bar they are a few characters of text, which is not a
touch target — and explore-link holds its navigation for one
double-click interval and drops it if a second click arrives, a gesture
that exists so double-clicking a row can play it and that means nothing
on touch.

Below the shell's phone breakpoint the three render as plain text. The
words are unchanged: the source line still says where the queue came
from, because dropping the link is the change and dropping the
information would be a different and worse one. The cover art already
carries the phone-only button that opens the full-screen Now Playing
view, which is where the links live.

This is in JS rather than in the stylesheet because what changes is the
content, not its appearance — no CSS rule takes a click handler off an
element. matchMedia is read in connectedCallback for the reason the
reduce-motion query beside it already is, so a test can answer it first.

Two smaller things. PHONE_QUERY moves out of track-list.ts into
utils/breakpoints.ts: it was a private const when one component needed
it, and a second reader is where a copy starts drifting from index.css.
And `phone` joins geometryKey(), because crossing the breakpoint swaps a
link for a bare string and the marquee travels a distance read from
measuring it — the words being identical either side is not the same as
the box measuring the same.

Closes #61
2026-08-19 14:08:06 -04:00
yonlu 977f624123 fix(home): gate the card play button on the device having hover
CI / check (push) Skipped
CI / e2e (push) Skipped
The play button on a home shelf's cover cards is revealed by :hover, and
a touch long-press synthesises a hover state in the WebView — so on a
phone it flashed into view during the 500ms hold that
utils/long-press.ts is measuring for a context menu. A control appearing
because the user was reaching for a different one.

It is gated on `(hover: hover) and (pointer: fine)` rather than on width,
so it is absent on any touch device and present on a desktop with a small
window. A phone user taps the album and plays from the detail view, so
nothing replaces it.

The default outside the query is display:none, not opacity:0. An
opacity-0 button still takes taps and is still in the accessibility tree,
so leaving the reveal as the only guarded part would keep the hit area
for a control the phone can never show.

The test asserts the parsed stylesheet rather than rendering as a phone,
and says so: CDP's Emulation.setEmulatedMedia does not reach this tier's
iframe, so matchMedia still answers `hover: hover` after it is set. The
regression worth catching is someone hoisting the rule back out of the
query as a tidy-up — a change no desktop assertion can see.

Closes #68
2026-08-19 14:08:06 -04:00
yonlu 8d46c4abb7 fix(scripts): refuse to start dev-headless on a port somebody else holds
CI / check (push) Skipped
CI / e2e (push) Skipped
dev-headless.sh checked the PID in *this* worktree's .dev/app.pid and
nothing else, so an app orphaned by a deleted worktree went on listening
with nothing left to stop it — `make dev-stop` only kills the pid it
wrote. The new app then started, failed to bind, exited, and every
subsequent curl and playwright-cli call went to the other process: the
harness reported facts about an app nobody asked for.

That fails a long way from its cause. It presented as "no such table:
libraries" against a *freshly created* YJ_HOME, which reads exactly like
applySchema or staleshape.go having gone wrong, with a zero-byte app.log
beside it saying nothing.

The startup wait cannot catch this, because its health check is satisfied
by any app on the port — which is precisely the failure — so the check is
before the launch and refuses rather than warns. It names the holder's
pid, cmdline and /proc/<pid>/cwd, which is what identifies the checkout
and says "(deleted)" for the case this exists for. It does not suggest
`make dev-stop`: the PID-file check has already passed, so by
construction dev-stop does not know about this process and would report
success while changing nothing. --port already covers the legitimate
second-app case.

The second, cheaper guard the report asks for goes in after the wait:
"the port answered" is not "the app we started answered", so a dead
APP_PID at that point is now an error with the log tail rather than a
success message about somebody else's process.

Closes #119
2026-08-19 14:08:05 -04:00
yonlu f714fe513d fix(scripts): report only what generation changed, not the worktree
CI / check (push) Skipped
CI / e2e (push) Skipped
The codegen-check hook was `go generate` followed by a bare
`git diff --name-only`, which is the whole unstaged worktree rather than
the generators' output. So a commit whose staged changes were fine failed
whenever anything unrelated sat unstaged — notes, a plan document, the
next commit's files — reporting "Generated code is out of date" and then
a diffstat of files no generator has ever written. `make generate` fixed
nothing, because nothing was stale, so the message sent you looking for a
codegen problem that did not exist. Splitting one piece of work into
several commits is exactly the shape that triggers it.

The tree is snapshotted either side of `go generate` and only what moved
across it is reported. That is deliberately a snapshot rather than the
list of generated paths the issue offers as the other option: a fourth
generator is one //go:generate line away, and a path list is a second
place to remember it.

Two things it has to get right. The comparison is a *symmetric*
difference, because generation can push a file into the unstaged set or
pull it out of one — a hand-edited generated file that the generator puts
back is stale generated code just as much as a source change that
outdates it, and comparing one direction reports it as current. And the
snapshot is content, not names, or a generated file that was already
dirty and is then rewritten further keeps its name on both sides and
slips through.

Closes #131
2026-08-19 14:08:05 -04:00
yonlu 087c69ac8d fix(scripts): let issue.sh claim work on a write:issue-only token
CI / e2e (push) Skipped
CI / check (push) Skipped
`claim` is the one step the workflow requires before the first edit, and
it failed outright on a token scoped to the work it does: `me()` calls
`GET /user` purely to name the assignee, and that endpoint needs
read:user. So the documented process was blocked by its own tooling, and
the fallback was to do the assignment, the label and the comment by hand
— which is the half-made claim `claim` exists to prevent.

GITEA_USER short-circuits the lookup, so least privilege is enough. The
lookup stays as the fallback because it is right when the scope is there
and needs no setup. Failure is now actionable and says both remedies,
and it still happens before any of the three halves are mutated.

Closes #130
2026-08-19 14:08:05 -04:00
80 changed files with 6497 additions and 929 deletions
+6
View File
@@ -195,6 +195,12 @@ Two rules about climbing:
- **Do not write an e2e spec first.** Drive the flow by hand, then - **Do not write an e2e spec first.** Drive the flow by hand, then
promote it with `/e2e`. Specs written blind assert on selectors that promote it with `/e2e`. Specs written blind assert on selectors that
do not exist. do not exist.
- **Not every view has a nav item.** Since #25 the destinations are
configurable, Autotag is hidden by default and Downloads is absent
until a download client exists — so `getByTestId('nav-<view>')` waits
30 s for a locator that will never resolve. `navigateTo(page, view)`
(`e2e/support/fixtures.ts`) dispatches the app's own `navigate` event.
Click the nav item when the *nav* is what the spec is about.
Before a commit, the gate is `make lint`, `make test`, `make ui-test`, Before a commit, the gate is `make lint`, `make test`, `make ui-test`,
`make bindings-check`, `make css-check` and — from `frontend/` `make bindings-check`, `make css-check` and — from `frontend/`
+227
View File
@@ -3664,3 +3664,230 @@ knowing before someone "fixes" it as broken: sampled from screenshots at
900×600, the main panel's background goes 33,37,41 → 18,20,23 and a 900×600, the main panel's background goes 33,37,41 → 18,20,23 and a
row's text 242 → 133. It covers the content area only — not the sidebar row's text 242 → 133. It covers the content area only — not the sidebar
or the transport — because the queue is not modal. or the transport — because the queue is not modal.
## No test tier can see a `hover:` media query (measured 2026-08-19)
Gating an affordance on `(hover: hover) and (pointer: fine)` — #68's fix
for the play button that flashed on a long-press — is invisible to both
browser tiers, in *different* ways, and neither of them fails.
- **`make ui-test`**: CDP's `Emulation.setEmulatedMedia` with a `hover`
feature does not reach the tier's iframe. The call succeeds and
`matchMedia('(hover: hover)')` still answers `true` afterwards. So
there is no way to render a component as a phone would and read the
computed style.
- **`make e2e`**: both projects are desktop (`Desktop Chrome`,
`Desktop Safari`), and the phone specs reach phone *width* with
`setViewportSize`, which changes no media feature but `width`. So the
phone specs run with `hover: hover` and the gate is never exercised.
What does work, and what the fix was verified with, is a second browser
context under a device descriptor: `chromium.newContext(devices['Pixel
5'])` reports `hover=false pointer:fine=false` and the button computes
`display: none`, against `flex` at 1440px. That is a one-off script, not
a spec — `isMobile` is Chromium-only, so it cannot become an e2e project
without losing the WebKit half.
`hover-affordance.test.ts` therefore asserts the *parsed stylesheet* —
that the reveal rule sits inside the media query — which catches the
regression that actually threatens it: someone hoisting the rule back out
as a tidy-up, a change nothing on a desktop renders differently.
Related: a width-gated decision **is** testable at both tiers, which is
why #61's phone mini player is a `matchMedia` stub in the component test
and needs nothing special.
## A default that is an *absent* key survives an existing seed (2026-08-19)
The skill warns that a seed freezes every default it has already
persisted, so changing one in `backend/config` is invisible against an
existing `YJ_HOME` while CI, which seeds by running the app, tests the
new one. That warning is about defaults stored as *values*.
#25's Autotag-hidden default is stored as the **absence of a key**:
`GeneralConfig.ViewVisibility` is a map, an id it does not mention takes
`backend/config.Views`' answer, and only what the user changed is ever
written. So a seed built before the feature existed showed the new
default immediately — verified against `.dev/seeds/default.tar`, whose
`config.toml` has no `[General.ViewVisibility]` table at all, and whose
sidebar came up without Autotag on the first launch of the new binary.
After toggling it on and off again the file carries exactly one line,
`autotag = false`.
The general form is worth keeping: **a default expressed as a zero value
needs a re-seed to observe; a default expressed as an absent key does
not**, and it needs no migration for existing installs either. It is the
same property that makes removing a view later free (an unknown key is
dropped on load), which is what the `#25#27` ordering on #73 rests on.
## A spec cannot assume a destination has a nav item (2026-08-19)
Since #25, `getByTestId('nav-<view>')` is not a reliable way to reach a
view: Autotag is hidden by default and Downloads is absent without a
download client, so four existing specs failed on a 30 s timeout waiting
for a locator that will never resolve. `navigateTo(page, view)` in
`e2e/support/fixtures.ts` dispatches the app's own `navigate` event
instead, which is what every nav item, card and detail view dispatches —
so it is the mechanism and not a test-only door.
Use the nav item when the *nav* is the subject, and `navigateTo` when
the view is.
## `config-section .header` is ambiguous once a job exists (2026-08-19)
#27 embeds `<job-panel>` inside four Settings sections, and a panel with
any job in it also mounts a `job-details-drawer` — whose own header
carries the class `.header`. So `config-page config-section .header`,
which `settings-reach.spec.ts` had used since plan 007, resolves to two
elements and fails Playwright's strict mode the moment a scan has run.
Two things follow. A spec asserting on a section's *disclosure* should
locate it by role and name (`getByRole('button', {name: heading})`) or
scope per section and take `.first()`, not by that class. And this is a
worked example of the more general trap: a class name is not a
selector's contract, and a component that embeds another inherits its
class names into every ancestor query.
It also only appears in a suite that has *done* something — the
sections are empty on a fresh app, so this cannot be reproduced by
opening Settings and looking.
**And it appears on the second engine, not the first.** CI runs
chromium then webkit against **one app**, so a spec that scans in the
chromium pass leaves a finished job the webkit pass then trips over.
Three specs used that selector; two failed locally and the third
(`failure-voice.spec.ts`) was green on chromium and red on webkit in
the same run. Reproducing it locally is running the suite twice against
one `make dev-headless` — which is worth doing for any change that
leaves state behind, since it is the only place a cross-engine order
dependency shows up.
## `scrollWidth` counts the left padding and not the right (measured 2026-08-20)
The obvious predicate for "does this flex row fit" is
`el.scrollWidth <= el.clientWidth`, and on a box with symmetric gutters
it **under-reports by one gutter**. `scrollWidth` is the extent of the
scrollable content area, which includes `padding-left` and excludes
`padding-right`; `clientWidth` includes both. So a child may end up to
`padding-right` past where content is allowed to go while the box
reports a perfect fit.
Measured on the top bar (`padding: 0 2em`) at 700x600 with a long-titled
scan staged: `clientWidth 700`, `scrollWidth 700` — and
`job-indicator`'s right edge at 700 against a content edge of 668, i.e.
sitting in the whole right gutter. `#143`'s first fix passed its own
measurement and left the indicator visibly jammed against the window
edge.
The predicate `services/top-bar-fit.ts` uses instead is the one its
spec asserts: no in-flow child's rect outside the parent's *content*
box, both edges, with half a pixel of slack for fractional flex widths.
This is the same family as #69's title trap — the measurement easiest to
reach for is the one that cannot see the failure — and it is worth
knowing before writing the next one of these: **the fit test and the
assertion that proves it should be the same test.** It was found only
because `top-bar-fit.spec.ts` measures per child rather than asserting
on the container, which is exactly why #69 needed
`header-action-overflow.spec.ts`.
## The top bar's overflow is 11px idle and 262px while working (measured 2026-08-20)
#143 was filed as "11px at 600x600" and re-measured as 171. Both are the
same defect seen with different jobs running: `job-indicator` is
`hidden` when idle, ~144px wide showing "Scanning Music", and **235px**
showing a real library's scan title ("Scanning Music from the external
drive"), because the label is capped at 12rem and gets there.
Swept against the running app with that job staged, `header.top-bar`
client vs scroll:
| width | idle | with the long-titled scan |
|---|---|---|
| 320, 390, 599 | fits | fits (the phone rules drop the filter and the label) |
| 600 | 611 | **862** |
| 700 | fits | 862 |
| 800 | fits | 862 |
| 899 | fits | 899 (fits) |
| 900 | fits | 946 |
| 1100, 1440 | fits | fits |
Two things worth keeping. The band is **600610 idle and 600900 while
working**, so "a narrow corner" and "the header is crowded from 900
down" are both true and the difference is entirely what is in flight —
which is the case a seeded, settled app can never show you. And 899
fits while 900 does not, because `nav-history` appears at 900: the worst
width for the header is not the narrowest one, the same way 900 rather
than 800 is the worst width for the content area.
Staging it is `/__test/emit` with a `JobsChanged` snapshot; a job with
`state: "running"` never completes, so it stays up until an empty
snapshot is emitted, which is what makes an idle re-measurement look
like the fix not working.
## Two repaint mechanisms, and neither is pinned alone (measured 2026-08-20)
`CLAUDE.md` already states the rule — *a virtualized list repaints only
when you tell it to, and the accidental way you were telling it may be
the thing you are about to delete* — found in `artists-view` and
`genres-view`. `queue-panel` is a second instance with numbers, and the
numbers are the part worth keeping.
It repaints its rows **two** ways:
- `onSelectionChanged()` calls `virtualizer.requestUpdate()`, which is
the intended one and the one `track-list` has always had;
- `.keyFunction=${(track) => track.id}` is a **per-render arrow**, so it
is a changed property on every host update and repaints the rows by
itself.
Removing *either* alone changes nothing observable. That is why #43
could not be settled by reading the code: the hypothesis in its Findings
(the repaint is missing) was checkable, false, and would have looked
identical either way.
Removing **both** does not break selection either — it delays it. Time
from click to `aria-selected`, three clicks each:
| build | ms to highlight |
|---|---|
| healthy | 5, 16, 17 |
| both mechanisms removed | 134, 3,866, 5,816 |
The highlight arrives on whatever unrelated render happens next (the
player's 1 Hz position report is the usual candidate). **Four seconds is
indistinguishable from broken to a user, and invisible to a spec** —
`expect.poll`'s default 5 s timeout passes the degraded build on every
assertion. `queue-selection.spec.ts` bounds its selection assertions at
500 ms for that reason, which is ~30x the healthy case and an order of
magnitude under the degraded one.
The general form, for the next spec about anything push-driven: **a poll
generous enough to be stable is generous enough to miss a latency
regression entirely.** If "late" is a failure mode worth having, the
timeout has to say so.
## A hit-scan says how much of a row is not selectable (measured 2026-08-20)
`explore-link` stops the click's propagation on purpose — "the row must
not also treat it as a selection" — so a click on a track, album or
artist *name* navigates and selects nothing. That is app-wide and
deliberate, and the useful question about any given list is how much of
its row it costs.
Asking `elementFromPoint` what is under each x across a row, at three
heights:
| list | link coverage |
|---|---|
| queue panel | 12% |
| track list | 21% |
This killed a fix in progress. #43 reads as "selection is broken in the
queue panel, and fine in the track list", the obvious mechanism is that
the queue's narrow rows are mostly name, and it is **wrong**: the panel
is *less* link-covered than the list it is being compared against. The
scan takes a minute and is worth running before demoting anybody's links
— `explore-album-details`'s tracklist (number / title / artist /
duration) is the one that plausibly *is* mostly link, and is the one
#5 is about to add selection to.
@@ -3,7 +3,8 @@
**Issue:** #24 (`Area/Shell-Nav`, `Priority/High`, `Reviewed/Confirmed`) **Issue:** #24 (`Area/Shell-Nav`, `Priority/High`, `Reviewed/Confirmed`)
**Unblocks:** #55 (queue as a screen) — a real Gitea dependency **Unblocks:** #55 (queue as a screen) — a real Gitea dependency
**Relates:** #69 (page-header overflow), #12 (mini-player), #51 (small-screen umbrella) **Relates:** #69 (page-header overflow), #12 (mini-player), #51 (small-screen umbrella)
**Status:** in flight **Status:** complete — #24 shipped as PR #132, and the matrix's last
unkept promise closed with #69.
#73 puts this first in Phase 2 and hangs the rest of the phase off it, #73 puts this first in Phase 2 and hangs the rest of the phase off it,
so the decision has to be written down and arguable before any CSS so the decision has to be written down and arguable before any CSS
@@ -269,6 +270,38 @@ leaving the navigation live means the scrim reads as "this is over the
content" (which is what #24 asked for) without pretending the rest of content" (which is what #24 asked for) without pretending the rest of
the app is unavailable. the app is unavailable.
## What #69 did with the promise, and one thing this plan got wrong
#69 landed on its own branch as decision 3 said it would, and the
matrix's *no action is ever unreachable at any supported size* is now
kept rather than promised. Measured on Playlists, actions clipped:
| viewport | before #24 | after #24 | after #69 |
|---|---|---|---|
| 900×600, queue open | all three | one (114/162px) | none |
| 900×600, queue closed | one | one | none |
| 800×600, queue closed | one (158/162px) | one | none |
| 390×780 | all three | all three | none |
| 320×600 | all three | all three | none |
The shape was the one decision 3 predicted — an actions API first, an
overflow rule second — and all three hosts that slot actions migrated.
**What this document got wrong is smaller and worth keeping.** Decision
1 says the header's minimum is a *comfort* floor and that only the
queue and the actions compete for the header's width. They are not the
only two: every child of that flex row was `flex-shrink: 0`, so
whatever came last lost, and the actions come last. At 320px the sort
control alone is 172px of the header — so with every action already
collapsed into the menu, the *menu button* was 76px off the right edge.
The promise was still broken with nothing left to collapse.
That is why #69 also had to decide what gives way: the title (which the
navigation also states) and, below 600px, the word "Sort:" (which the
direction arrow implies). Neither is an action, which is the rule the
matrix actually encodes — **an action is a capability and everything
else on that row is a label.**
## Verification, and what each tier cannot see ## Verification, and what each tier cannot see
- `make ui-test` — the queue panel's mode logic is component-tier - `make ui-test` — the queue panel's mode logic is component-tier
+310 -1
View File
@@ -514,6 +514,36 @@ rather than renaming them.
the autotag apply are registered; anything that is not registered has the autotag apply are registered; anything that is not registered has
none of that, which is exactly how the three gaps the audit found none of that, which is exactly how the three gaps the audit found
came about. came about.
**Its rows are shown where the work is started, not on a page of
their own.** #27 folded the Jobs destination away, and the shape it
folded into is `<job-panel kinds="…">` embedded four times — scans in
Settings → Libraries, index and enrichment in Settings → Search
Index, downloads under the download clients, the autotag apply in
`autotag-view`. One "Background jobs" section in Settings was the
obvious reading of the report and is the tab again under another
name.
Four things about it are load-bearing. **Four of the five kinds
already had a home** that showed their work — the tier list, the
download list, the apply ring — and what none of them had is the
*generic* affordances, so the panel carries pause, cancel, Details
and the log to each rather than replacing what is there. **The
controls are `applyJobControl`**, not a reimplementation, which is
what keeps the "you will discard hours of downloading" confirmation
alive: it is keyed on `KindIndexBuild` inside the shared handler, and
a host drawing its own buttons would drop it silently. **A panel with
nothing to say is `hidden`**, host margin included, because an idle
panel in four places is four pieces of furniture describing an
absence. And **there is no "Clear finished"** in it, because
`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
`config-section .header` is ambiguous the moment a job exists.
- `config` — TOML-based settings. Settings page uses HTMX + templ for server-rendered HTML fragments. - `config` — TOML-based settings. Settings page uses HTMX + templ for server-rendered HTML fragments.
- `playlist` / `smartplaylist` — Playlist CRUD and rule-based smart playlists. - `playlist` / `smartplaylist` — Playlist CRUD and rule-based smart playlists.
- `mediacontrols` — OS media controls behind one `Handler`: MPRIS over - `mediacontrols` — OS media controls behind one `Handler`: MPRIS over
@@ -945,13 +975,167 @@ change at all.
Two rules hold it up. The **first** navigation *replaces* the launch Two rules hold it up. The **first** navigation *replaces* the launch
entry rather than pushing one, or every launch costs a back press before entry rather than pushing one, or every launch costs a back press before
the app will close. And the in-app back buttons (`navigate-back`, fired the app will close. **There are two launch navigations**, which is what
defeated that rule for five phases: the eager `navigate → home` at the
foot of `index.ts` and the configured page `GetDefaultPage()` resolves
to later. Only the first replaced, so a fresh session was already one
entry deep, the first back press replayed home over home, and on Android
`canGoBack()` was true so the press that should have exited the app did
nothing (#142). The landing-page navigation carries `_replace`, honoured
only while still at index 0 — past that the user has navigated during
the backend call, and a slow answer must not overwrite an entry they
made. And the in-app back buttons (`navigate-back`, fired
by the detail views and `now-playing-view`) go through `history.back()` by the detail views and `now-playing-view`) go through `history.back()`
rather than a stack of their own: the old `navStack` is **deleted**, not rather than a stack of their own: the old `navStack` is **deleted**, not
kept beside it, because two stacks is precisely how a view's own back kept beside it, because two stacks is precisely how a view's own back
button and the phone's gesture come to disagree about what one press button and the phone's gesture come to disagree about what one press
means. means.
**And there is one statement of which view is active**, for the same
reason: `popstate` calls `handleNavigate()` directly and dispatches no
`navigate`, so the two nav components — which learned the active view
from that event — kept highlighting the view the user had just *left*.
`store/active-view-store.ts` is the shell saying where the user is, and
both navs read it through `ActiveViewController` rather than holding an
`activeView` of their own.
Four things about it are load-bearing.
**"Please go to X" and "the active view is now X" are different
statements**, and only the first existed — dispatched from 28 call
sites across 18 files. A re-dispatch from inside `handleNavigate` is
not the fix and cannot be: that function is the `document` listener for
`navigate`, so it is an infinite loop.
**It is a store rather than an event, because a component that mounts
after a navigation still has to know.** `bottom-nav`'s "More" drawer
creates its `<app-sidebar>` on open, and that copy had heard no
`navigate` at all — standing on Albums, the drawer opened highlighting
Home. An event has no answer for a listener that was not there.
**A detail view is not a view here**, so the destination it was opened
from stays lit. `app-sidebar` did that by accident (it guarded on
`navItems.some(...)`, so an unmatched name left its highlight alone)
and `bottom-nav` had no such guard and so lit *nothing* — which is why
one looked right and the other looked broken on the same screen.
Whether a view is primary is the shell's fact: `view in VIEW_TAGS` is
passed to `setView`, never re-derived, because a second copy of that
list is a second thing to forget.
**Nothing is lit until the shell has navigated.** The store starts
empty rather than defaulting to `home`, which is what `app-sidebar`'s
field used to do to match the landing view — a default that is correct
only while `GetDefaultPage()` agrees with it.
**Back and forward are chrome, and the depth is the shell's own
count.** `<nav-history>` in the top bar is #6: the stack was always
global — every navigation is an entry and `popstate` restores any of
them in either direction — so what was missing was an affordance, since
the only way back was a detail view's own button, which leaves the
screen with the view it belongs to. The buttons dispatch
`navigate-back` / `navigate-forward` and the shell owns both guards,
for the reason the old `navStack` was deleted: a second caller reaching
for `history` is how two stacks come to disagree.
Three things about it are load-bearing. **Forward is not back
negated**, so the single `pushedEntries` counter could not express it —
`popstate` carries no direction and fires identically both ways, so a
counter decremented on every pop reads a forward as a second back. Each
entry carries its index (`yjIdx`) and the shell keeps the current one
and a high-water mark; that also survives a jump of more than one,
which `history.go(-n)` and a long-press on a browser's back button both
produce. **A control that cannot act is `disabled` here**, which is the
documented exception to `library-status-indicator`'s rule: the two are
a pair whose positions the user learns, and hiding one moves the other
under the cursor. And **it stands down below 900px** — the top bar is
what runs out of room first below that (it already overflows 600px by
11px, #143), and nothing becomes unreachable: `nav.back` / `nav.forward`
(`Alt+Left` / `Alt+Right`, the browser's own combination, and clear of
the bare arrows that seek) are global at every width, and the phone has
the platform's gesture.
The assertion is `aria-current="page"`, in
`e2e/specs/back-navigation.spec.ts`. That file existed throughout the
bug, covered exactly these journeys, and asserted only
`data-active-view` — the shell's own bookkeeping, which was right the
whole way through — so it was green on the broken build. Same trap as
`layout-overflow.spec.ts` and `page-header`: a spec named for the
behaviour, measuring the plumbing.
**Which destinations exist is configuration, and hiding one takes away
the nav item and nothing else.** Eleven sidebar entries is more than
most libraries need (#25), so each is toggleable from Settings →
Navigation, Autotag is off until asked for, and Downloads is absent
until there is a client to download with — a destination for a feature
that cannot work is worse than none. `navigate` still resolves a hidden
view, which is not a nicety: detail views navigate into these and the
launch page is one of them. Nothing needed a special case for the
highlight either, because the paragraph above moved that onto
`active-view-store`: the sidebar asks `isActive(id)` per *rendered*
item, so a hidden view lights nothing exactly as a detail view does.
Five things about it are load-bearing.
**The stored shape is a map keyed by view id, and an absent key means
that view's own default** (`backend/config.Views`). That is what makes
this need no migration in either direction, and it is the polarity rule
`AllowMeteredCatalogDownload` states: the zero value is the intended
answer. A `HiddenViews []string` cannot express "Autotag off by
default" at all — its zero value is *hide nothing* — and a struct with
a boolean per view turns a view that later stops existing into stored
garbage. Here an unknown key is dropped on load and a view added later
gets its own default rather than being invisible or forcibly visible.
It is also what makes #73's `#25 → #27` order safe rather than
backwards: when Jobs folds into Settings, `jobs = true` in somebody's
config is a key nothing asks about.
**Two states the user could not get out of are refused, in the config
and not in the checkbox.** Settings is never hideable and the launch
page is not hideable while it is the launch page. `config.toml` is
hand-editable, so a disabled checkbox is the affordance and
`SetViewVisible` is the rule — an app that can be locked out of its own
Settings by a typo in TOML is a support problem nobody can debug
remotely. On *load* the launch page is instead un-hidden rather than
refused: there is nobody to tell, and the honest reading of "my launch
page is Autotag" is that this user wants Autotag, not that their launch
page should be silently reset to something they did not choose.
**Downloads is gated at the nav and not in the config**, on
`downloadStore.available`, so switching it on in Settings still means
what it says once a client exists and the tab appears without a restart
(#37's rule). `available` is false until the providers have loaded,
which makes the item *appear* on a fresh launch rather than appearing
and then vanishing.
**The tab bar honours the toggles too, and the reason is local rather
than a general rule about phones.** `PHONE_COLUMN_IDS` is the precedent
for "what a phone shows is a different question", and it would apply —
except that `bottom-nav`'s "More" opens the *same* `<app-sidebar>`,
which filters, so an unfiltered bar would contradict its own drawer one
tap away. Which four tabs is still plan 016's committed subset; this
only removes from it, and "More" is never filtered because it is how
everything else stays reachable.
**A retired destination is the one shape this does not make free.** An
absent visibility key takes its default and an unknown one is dropped,
but `DefaultPage` is a *value*: a launch page naming a view that no
longer exists fails validation, and on the load path that means the app
refuses to start for whoever had it selected. `RetiredViews` is that
list, and `ApplyDefaults` treats a retired name as a zero value while
an unknown-but-not-retired one still errors — a typo is worth being
told about. #27 retiring `jobs` is its first entry.
**The list of destinations is `services/view-meta.ts`**, on
`shortcut-meta.ts`'s pattern, because #25 gave it a second reader:
Settings renders a toggle per view and needs the same labels in the
same order. Which views exist and what an unconfigured install shows is
Go's (`backend/config.Views`, which `DefaultPage`'s validation reads
too, so the launchable set is not a second list); how they are *drawn*
is the frontend's, beside the rest of the icon vocabulary. The binding
returns the **resolved** map for every view, so the frontend holds no
copy of the defaults — which would be the copy that shipped in the
binary rather than the one being edited.
**A primary view is cached, not unmounted.** `index.ts` keeps every **A primary view is cached, not unmounted.** `index.ts` keeps every
primary view in the DOM and toggles a `.view-hidden` class, because that primary view in the DOM and toggles a `.view-hidden` class, because that
is what preserves `scrollTop` across navigation — so is what preserves `scrollTop` across navigation — so
@@ -1336,6 +1520,58 @@ 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 themselves already existed; what was new is that they are a promise and
that the queue panel is inside it. 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
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
ResizeObserver, every pass starting from all-visible, hiding the
lowest-priority child until it fits.
Five things about it are load-bearing.
**It is measured rather than breakpointed for a reason specific to this
bar**: three of its five children are as wide as their *content* — the
library filter is a `<select>` sized by the longest library name, the
indicator by the running job's title, the search box by its view-scoped
placeholder — so any width picked is right for one library, one job and
one view. Swept with a long-titled scan staged, the bar overflowed at
**every** width from 600 to 899 *and* at 900 where `nav-history`
appears, while 899 fits; a breakpoint fixing "600 to 610" would have
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
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
it below 600px and its `sr-only` live region is what announces the
state either way.
**The wordmark yields its width, not its existence.** The collapsed
rule is visually-hidden rather than `display: none`, because that `h1`
is the document's top-level heading as well as the brand.
**"Fits" is the children against the content box, and `scrollWidth`
cannot express it.** `scrollWidth` counts a box's left padding and not
its right, so with 2em gutters it under-reports by 32px: the first fix
read `700/700` — a perfect fit — with the indicator sitting in the
whole right gutter. Same family as #69's title trap, and found only
because `top-bar-fit.spec.ts` measures **per child**, which is what
`layout-overflow.spec.ts` cannot do and why that spec was green
throughout the defect.
And **the bar does not resize when a job starts**, which is the case the
whole thing is for — a ResizeObserver on the header alone never fires,
so every element child is observed too.
**900 is the worst desktop width, not the 800×600 minimum.** The **900 is the worst desktop width, not the 800×600 minimum.** The
sidebar collapses to icons *below* 900, so the main panel is 843px at sidebar collapses to icons *below* 900, so the main panel is 843px at
899 and 700px at 900 — the narrowest content area any desktop width 899 and 700px at 900 — the narrowest content area any desktop width
@@ -1980,6 +2216,79 @@ that corrects itself a moment later is worse than saying nothing. And
the field, the direction and their persistence, so the control cannot the field, the direction and their persistence, so the control cannot
disagree with the list. disagree with the list.
**And an action is data, on that same rule: the header decides what
fits, the host decides what happens.** Playlists slotted three buttons
totalling 390px into a header that gets 700px at 900×600, so "New Smart
Playlist" rendered **114 of its 162px** with the queue closed — and on a
phone none of them could be reached at all, which is what #69 reported.
A host passes `PageAction[]` (`{id, label, icon, onSelect, priority,
drop?}`) and `page-header` renders each one as a button or as an item in
one "More actions" menu.
**It could not have been a rule added in one place**, and that is a fact
about the API rather than an effort estimate: actions used to arrive
through `<slot name="actions">` as arbitrary light-DOM markup, and a
component cannot move another component's light-DOM children into a
dropdown and keep their behaviour — there is nothing generic in markup
to render as a menu item. The slot survives for markup a data list
cannot express, at the stated cost that **a slotted action does not
collapse** and must therefore fit at 800×600.
Six things about it are load-bearing:
- **The fit is measured, never breakpointed.** A ResizeObserver drives
it, and each pass starts from *all visible* and hides the
lowest-priority action until it fits — so the collapsed set is a pure
function of the current width rather than of how the window got
there. A rule that only ever added to the set would never give a
button back, and one that adjusted by a step would need a hysteresis
band to stop it oscillating on the pixel where a button exactly fits.
- **"Fits" means nothing is clipped, which is not the same as the
header not overflowing.** The title can ellipsis, and the moment it
can it absorbs the pressure: `scrollWidth` reports a header that fits
perfectly while the heading reads "Playlis…". That is this bug moved
from the button to the title, invisible to the same measurement that
missed it the first time — so the heading's own truncation counts as
not fitting, and an action is collapsed before the title gives way.
Below that, at 320px, the title *is* what yields: the navigation also
says which page you are on, and an action has nowhere else to be said.
- **The measurement flips `hidden` on the rendered nodes rather than
re-rendering between steps.** Reading `scrollWidth` forces layout,
which is the point; awaiting a Lit update between steps instead lets
the intermediate all-visible state paint, so the fix would flash the
overflow it exists to prevent.
- **Priority is what a *capability* costs, not what a button is worth.**
New Playlist is highest because it is the **drop target** and a closed
menu cannot be one; that is also why `PageAction.drop` carries the
host's own `dragover`/`dragleave`/`drop` handlers rather than the
header owning a notion of dropping, and why the affordance is simply
absent from the overflow rather than approximated there.
- **`aria-controls` names a panel that is always in the DOM** —
`config-section`'s rule, and `wa-popup` hides it when inactive — and
the keyboard model is `MenuKeyboard`, shared with every other menu in
the app so this is not a second one.
- **It is checked per button, because `layout-overflow.spec.ts` cannot
see this.** That spec asserts the *shell* needs no sideways
scrolling and passed on the broken build; clipping *inside* a
component is invisible to it, which is exactly why the defect
survived a spec named for it.
`e2e/specs/header-action-overflow.spec.ts` measures each button
against its header at 900×600, 800×600, 390×780 and 320×600, and
asserts buttons **plus** menu account for every declared action —
without that half it would pass vacuously on a build that renders no
actions at all.
One thing it deliberately does **not** grow is a phone mode for the
actions. `PHONE_COLUMN_IDS` is the precedent for "what is drawn and
what can be sorted are different questions", but it exists because the
track list's columns cannot be derived from a width; these can, and a
second declaration of what a phone shows is a second thing to keep in
step. What the header *does* state at phone width is one word: below
600px the sort control's "Sort:" label is visually hidden — 172px of a
320px header for a label the adjacent direction arrow implies — and it
stays in the accessibility tree, because it is the select's accessible
name and hiding it outright is `config-field`'s bug one component over.
**The header search box is view-scoped, and now says so.** It sits in **The header search box is view-scoped, and now says so.** It sits in
the app header and reads as global; typing `tide` on Playlists answered the app header and reads as global; typing `tide` on Playlists answered
"No playlists match your search" with three *Tideline* tracks in the "No playlists match your search" with three *Tideline* tracks in the
+76 -1
View File
@@ -544,7 +544,7 @@ func (c *Config) SetDefaultPage(page string) error {
c.General.ApplyDefaults() c.General.ApplyDefaults()
} }
c.General.DefaultPage = DefaultPage(page) c.General.DefaultPage = View(page)
if err := c.General.Validate(); err != nil { if err := c.General.Validate(); err != nil {
return fmt.Errorf( return fmt.Errorf(
@@ -666,6 +666,81 @@ func (c *Config) SetAllowMeteredCatalogDownload(allow bool) error {
return nil return nil
} }
// GetViewVisibility reports which primary views the sidebar should
// show, answered for every known view rather than only the ones the
// config mentions -- so the frontend filters on a value and never has
// to hold a second copy of the defaults.
func (c *Config) GetViewVisibility() map[string]bool {
if c.General == nil {
general := &GeneralConfig{}
general.ApplyDefaults()
return general.ResolvedViewVisibility()
}
return c.General.ResolvedViewVisibility()
}
// SetViewVisible shows or hides one primary view.
//
// Two refusals, both about a state the user cannot get out of from the
// UI they would be left with: Settings is never hideable, and the
// launch page is never hideable while it is the launch page (change it
// first). Hiding a view does not make it unreachable -- `navigate`
// still resolves it, which detail views depend on -- it only takes the
// nav item away.
func (c *Config) SetViewVisible(view string, visible bool) error {
spec, known := LookupView(view)
if !known {
return fmt.Errorf("%w: %q", errUnknownView, view)
}
if c.General == nil {
c.General = &GeneralConfig{}
c.General.ApplyDefaults()
}
if !visible {
if !spec.Hideable {
return fmt.Errorf("%w: %q", errViewNotHideable, view)
}
if spec.ID == c.General.DefaultPage {
return fmt.Errorf("%w: %q", errViewIsLaunchPage, view)
}
}
if c.General.ViewVisibility == nil {
c.General.ViewVisibility = make(map[string]bool, len(Views))
}
c.General.ViewVisibility[view] = visible
if err := c.General.Validate(); err != nil {
return fmt.Errorf("invalid view visibility: %w", err)
}
if err := c.Save(); err != nil {
return fmt.Errorf("could not save config: %w", err)
}
events.Emit(
c.ctx,
events.GeneralConfigChanged,
map[string]any{
"ViewVisibility": c.General.ResolvedViewVisibility(),
},
)
c.logger.Info(
"view visibility updated",
"view", view,
"visible", visible,
)
return nil
}
// GetTrackListColumns returns the configured track-list columns. // GetTrackListColumns returns the configured track-list columns.
func (c *Config) GetTrackListColumns() []tracklist.Column { func (c *Config) GetTrackListColumns() []tracklist.Column {
if c.TrackList == nil { if c.TrackList == nil {
+86 -26
View File
@@ -5,27 +5,13 @@ import (
"fmt" "fmt"
) )
// DefaultPage identifies which view the app opens to on launch.
type DefaultPage string
// Valid DefaultPage values, matching the frontend's top-level route ids.
const (
DefaultPageHome DefaultPage = "home"
DefaultPageTracks DefaultPage = "tracks"
DefaultPageAlbums DefaultPage = "albums"
DefaultPageArtists DefaultPage = "artists"
DefaultPageGenres DefaultPage = "genres"
DefaultPagePlaylists DefaultPage = "playlists"
DefaultPageExplore DefaultPage = "explore"
DefaultPageDownloads DefaultPage = "downloads"
DefaultPageAutotag DefaultPage = "autotag"
DefaultPageJobs DefaultPage = "jobs"
)
// DefaultDefaultPage is the launch page for a fresh install. // DefaultDefaultPage is the launch page for a fresh install.
const DefaultDefaultPage = DefaultPageHome const DefaultDefaultPage = ViewHome
var errUnknownDefaultPage = errors.New("unknown default page") var (
errUnknownDefaultPage = errors.New("unknown default page")
errViewCannotLaunch = errors.New("view cannot be the launch page")
)
// QueueFallback identifies what plays, if anything, once the queue // QueueFallback identifies what plays, if anything, once the queue
// runs out with nothing left to auto-advance to. // runs out with nothing left to auto-advance to.
@@ -46,8 +32,22 @@ var errUnknownQueueFallback = errors.New("unknown queue fallback")
// GeneralConfig holds general application preferences that don't // GeneralConfig holds general application preferences that don't
// belong to a more specific subsystem. // belong to a more specific subsystem.
type GeneralConfig struct { type GeneralConfig struct {
DefaultPage DefaultPage `toml:"DefaultPage"` DefaultPage View `toml:"DefaultPage"`
QueueFallback QueueFallback `toml:"QueueFallback"` QueueFallback QueueFallback `toml:"QueueFallback"`
// ViewVisibility says which sidebar destinations are shown, keyed by
// view id.
//
// **An absent key means that view's own default** (`Views`), and that
// is the whole reason this is a map rather than a `HiddenViews
// []string` or a struct of booleans. A list's zero value is "hide
// nothing", which cannot express Autotag being off by default without
// a migration; a struct field for a view that later stops existing is
// stored garbage somebody has to deprecate. Here a view added later
// gets its own default rather than being invisible or forcibly
// visible, an unknown key is dropped on load, and no install needs
// migrating in either direction. Same polarity rule as
// AllowMeteredCatalogDownload: the zero value is the intended answer.
ViewVisibility map[string]bool `toml:"ViewVisibility"`
// AllowMeteredCatalogDownload permits the ~0.6 GB Explore catalog to // AllowMeteredCatalogDownload permits the ~0.6 GB Explore catalog to
// be fetched on a connection the platform calls cellular. It defaults // be fetched on a connection the platform calls cellular. It defaults
// to false, which is the whole point: the zero value is the safe one, // to false, which is the whole point: the zero value is the safe one,
@@ -57,7 +57,17 @@ type GeneralConfig struct {
} }
// ApplyDefaults fills zero-value fields with sensible defaults. // ApplyDefaults fills zero-value fields with sensible defaults.
//
// A launch page naming a *retired* view is treated as a zero value
// rather than as an error, because the alternative is an app that will
// not start for anyone who had that page selected when it was removed.
// An unknown-but-not-retired name still fails Validate: that is a typo,
// and telling someone about it is the useful answer.
func (c *GeneralConfig) ApplyDefaults() { func (c *GeneralConfig) ApplyDefaults() {
if _, retired := RetiredViews[c.DefaultPage]; retired {
c.DefaultPage = ""
}
if c.DefaultPage == "" { if c.DefaultPage == "" {
c.DefaultPage = DefaultDefaultPage c.DefaultPage = DefaultDefaultPage
} }
@@ -71,15 +81,17 @@ func (c *GeneralConfig) ApplyDefaults() {
func (c *GeneralConfig) Validate() error { func (c *GeneralConfig) Validate() error {
c.ApplyDefaults() c.ApplyDefaults()
switch c.DefaultPage { spec, known := LookupView(string(c.DefaultPage))
case DefaultPageHome, DefaultPageTracks, DefaultPageAlbums, DefaultPageArtists, if !known {
DefaultPageGenres, DefaultPagePlaylists, DefaultPageExplore, DefaultPageDownloads,
DefaultPageAutotag, DefaultPageJobs:
// Valid.
default:
return fmt.Errorf("%w: %q", errUnknownDefaultPage, c.DefaultPage) return fmt.Errorf("%w: %q", errUnknownDefaultPage, c.DefaultPage)
} }
if !spec.CanLaunch {
return fmt.Errorf("%w: %q", errViewCannotLaunch, c.DefaultPage)
}
c.normalizeViewVisibility()
switch c.QueueFallback { switch c.QueueFallback {
case QueueFallbackStop, QueueFallbackFavorites, QueueFallbackDynamicMix: case QueueFallbackStop, QueueFallbackFavorites, QueueFallbackDynamicMix:
// Valid. // Valid.
@@ -89,3 +101,51 @@ func (c *GeneralConfig) Validate() error {
return nil return nil
} }
// normalizeViewVisibility drops what the stored map may not say, and
// repairs the one invariant the shell depends on.
//
// Three things are dropped or forced, and all three are reachable only
// from a hand-edited config or from a version that knew different
// views: an unknown id (a view removed since, e.g. when #27 folds Jobs
// into Settings) says nothing to anybody; a view that is not Hideable
// cannot be false; and **the launch page is always visible**, because
// otherwise an install lands on a page with no nav item pointing at it.
//
// That last one is a *repair* here and an *error* at the setter
// (SetViewVisible), deliberately. On load there is nobody to tell and
// the honest reading of "my launch page is Autotag" is that this user
// wants Autotag, so it is un-hidden rather than the launch page being
// silently reset to something they did not choose. At the setter the
// user is right there and can act, so it refuses and says why.
func (c *GeneralConfig) normalizeViewVisibility() {
for id := range c.ViewVisibility {
spec, known := LookupView(id)
if !known || !spec.Hideable {
delete(c.ViewVisibility, id)
}
}
if visible, ok := c.ViewVisibility[string(c.DefaultPage)]; ok && !visible {
c.ViewVisibility[string(c.DefaultPage)] = true
}
}
// ResolvedViewVisibility answers for every known view, so no caller has
// to know the defaults -- the frontend included, which is why the
// binding returns this rather than the stored map.
func (c *GeneralConfig) ResolvedViewVisibility() map[string]bool {
resolved := make(map[string]bool, len(Views))
for _, v := range Views {
visible := v.VisibleByDefault
if stored, ok := c.ViewVisibility[string(v.ID)]; ok && v.Hideable {
visible = stored
}
resolved[string(v.ID)] = visible
}
return resolved
}
+103
View File
@@ -0,0 +1,103 @@
package config
import "errors"
var (
errUnknownView = errors.New("unknown view")
errViewNotHideable = errors.New("view cannot be hidden")
errViewIsLaunchPage = errors.New("view is the launch page")
)
// View identifies one of the shell's primary destinations -- the
// things the sidebar lists and `index.ts` knows as `VIEW_TAGS`.
type View string
// The primary views, in no particular order: the sidebar owns the order
// it draws them in, because that is presentation.
const (
ViewHome View = "home"
ViewPlaylists View = "playlists"
ViewArtists View = "artists"
ViewGenres View = "genres"
ViewAlbums View = "albums"
ViewTracks View = "tracks"
ViewExplore View = "explore"
ViewDownloads View = "downloads"
ViewAutotag View = "autotag"
ViewSettings View = "settings"
)
// RetiredViews are destinations that used to exist and no longer do.
//
// A *visibility* entry for a removed view needs no such list: it is a
// key in a map, and an unknown key is dropped on load. A `DefaultPage`
// is a **value**, and an unknown one fails validation -- which on the
// load path means the app refuses to start rather than a setting being
// ignored. So the one shape that cannot be retired for free is named
// here and reset to the default instead.
//
// `jobs` was folded into Settings by #27: library scans under
// Libraries, index work under Search Index, downloads under the
// download clients, and the autotag apply into the Autotag view.
var RetiredViews = map[View]struct{}{
"jobs": {},
}
// ViewSpec is what the backend knows about a destination. The label and
// the icon are deliberately absent: those are presentation, they live
// beside the rest of the app's icon vocabulary in
// `frontend/src/utils/icon-language.ts`, and a Go copy of them would be
// a second thing to keep in step for nothing.
type ViewSpec struct {
// ID is the view name the frontend navigates by.
ID View
// VisibleByDefault is what an install gets when the config says
// nothing about this view -- which is every install until somebody
// changes it, and every view added after this one shipped.
VisibleByDefault bool
// Hideable is false for Settings alone. It is a property of the
// view rather than a check in the setter because `config.toml` is
// hand-editable, and an app that can be locked out of its own
// Settings by a typo is a support problem nobody can debug
// remotely.
Hideable bool
// CanLaunch reports whether the view may be the launch page.
// Settings is the only one that may not, which is the shape the
// DefaultPage enum already had.
CanLaunch bool
}
// Views is the one list of primary destinations, in the order Settings
// offers them.
//
// It is the single source for three things that used to be written down
// separately: which views exist, which of them may be the launch page
// (`DefaultPage`'s validation reads it), and what an unconfigured
// install shows.
//
// Autotag is the one view hidden by default: it rewrites tags on disk,
// which is not what most libraries want on day one, and #25 asks for it
// to be turned on deliberately.
var Views = []ViewSpec{
{ID: ViewHome, VisibleByDefault: true, Hideable: true, CanLaunch: true},
{ID: ViewPlaylists, VisibleByDefault: true, Hideable: true, CanLaunch: true},
{ID: ViewArtists, VisibleByDefault: true, Hideable: true, CanLaunch: true},
{ID: ViewGenres, VisibleByDefault: true, Hideable: true, CanLaunch: true},
{ID: ViewAlbums, VisibleByDefault: true, Hideable: true, CanLaunch: true},
{ID: ViewTracks, VisibleByDefault: true, Hideable: true, CanLaunch: true},
{ID: ViewExplore, VisibleByDefault: true, Hideable: true, CanLaunch: true},
{ID: ViewDownloads, VisibleByDefault: true, Hideable: true, CanLaunch: true},
{ID: ViewAutotag, VisibleByDefault: false, Hideable: true, CanLaunch: true},
{ID: ViewSettings, VisibleByDefault: true, Hideable: false, CanLaunch: false},
}
// LookupView returns the spec for a view id.
func LookupView(id string) (ViewSpec, bool) {
for _, v := range Views {
if string(v.ID) == id {
return v, true
}
}
return ViewSpec{}, false
}
+307
View File
@@ -0,0 +1,307 @@
package config
import (
"errors"
"log/slog"
"path/filepath"
"testing"
)
// newViewTestConfig builds a Config backed by a temp file, which is all
// SetViewVisible needs: it saves and emits, and the emit is a no-op
// without a running app.
func newViewTestConfig(t *testing.T) *Config {
t.Helper()
c := &Config{
logger: slog.Default(),
filePath: filepath.Join(t.TempDir(), "config.toml"),
}
// Load a file that is not there: that is what marks the config
// loaded, without which Save refuses on the *second* write.
if err := c.Load(); err != nil {
t.Fatalf("Load() error: %v", err)
}
return c
}
// A view the config says nothing about takes its own default, which is
// what makes this need no migration in either direction: an existing
// install gets Autotag hidden without a key, and a view added later
// gets its own answer rather than the list's.
func TestViewVisibilityDefaults(t *testing.T) {
t.Parallel()
general := &GeneralConfig{}
general.ApplyDefaults()
resolved := general.ResolvedViewVisibility()
if len(resolved) != len(Views) {
t.Fatalf("resolved %d views, want %d", len(resolved), len(Views))
}
if resolved[string(ViewAutotag)] {
t.Error("autotag should be hidden by default")
}
for _, v := range Views {
if v.ID == ViewAutotag {
continue
}
if !resolved[string(v.ID)] {
t.Errorf("%s should be visible by default", v.ID)
}
}
}
// A stored answer wins over the default, in both directions -- turning
// Autotag on is the whole user-facing point.
func TestViewVisibilityStoredWins(t *testing.T) {
t.Parallel()
general := &GeneralConfig{
ViewVisibility: map[string]bool{
string(ViewAutotag): true,
string(ViewExplore): false,
},
}
general.ApplyDefaults()
resolved := general.ResolvedViewVisibility()
if !resolved[string(ViewAutotag)] {
t.Error("autotag was switched on and should be visible")
}
if resolved[string(ViewExplore)] {
t.Error("explore was switched off and should be hidden")
}
}
// A key for a view that no longer exists is discarded rather than
// migrated. This is the property the #25-before-#27 ordering rests on:
// when Jobs folds into Settings, `jobs = true` in somebody's config is
// a key nothing asks about, not a cleanup task.
func TestValidateDropsUnknownAndUnhideableViews(t *testing.T) {
t.Parallel()
general := &GeneralConfig{
ViewVisibility: map[string]bool{
"a-view-that-was-removed": true,
string(ViewSettings): false,
string(ViewAutotag): true,
},
}
if err := general.Validate(); err != nil {
t.Fatalf("Validate() error: %v", err)
}
if _, ok := general.ViewVisibility["a-view-that-was-removed"]; ok {
t.Error("an unknown view id should be dropped on load")
}
if _, ok := general.ViewVisibility[string(ViewSettings)]; ok {
t.Error("settings is not hideable and should not be stored")
}
if !general.ResolvedViewVisibility()[string(ViewSettings)] {
t.Error("settings must resolve visible whatever the file said")
}
}
// On load there is nobody to tell, so a launch page hidden by a
// hand-edited file is un-hidden rather than the launch page being
// reset to something the user did not choose.
func TestValidateRevealsAHiddenLaunchPage(t *testing.T) {
t.Parallel()
general := &GeneralConfig{
DefaultPage: ViewAutotag,
ViewVisibility: map[string]bool{
string(ViewAutotag): false,
},
}
if err := general.Validate(); err != nil {
t.Fatalf("Validate() error: %v", err)
}
if !general.ResolvedViewVisibility()[string(ViewAutotag)] {
t.Error("the launch page must be visible")
}
}
// A launch page naming a view that no longer exists resets to the
// default instead of failing validation, which on the load path would
// mean the app refusing to start for whoever had it selected.
//
// This is the one shape #25's storage decision does *not* make free: a
// visibility entry is a key and an unknown key is dropped, but a launch
// page is a value.
func TestARetiredLaunchPageFallsBackToTheDefault(t *testing.T) {
t.Parallel()
general := &GeneralConfig{DefaultPage: "jobs"}
if err := general.Validate(); err != nil {
t.Fatalf("Validate() error: %v", err)
}
if general.DefaultPage != DefaultDefaultPage {
t.Errorf("DefaultPage = %q, want %q", general.DefaultPage, DefaultDefaultPage)
}
}
// A name that is merely wrong is still an error: that is a typo, and
// saying so is more useful than ignoring it.
func TestAnUnknownLaunchPageIsStillAnError(t *testing.T) {
t.Parallel()
general := &GeneralConfig{DefaultPage: "nonsense"}
if err := general.Validate(); !errors.Is(err, errUnknownDefaultPage) {
t.Fatalf("Validate() error = %v, want errUnknownDefaultPage", err)
}
}
// A retired view is not a view, so nothing offers it and nothing
// resolves it -- the visibility map included.
func TestARetiredViewIsGone(t *testing.T) {
t.Parallel()
for id := range RetiredViews {
if _, ok := LookupView(string(id)); ok {
t.Errorf("%s is retired but still in Views", id)
}
general := &GeneralConfig{}
general.ApplyDefaults()
if _, ok := general.ResolvedViewVisibility()[string(id)]; ok {
t.Errorf("%s is retired but still resolves a visibility", id)
}
}
}
// Settings may not be the launch page, which is the shape the old
// DefaultPage enum had and is now read off the same table.
func TestValidateRejectsAnUnlaunchablePage(t *testing.T) {
t.Parallel()
general := &GeneralConfig{DefaultPage: ViewSettings}
err := general.Validate()
if !errors.Is(err, errViewCannotLaunch) {
t.Fatalf("Validate() error = %v, want errViewCannotLaunch", err)
}
}
// At the setter the user is present and can act, so the two states
// they could not get out of are refused rather than repaired.
func TestSetViewVisibleRefusals(t *testing.T) {
t.Parallel()
tests := []struct {
name string
view string
visible bool
want error
}{
{"settings is never hideable", string(ViewSettings), false, errViewNotHideable},
{"the launch page is not hideable", string(ViewHome), false, errViewIsLaunchPage},
{"an unknown view is not a setting", "nonsense", false, errUnknownView},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
c := newViewTestConfig(t)
err := c.SetViewVisible(tt.view, tt.visible)
if !errors.Is(err, tt.want) {
t.Fatalf("SetViewVisible() error = %v, want %v", err, tt.want)
}
})
}
}
// Showing a view is never refused, including Settings and the launch
// page -- there is no state to be stuck in.
func TestSetViewVisibleShowsAnything(t *testing.T) {
t.Parallel()
c := newViewTestConfig(t)
for _, v := range Views {
if err := c.SetViewVisible(string(v.ID), true); err != nil {
t.Fatalf("SetViewVisible(%q, true) error: %v", v.ID, err)
}
}
if !c.GetViewVisibility()[string(ViewAutotag)] {
t.Error("autotag was switched on and should be visible")
}
}
// The stored map survives a save/load round trip, which is what a
// map-valued TOML key is worth checking for.
func TestViewVisibilityRoundTrips(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "config.toml")
original := &Config{logger: slog.Default(), filePath: path}
if err := original.Load(); err != nil {
t.Fatalf("Load() error: %v", err)
}
if err := original.SetViewVisible(string(ViewAutotag), true); err != nil {
t.Fatalf("SetViewVisible() error: %v", err)
}
if err := original.SetViewVisible(string(ViewExplore), false); err != nil {
t.Fatalf("SetViewVisible() error: %v", err)
}
loaded := &Config{logger: slog.Default(), filePath: path}
if err := loaded.Load(); err != nil {
t.Fatalf("Load() error: %v", err)
}
resolved := loaded.GetViewVisibility()
if !resolved[string(ViewAutotag)] {
t.Error("autotag should have loaded as visible")
}
if resolved[string(ViewExplore)] {
t.Error("explore should have loaded as hidden")
}
}
// Every view the shell can launch into is a view the sidebar can show,
// or an install could land on a page with no nav item and no setting
// pointing at it.
func TestEveryLaunchableViewIsAView(t *testing.T) {
t.Parallel()
for _, v := range Views {
if !v.CanLaunch {
continue
}
if !v.Hideable {
continue
}
if _, ok := LookupView(string(v.ID)); !ok {
t.Errorf("%s is launchable but not a known view", v.ID)
}
}
}
+10 -1
View File
@@ -24,10 +24,19 @@ func DefaultBindings() map[string]string {
"player.repeat": "R", "player.repeat": "R",
"player.mute": "M", "player.mute": "M",
// Navigation (Global scope) // Navigation (Global scope). Back and forward are the browser's
// own combination on every platform, which is the whole design
// brief for them: the app has one global history and this is the
// gesture people already have for it. The modifier is what keeps
// them clear of `player.seekBack`/`seekForward`, which are the
// bare arrows -- a binding is matched on its full canonical
// string, so "Alt+Left" and "Left" are different keys and not a
// conflict.
"nav.search": "/", "nav.search": "/",
"nav.searchAlt": "Ctrl+F", "nav.searchAlt": "Ctrl+F",
"nav.queue": "Q", "nav.queue": "Q",
"nav.back": "Alt+Left",
"nav.forward": "Alt+Right",
// App actions // App actions
"app.selectAll": "Ctrl+A", "app.selectAll": "Ctrl+A",
+244
View File
@@ -15,12 +15,49 @@ import { test, expect } from '../support/fixtures.js';
* *
* What it cannot answer is whether Android's *gesture* reaches the * What it cannot answer is whether Android's *gesture* reaches the
* WebView, which is between the OS and the scaffold. * WebView, which is between the OS and the scaffold.
*
* **And `data-active-view` is not the behaviour.** Every assertion here
* used to be that attribute, which the shell sets on every path
* including `_isBack` — so this file was green throughout #72, in
* which both navs highlighted the view the user had just *left*. The
* shell's own bookkeeping was the one thing that was already right;
* what a person sees is `aria-current`, and that is asserted below as
* well. This is the same trap `layout-overflow.spec.ts` set for #69: a
* spec named for the behaviour, measuring the plumbing.
*/ */
type Page = import('@playwright/test').Page; type Page = import('@playwright/test').Page;
const activeView = (page: Page) => const activeView = (page: Page) =>
page.getByTestId('main-content'); page.getByTestId('main-content');
/** A common phone, where the bottom bar is the primary navigation. */
const PHONE = { width: 390, height: 844 };
/**
* The nav item for a destination, in whichever navigation is on screen.
*
* Both navs carry a button named `Albums`, and only one of them is ever
* in the accessibility tree — the other is `display: none` — so the
* role query resolves to the one the user can see at this viewport.
* That is the point: the highlight has to be right in both, and #72 was
* two different-looking symptoms of one cause.
*/
const navItem = (page: Page, label: string) =>
page.getByRole('button', { name: label, exact: true });
/**
* `aria-current="page"` is the accessible fact and the assertion worth
* making; `.active` is a class and could be restyled without breaking
* anything real.
*/
async function expectHighlighted(page: Page, label: string): Promise<void> {
await expect(navItem(page, label)).toHaveAttribute('aria-current', 'page');
}
async function expectNotHighlighted(page: Page, label: string): Promise<void> {
await expect(navItem(page, label)).toHaveAttribute('aria-current', 'false');
}
/** /**
* Open an artist's detail view, which is the deepest ordinary route. * Open an artist's detail view, which is the deepest ordinary route.
* *
@@ -42,6 +79,114 @@ async function openAnArtist(app: Page): Promise<void> {
); );
} }
/**
* The global back/forward control (#6).
*
* It is desktop chrome — hidden below 900px, where the sidebar has
* already given up its labels — so these set a desktop viewport
* explicitly rather than trusting the runner's default.
*/
const DESKTOP = { width: 1280, height: 800 };
const backButton = (page: Page) =>
page.locator('nav-history').getByRole('button', { name: 'Back' });
const forwardButton = (page: Page) =>
page.locator('nav-history').getByRole('button', { name: 'Forward' });
test.describe('global back and forward', () => {
test.beforeEach(async ({ app }) => {
await app.setViewportSize(DESKTOP);
});
test('offers nothing at launch, in either direction', async ({ app }) => {
// The launch entry is *replaced*, not pushed, so there is nothing
// of ours behind it — and a Back button that is live at the root
// is a press that does nothing on desktop and, on Android, the
// press that should have exited the app (#142). This assertion is
// what pins that: it failed before the launch navigation stopped
// recording two entries.
await expect(backButton(app)).toBeDisabled();
await expect(forwardButton(app)).toBeDisabled();
});
test('walks the history in both directions, and says which are available', async ({
app,
}) => {
await app.getByTestId('nav-albums').click();
await expect(activeView(app)).toHaveAttribute('data-active-view', 'albums');
await expect(backButton(app)).toBeEnabled();
await expect(forwardButton(app)).toBeDisabled();
await app.getByTestId('nav-tracks').click();
await expect(activeView(app)).toHaveAttribute('data-active-view', 'tracks');
await backButton(app).click();
await expect(activeView(app)).toHaveAttribute('data-active-view', 'albums');
// Standing in the middle of the list: both directions live, which
// is the state a single depth counter cannot express.
await expect(backButton(app)).toBeEnabled();
await expect(forwardButton(app)).toBeEnabled();
await forwardButton(app).click();
await expect(activeView(app)).toHaveAttribute('data-active-view', 'tracks');
await expect(forwardButton(app)).toBeDisabled();
});
test('reaches the detail view a tab click left behind', async ({ app }) => {
// The report, exactly: the album is one entry away the whole time,
// and before this control the only way back to it was a button
// that had gone off screen with the view it belonged to.
await app.getByTestId('nav-artists').click();
await openAnArtist(app);
await app.getByTestId('nav-tracks').click();
await expect(activeView(app)).toHaveAttribute('data-active-view', 'tracks');
await backButton(app).click();
await expect(activeView(app)).toHaveAttribute(
'data-active-view',
'explore-artist-details',
);
});
test('drops the forward list when the user navigates from the middle', async ({
app,
}) => {
await app.getByTestId('nav-albums').click();
await app.getByTestId('nav-tracks').click();
await backButton(app).click();
await expect(forwardButton(app)).toBeEnabled();
// A browser truncates here, and so does this: what was ahead is no
// longer reachable, and a Forward button still offering it would
// be pointing at an entry that has been overwritten.
await app.getByTestId('nav-genres').click();
await expect(activeView(app)).toHaveAttribute('data-active-view', 'genres');
await expect(forwardButton(app)).toBeDisabled();
await expect(backButton(app)).toBeEnabled();
});
test('is absent below the desktop band, where nothing needs it', async ({
app,
}) => {
// Alt+Left/Right survive at every width, the detail views keep
// their own back buttons and the phone has the platform's gesture
// — so this is a control standing down, not an action becoming
// unreachable. It is hidden at 899 because the top bar is what
// runs out of room first below 900 (#143).
await app.setViewportSize({ width: 899, height: 600 });
await expect(app.locator('nav-history')).toBeHidden();
await app.setViewportSize({ width: 390, height: 844 });
await expect(app.locator('nav-history')).toBeHidden();
});
});
test.describe('the back gesture', () => { test.describe('the back gesture', () => {
test('leaves a detail view for the view it was opened from', async ({ test('leaves a detail view for the view it was opened from', async ({
app, app,
@@ -71,6 +216,105 @@ test.describe('the back gesture', () => {
await expect(activeView(app)).toHaveAttribute('data-active-view', 'albums'); await expect(activeView(app)).toHaveAttribute('data-active-view', 'albums');
}); });
test('leaves the nav highlighting the view it landed on, not the one it left', async ({
app,
}) => {
await app.getByTestId('nav-albums').click();
await expectHighlighted(app, 'Albums');
await app.getByTestId('nav-tracks').click();
await expectHighlighted(app, 'Tracks');
await app.goBack();
// #72, and the half of it the report did not describe: this is
// desktop, and before the shell published the active view *both*
// navs stayed on Tracks. An absent highlight reads as a glitch; a
// confident wrong one is worse, and any back across two primary
// views produced it.
await expect(activeView(app)).toHaveAttribute('data-active-view', 'albums');
await expectHighlighted(app, 'Albums');
await expectNotHighlighted(app, 'Tracks');
});
test('keeps the parent destination lit while a detail view is open', async ({
app,
}) => {
await app.getByTestId('nav-artists').click();
await expectHighlighted(app, 'Artists');
await openAnArtist(app);
// A detail view is not a destination in either nav, and the user is
// still inside Artists. `app-sidebar` did this by accident -- it
// guarded on its own item list, so an unmatched name left the
// highlight alone -- and that accident is why the sidebar looked
// right on a detail view while the tab bar lit nothing. This test
// therefore passed before the fix and is here to keep the rule from
// being lost while the others are made to pass; the *tab bar's*
// half of it is the phone test below, which did not.
await expectHighlighted(app, 'Artists');
await app.goBack();
await expectHighlighted(app, 'Artists');
});
test('the tab bar survives the same journey on a phone', async ({ app }) => {
await app.setViewportSize(PHONE);
// The reported shape: Albums, open an album, press back. The tab
// bar had a highlight, then no highlight at all, and never got it
// back — `bottom-nav` took the detail view's name, matched it
// against no tab, and lit nothing.
await navItem(app, 'Albums').click();
await expectHighlighted(app, 'Albums');
await app.locator('cover-grid').getByText('Glass Harbour').first().click();
await expect(activeView(app)).toHaveAttribute(
'data-active-view',
'explore-album-details',
);
await expectHighlighted(app, 'Albums');
await app.goBack();
await expect(activeView(app)).toHaveAttribute('data-active-view', 'albums');
await expectHighlighted(app, 'Albums');
});
test('the drawer sidebar opens on the page you are standing on', async ({
app,
}) => {
await app.setViewportSize(PHONE);
await navItem(app, 'Tracks').click();
await expectHighlighted(app, 'Tracks');
// A third symptom of the same cause, found while measuring #72 and
// not in the report: `bottom-nav` mounts its `<app-sidebar>` when
// the drawer opens, so that copy had heard no `navigate` at all and
// showed its own default — Home, from any page in the app. An event
// has no answer for a listener that was not there; a store does.
await navItem(app, 'More').click();
// The element carrying the testid is the `wa-drawer` host, which
// always reports hidden -- what is visible is the `<dialog>` in its
// shadow root -- so the drawer being open is asserted of the
// sidebar it holds rather than of itself.
const drawer = app.getByTestId('nav-drawer');
await expect(drawer.locator('app-sidebar')).toBeVisible();
await expect(drawer.getByTestId('nav-tracks')).toHaveAttribute(
'aria-current',
'page',
);
await expect(drawer.getByTestId('nav-home')).toHaveAttribute(
'aria-current',
'false',
);
});
test('an in-app back button consumes exactly one entry', async ({ app }) => { test('an in-app back button consumes exactly one entry', async ({ app }) => {
await app.getByTestId('nav-tracks').click(); await app.getByTestId('nav-tracks').click();
await openAnArtist(app); await openAnArtist(app);
+8 -1
View File
@@ -36,9 +36,16 @@ test.describe('a failed binding says so', () => {
// Libraries is the one section that starts expanded (H-22), so ask // Libraries is the one section that starts expanded (H-22), so ask
// the disclosure what state it is in rather than assuming one — a // the disclosure what state it is in rather than assuming one — a
// blind click used to expand it and now collapses it. // blind click used to expand it and now collapses it.
//
// By role and name, not by `.header`: since #27 the section also
// contains a `job-panel`, and an open `job-details-drawer` inside
// it carries the same class. That only bites once a job exists,
// which is why it showed up on the *second* engine of a CI run and
// not the first.
const disclosure = page const disclosure = page
.locator('config-section[heading="Libraries"]') .locator('config-section[heading="Libraries"]')
.locator('.header'); .getByRole('button', { name: 'Libraries' })
.first();
if ((await disclosure.getAttribute('aria-expanded')) === 'false') { if ((await disclosure.getAttribute('aria-expanded')) === 'false') {
await disclosure.click(); await disclosure.click();
+318
View File
@@ -0,0 +1,318 @@
import { test, expect } from '../support/fixtures.js';
/**
* #69: the Playlists header's buttons could not be reached.
*
* Three text buttons — Import (91px), New Playlist (122px), New Smart
* Playlist (162px), 390px in total — inside a header that gets 700px at
* 900×600. "New Smart Playlist" rendered **114 of its 162px**, and at
* phone width the Android report was the plain version of it: you
* cannot scroll to reach them, and scrolling is not how page controls
* should be exposed anyway.
*
* **`layout-overflow.spec.ts` passes on the broken build**, which is why
* this file exists rather than a case being added there. That spec
* asserts the *shell* needs no sideways scrolling; clipping *inside* a
* component is invisible to it. So the measurement here is per-button
* and per-header, against the widths the app promises.
*
* Plan 018's size matrix is the promise being kept: **no action is ever
* unreachable at any supported size.** These are its three bands.
*/
const VIEWPORTS = [
// Desktop's worst case, and not the enforced minimum: the sidebar
// collapses to icons *below* 900, so the content area is 843px at 899
// and 700px at 900. Testing "the minimum" and stopping misses it.
{ name: '900×600 (widest sidebar, narrowest content)', width: 900, height: 600 },
{ name: '800×600 (the enforced minimum)', width: 800, height: 600 },
{ name: '390×780 (phone)', width: 390, height: 780 },
// WCAG 1.4.10's reflow target, which plan 018 promises the app fits.
{ name: '320×600 (400% zoom)', width: 320, height: 600 },
];
/** Every action the Playlists header can offer, in declared order. */
const ACTIONS = ['Import', 'New Playlist', 'New Smart Playlist'];
/**
* What the header is actually rendering, measured rather than inferred.
*
* A shadow query is the wrong tool for *asserting* — that is what
* `getByRole` below is for — but it is the right one for a measurement,
* because the number this issue is about (a button 48px wider than the
* box holding it) is not in the accessibility tree at all.
*/
const headerFit = (page: import('@playwright/test').Page) =>
page.evaluate(() => {
const root = document
.querySelector('[data-testid="main-content"] playlist-view')
?.shadowRoot?.querySelector('page-header')?.shadowRoot;
if (!root) return null;
const header = root.querySelector<HTMLElement>('.page-header')!;
const box = header.getBoundingClientRect();
const title = root.querySelector<HTMLElement>('h1')!;
const clipped = [
...root.querySelectorAll<HTMLElement>('.action, .more-button'),
]
.filter((b) => !b.hidden)
.filter((b) => {
const r = b.getBoundingClientRect();
return r.right > box.right + 1 || r.left < box.left - 1;
})
.map((b) => b.dataset['actionId'] ?? 'more');
return {
overflow: header.scrollWidth - header.clientWidth,
clipped,
titleTruncated: title.scrollWidth > title.clientWidth + 1,
buttons: [...root.querySelectorAll<HTMLElement>('.action')]
.filter((b) => !b.hidden)
.map((b) => b.textContent?.trim() ?? ''),
menu: [
...root.querySelectorAll('#page-header-overflow wa-dropdown-item'),
].map((i) => i.textContent?.trim() ?? ''),
};
});
test.describe('the page header never clips an action', () => {
test.beforeEach(async ({ app }) => {
await app.getByTestId('nav-playlists').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'playlists',
);
});
test.afterEach(async ({ app }) => {
await app.setViewportSize({ width: 1280, height: 800 });
});
for (const vp of VIEWPORTS) {
test(`every action is reachable at ${vp.name}`, async ({ app }) => {
await app.setViewportSize({ width: vp.width, height: vp.height });
// Polled: the fit is decided by a ResizeObserver, so it settles a
// frame after the resize rather than with it.
await expect
.poll(async () => (await headerFit(app))?.clipped)
.toEqual([]);
const fit = (await headerFit(app))!;
expect(fit.overflow).toBeLessThanOrEqual(0);
// Between them, buttons and menu account for all three. This is
// the assertion the issue asks for: not "it fits" but "nothing
// was dropped to make it fit".
expect([...fit.buttons, ...fit.menu].sort()).toEqual([...ACTIONS].sort());
});
}
/**
* The title gives way before an action does.
*
* Once the heading can ellipsis it absorbs the pressure, and
* `scrollWidth` then reports a header that fits perfectly while the
* heading reads "Playlis…" — this issue's own failure mode moved from
* the button to the title, and invisible to exactly the measurement
* that missed it the first time. At the desktop sizes there is always
* an action to collapse instead.
*/
test('does not truncate the heading to keep a button', async ({ app }) => {
for (const vp of VIEWPORTS.slice(0, 2)) {
await app.setViewportSize({ width: vp.width, height: vp.height });
await expect
.poll(async () => (await headerFit(app))?.titleTruncated)
.toBe(false);
}
});
/**
* Asserted through the accessibility tree, never a shadow query. An
* overflow menu is exactly the shape that grows a nameless control,
* and this repo has shipped one four times — most recently the
* queue's own close button.
*/
test('the overflow is a named control that opens a named menu', async ({
app,
}) => {
await app.setViewportSize({ width: 900, height: 600 });
const more = app.getByRole('button', { name: 'More actions' });
await expect(more).toBeVisible();
await expect(more).toHaveAttribute('aria-expanded', 'false');
await more.click();
await expect(more).toHaveAttribute('aria-expanded', 'true');
const menu = app.getByRole('menu', { name: 'More actions' });
await expect(menu).toBeVisible();
// Collapsed at 900×600: Import (lowest priority) and New Smart
// Playlist. New Playlist stays a button because it is the drop
// target, and a closed menu cannot be one.
await expect(
menu.getByRole('menuitem', { name: 'Import' }),
).toBeVisible();
await expect(
app.getByRole('button', { name: 'New Playlist', exact: true }),
).toBeVisible();
});
/**
* The phone case is the original report. Every action is in the menu
* at 390px, and the menu is reachable by name — which is the whole of
* "these need to be reachable in a sensible way".
*/
test('offers every action from the menu on a phone', async ({ app }) => {
await app.setViewportSize({ width: 390, height: 780 });
const more = app.getByRole('button', { name: 'More actions' });
await expect(more).toBeVisible();
await more.click();
const menu = app.getByRole('menu', { name: 'More actions' });
for (const label of ACTIONS) {
await expect(menu.getByRole('menuitem', { name: label })).toBeVisible();
}
});
/**
* Escape closes it and focus goes back to the trigger — `MenuKeyboard`
* is shared with every other menu in the app precisely so this is not
* a second keyboard model, and this is what proves it was wired up
* rather than merely imported.
*/
test('takes the keyboard, and gives it back', async ({ app }) => {
await app.setViewportSize({ width: 900, height: 600 });
const more = app.getByRole('button', { name: 'More actions' });
await more.click();
const menu = app.getByRole('menu', { name: 'More actions' });
await expect(menu).toBeVisible();
// The first item takes focus on open. `wa-dropdown-item` sets its
// own role in its own first update, so this is polled rather than
// read: a query at the host's updateComplete finds nothing, which
// reads exactly like a menu that refused to take focus.
await expect
.poll(async () =>
app.evaluate(() => {
// Stops where `MenuKeyboard`'s own `deepActiveElement` stops:
// on the *host* whose shadow root has no active element.
// Descending unconditionally lands inside the focused
// `wa-dropdown-item`'s own shadow root, where nothing is
// focused — which reads exactly like a menu that refused the
// keyboard, on a build where it did not.
let el = document.activeElement;
while (el?.shadowRoot?.activeElement) el = el.shadowRoot.activeElement;
return el?.textContent?.trim() ?? null;
}),
)
.toBe('Import');
await app.keyboard.press('Escape');
await expect(more).toHaveAttribute('aria-expanded', 'false');
await expect(more).toBeFocused();
});
/**
* New Playlist is a drop target, and declaring it as data must not
* take that away — which is why a `PageAction` carries the drop
* handlers rather than the header owning a notion of dropping.
*
* Nothing covered this before, in either tier, and it is the one
* behaviour the migration could plausibly have destroyed silently:
* dragging still *looks* fine against a button that no longer
* accepts anything.
*/
test('New Playlist still accepts a dropped track', async ({ app }) => {
await app.setViewportSize({ width: 1280, height: 800 });
const button = app.getByRole('button', {
name: 'New Playlist',
exact: true,
});
await expect(button).toBeVisible();
const result = await app.evaluate(async () => {
const view = document.querySelector(
'[data-testid="main-content"] playlist-view',
)!;
const target = view.shadowRoot!
.querySelector('page-header')!
.shadowRoot!.querySelector('[data-testid="page-action-new-playlist"]')!;
const data = new DataTransfer();
data.setData(
'application/x-yj-tracks',
JSON.stringify({ filePaths: ['/tmp/dropped.mp3'] }),
);
const fire = (type: string) =>
target.dispatchEvent(
new DragEvent(type, {
bubbles: true,
cancelable: true,
dataTransfer: data,
}),
);
fire('dragover');
await new Promise((r) => setTimeout(r, 50));
// The affordance is the host's state reaching the header's
// button, which is the half a plain handler call would not prove.
const highlighted = target.classList.contains('drag-over');
fire('drop');
await new Promise((r) => setTimeout(r, 200));
return {
highlighted,
opened: view.shadowRoot!.querySelector('.create-form') !== null,
};
});
expect(result).toEqual({ highlighted: true, opened: true });
// Leave the view as it was found.
await app.keyboard.press('Escape');
});
/**
* An action given back when the window widens again. The collapsed
* set is a function of the current width and not of how it got there
* — a rule that only ever *added* to it would never widen.
*/
test('gives the buttons back when the window grows', async ({ app }) => {
await app.setViewportSize({ width: 390, height: 780 });
await expect.poll(async () => (await headerFit(app))?.buttons).toEqual([]);
await app.setViewportSize({ width: 1440, height: 900 });
await expect
.poll(async () => (await headerFit(app))?.buttons)
.toEqual(ACTIONS);
await expect.poll(async () => (await headerFit(app))?.menu).toEqual([]);
});
});
+124
View File
@@ -0,0 +1,124 @@
import { test, expect, waitForEvent } from '../support/fixtures.js';
/**
* The Jobs tab folded into the places the work is started (#27).
*
* The assertion worth making is not that the tab is gone — that is one
* line of a table — but that **nothing became unreachable when it
* went**. Scanning is the case that mattered: the per-library controls
* lived only on that page, and the tab's own comment says they had been
* moved there out of Settings in the first place.
*
* `#24` wrote down one sentence covering all three size bands: *no
* action is ever unreachable at any supported size*. Deleting a
* destination is exactly the change that can quietly break it.
*/
type Page = import('@playwright/test').Page;
const section = (page: Page, heading: string) =>
page.locator(`config-page config-section[heading="${heading}"]`);
async function openSettings(page: Page, heading: string): Promise<void> {
await page.getByTestId('nav-settings').click();
// The section's own disclosure, by role rather than by `.header`:
// an open Libraries section also contains `job-details-drawer`,
// whose own header matches that class and makes it ambiguous.
const header = section(page, heading)
.getByRole('button', { name: heading })
.first();
await expect(header).toBeVisible();
if ((await header.getAttribute('aria-expanded')) === 'false') {
await header.click();
}
await expect(header).toHaveAttribute('aria-expanded', 'true');
}
test.describe('background jobs live where the work is started', () => {
test('the Jobs destination is gone', async ({ app }) => {
await expect(app.getByTestId('nav-jobs')).toHaveCount(0);
// And it is not offered as a launch page either, which is the copy
// of the destination list that is easiest to forget.
await openSettings(app, 'General');
const options = await section(app, 'General')
.locator('select')
.first()
.locator('option')
.allTextContents();
expect(options).not.toContain('Jobs');
});
/**
* Scanning is startable from Settings → Libraries, and the job that
* results is visible there with its controls. One assertion covers
* both halves, because a Scan All that started nothing would leave
* the panel empty and read exactly like a panel that does not work.
*/
test('a scan is started and watched in Settings', async ({ app }) => {
await openSettings(app, 'Libraries');
const libraries = section(app, 'Libraries');
await libraries.getByRole('button', { name: 'Scan All' }).click();
await waitForEvent(app, 'LibraryScanComplete', { timeoutMs: 60_000 });
const panel = libraries.locator('job-panel');
await expect(panel.locator('job-row')).toHaveCount(1, { timeout: 10_000 });
// The generic affordances are the point of the panel: the tier
// list and the progress rings the other surfaces already had
// cannot open a log.
await expect(
panel.getByRole('button', { name: /^Details/ }),
).toBeVisible();
});
/** A finished job dismisses from where it is shown. */
test('a finished scan can be dismissed in place', async ({ app }) => {
await openSettings(app, 'Libraries');
const panel = section(app, 'Libraries').locator('job-panel');
const dismiss = panel.getByRole('button', { name: /^Dismiss/ }).first();
await expect(dismiss).toBeVisible({ timeout: 10_000 });
await dismiss.click();
await expect(panel.locator('job-row')).toHaveCount(0);
});
/**
* Full rescan is destructive and asks first. It is asserted at the
* dialog rather than through it — running one against the seeded app
* would delete the library the rest of the suite reads.
*/
test('Full Rescan asks before it does anything', async ({ app }) => {
await openSettings(app, 'Libraries');
await section(app, 'Libraries')
.getByRole('button', { name: 'Full Rescan' })
.click();
const dialog = app.getByRole('dialog', { name: 'Full rescan' });
await expect(dialog).toBeVisible();
// The message is read off the *host*, not the dialog: a wa-dialog
// keeps its slotted content in the host's shadow root, so
// `toContainText` on the dialog itself sees only Web Awesome's
// chrome.
await expect(app.locator('confirm-dialog')).toContainText(
'deletes all library data',
);
await app.getByRole('button', { name: 'Cancel' }).click();
await expect(dialog).toBeHidden();
});
});
+37 -12
View File
@@ -33,6 +33,14 @@ const VIEWPORTS = [
// was missing its own worst case. // was missing its own worst case.
{ name: '900×600 (the widest sidebar, so the narrowest content)', width: 900, height: 600 }, { name: '900×600 (the widest sidebar, so the narrowest content)', width: 900, height: 600 },
{ name: `the minimum (${MIN_VIEWPORT.width}×${MIN_VIEWPORT.height})`, ...MIN_VIEWPORT }, { name: `the minimum (${MIN_VIEWPORT.width}×${MIN_VIEWPORT.height})`, ...MIN_VIEWPORT },
// Below the enforced minimum on purpose, and for the reason 700×480
// is below it further down: a scaled display or a large system font
// lands the layout here without the window ever being dragged there,
// and 600 is the last width before the phone layout takes over. The
// *narrowest header* is a different question from the narrowest
// content area and has a different answer — this one (#143), where
// the bar was 611px inside 600 sitting still.
{ name: '600×600 (the bottom of the Compact band)', width: 600, height: 600 },
]; ];
/** /**
@@ -93,9 +101,10 @@ test.describe('the app fits in its own window', () => {
) )
.toBe(true); .toBe(true);
// Settings and Jobs are the two that were unreachable: they are // Settings is the one that was unreachable: it is last in the nav,
// last in the nav, and the pane used to clip rather than scroll. // and the pane used to clip rather than scroll. (Jobs was the other
for (const view of ['jobs', 'settings'] as const) { // half of this until #27 folded it into Settings.)
for (const view of ['explore', 'settings'] as const) {
const item = app.getByTestId(`nav-${view}`); const item = app.getByTestId(`nav-${view}`);
await item.scrollIntoViewIfNeeded(); await item.scrollIntoViewIfNeeded();
@@ -144,6 +153,12 @@ test.describe('the app fits in its own window', () => {
test.describe('the title block fits its bar', () => { test.describe('the title block fits its bar', () => {
test('the hgroup stays inside the 4em top bar', async ({ app }) => { test('the hgroup stays inside the 4em top bar', async ({ app }) => {
// Stated rather than inherited from whatever ran last. Since #143
// the wordmark is visually hidden at widths where the bar cannot
// afford it, so a test about its *vertical* fit has to say which
// width it is asking about.
await app.setViewportSize({ width: 1440, height: 900 });
// The state a11y.29 landed in. The pair is flex-centred and a UA // The state a11y.29 landed in. The pair is flex-centred and a UA
// gives an `h1` a 0.67em top margin, so the block measured 67px // gives an `h1` a 0.67em top margin, so the block measured 67px
// inside 64 — pre-existing, and invisible until dropping the h3's // inside 64 — pre-existing, and invisible until dropping the h3's
@@ -245,16 +260,26 @@ test.describe('the shell reflows rather than hiding what does not fit', () => {
test(`no scrollbar appears at ${vp.name}`, async ({ app }) => { test(`no scrollbar appears at ${vp.name}`, async ({ app }) => {
await app.setViewportSize({ width: vp.width, height: vp.height }); await app.setViewportSize({ width: vp.width, height: vp.height });
const excess = await app.evaluate(() => { // Polled, for the reason the track-row test above is: since #143
const de = document.documentElement; // the top bar's fit is *measured* — a ResizeObserver decides what
// it can afford at this width — so a single read taken straight
// after the resize races the observer and reports the frame
// before it. Read once, this passed alone and failed in the full
// suite, which is the shape of a timing assumption rather than of
// a defect.
//
// The other half of the assertion: at every size this app
// promises, the fix costs nothing. A scrollbar that is always
// there is a worse answer than the clipping it replaced.
await expect
.poll(() =>
app.evaluate(() => {
const de = document.documentElement;
return de.scrollWidth - de.clientWidth; return de.scrollWidth - de.clientWidth;
}); }),
)
// The other half: at every size this app promises, the fix costs .toBe(0);
// nothing. A scrollbar that is always there is a worse answer
// than the clipping it replaced.
expect(excess).toBe(0);
}); });
} }
}); });
+1 -1
View File
@@ -28,7 +28,7 @@ const EXPECTED_MIN_ICONS = 5;
const VIEWS = [ const VIEWS = [
'home', 'tracks', 'albums', 'artists', 'genres', 'playlists', 'home', 'tracks', 'albums', 'artists', 'genres', 'playlists',
'explore', 'downloads', 'jobs', 'settings', 'explore', 'downloads', 'autotag', 'settings',
]; ];
type IconState = { name: string; hasSvg: boolean }; type IconState = { name: string; hasSvg: boolean };
+6 -4
View File
@@ -1,4 +1,4 @@
import { test, expect } from '../support/fixtures.js'; import { test, expect, navigateTo } from '../support/fixtures.js';
/** /**
* H-19: Playlists, Downloads, Jobs, Settings and Home had a page * H-19: Playlists, Downloads, Jobs, Settings and Home had a page
@@ -21,7 +21,6 @@ const VIEWS: [string, string, boolean][] = [
['tracks', 'Tracks', true], ['tracks', 'Tracks', true],
['explore', 'Explore', false], ['explore', 'Explore', false],
['downloads', 'Downloads', false], ['downloads', 'Downloads', false],
['jobs', 'Background jobs', false],
]; ];
/** The header lives in the view's shadow root, inside its own. */ /** The header lives in the view's shadow root, inside its own. */
@@ -53,13 +52,16 @@ const TAGS: Record<string, string> = {
tracks: 'track-list', tracks: 'track-list',
explore: 'explore-view', explore: 'explore-view',
downloads: 'downloads-view', downloads: 'downloads-view',
jobs: 'jobs-view',
}; };
test.describe('every primary view says what it is', () => { test.describe('every primary view says what it is', () => {
test('each one has the shared header, with a heading', async ({ app }) => { test('each one has the shared header, with a heading', async ({ app }) => {
// By event rather than by nav item: a destination is not
// guaranteed to have one any more (#25 — Downloads is absent
// without a download client), and every one of these is still a
// primary view with a header, which is what this spec is about.
for (const [view, heading, hasCount] of VIEWS) { for (const [view, heading, hasCount] of VIEWS) {
await app.getByTestId(`nav-${view}`).click(); await navigateTo(app, view);
await expect(app.getByTestId('main-content')).toHaveAttribute( await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view', 'data-active-view',
view, view,
+271
View File
@@ -0,0 +1,271 @@
import {
test,
expect,
callBinding,
navigateTo,
LONG_TRACK,
NO_QUEUE_SOURCE,
} from '../support/fixtures.js';
import type { Page } from '@playwright/test';
/**
* The queue panel's mouse model (#43): single click selects, ctrl and
* shift extend, double click plays from that row.
*
* **All four already worked, and nothing pinned any of them** — which is
* the whole reason the report could be made and could not be settled.
* `queue-reorder.spec.ts` covers the keyboard, `queue-overlay.spec.ts`
* covers the panel's mode, and the component tier has the reorder
* arithmetic; the pointer path had no coverage in either tier, so
* "selection is broken here" and "selection is fine here" were equally
* consistent with a green suite.
*
* Two things this spec is deliberately shaped around.
*
* **The clicks are real.** A `dispatchEvent(new MouseEvent('click'))`
* on a row exercises the delegated handler and *not* the question being
* asked, which is what the pointer lands on: the rows carry
* `explore-link` names that take their own clicks, and a synthetic
* event aimed at the row reports a selection the mouse would never have
* produced. Every click here goes through Playwright.
*
* **The playing assertions use the 90-second fixture.** Every other
* track is 26 seconds, so "double click plays row 3" read against a
* 2-second track reports whatever auto-advance moved on to — measured
* during this work as row 3 double-clicked and row 4 playing, which
* reads exactly like an off-by-one in `PlayIndex` and is not one.
*/
/**
* How long a click may take to show up as a highlight.
*
* **A poll with the default 5s timeout cannot see this defect**, and
* that is the point of naming it. `queue-panel` repaints its rows two
* ways — `onSelectionChanged()` calls `virtualizer.requestUpdate()`,
* and `.keyFunction` is a per-render arrow, which is itself a changed
* property the virtualizer reacts to. With **both** removed the
* highlight still arrives, on whatever unrelated render happens next:
* measured at 134ms, 3,866ms and 5,816ms for three clicks, against
* 5ms, 16ms and 17ms on a healthy build.
*
* A user cannot tell "four seconds late" from "broken", which is very
* close to what this issue reports. So the assertion is that the
* highlight is *prompt*, with a bound ~30x the measured healthy case
* and an order of magnitude under the degraded one.
*/
const HIGHLIGHT_MS = 500;
/** The queue's own answer, never the DOM's. */
async function playing(app: Page): Promise<{ index: number; title: string }> {
const state = await callBinding<{
currentIndex: number;
tracks: { title: string }[];
}>(app, 'queue.Queue.GetState');
return {
index: state.currentIndex,
title: state.tracks[state.currentIndex]?.title ?? '',
};
}
/** Which rows are selected, as the accessibility tree sees it. */
const selected = (app: Page) =>
app.evaluate(() =>
[
...document
.querySelector('queue-panel')!
.shadowRoot!.querySelectorAll('[data-index]'),
]
.filter((row) => row.getAttribute('aria-selected') === 'true')
.map((row) => Number((row as HTMLElement).dataset['index'])),
);
/**
* Six tracks with the long one in the middle, so a "play from here"
* assertion has something to land on that will still be playing when it
* is read back.
*/
async function queueSixAndOpen(app: Page): Promise<void> {
const paths = await app.evaluate(async (longTitle) => {
// `TrackName`, not `Title`: the library model names it after the
// tag, and the *queue* is what calls it `title`.
const tracks = (await window.__yjEvents.call(
'library.Library.GetTracks',
[0],
10_000,
)) as { FilePath: string; TrackName: string }[];
const long = tracks.find((t) => t.TrackName === longTitle);
const rest = tracks.filter((t) => t.TrackName !== longTitle).slice(0, 5);
// Index 3 is the long one: far enough down that a shift-extend has
// room either side of it.
return [
...rest.slice(0, 3).map((t) => t.FilePath),
long!.FilePath,
...rest.slice(3).map((t) => t.FilePath),
];
}, LONG_TRACK);
await callBinding(app, 'queue.Queue.SetQueue', [
paths,
0,
false,
NO_QUEUE_SOURCE,
]);
// A closed panel renders no list at all.
await app.locator('#queue-button').click();
await expect(app.locator('queue-panel .track-item').first()).toBeVisible();
await expect(app.locator('queue-panel .track-item')).toHaveCount(6);
}
/** The row at a data-index, not the nth child: see the note in the file. */
const row = (app: Page, index: number) =>
app.locator(`queue-panel .track-item[data-index="${index}"]`);
test.describe('selecting in the queue with a mouse', () => {
// The suite shares one backend in file order, and a queue and an open
// panel both outlive the page. `queue-reorder.spec.ts` sets the
// precedent and the reason: a spec that spends state fails the next
// one, in a list that reads like a regression in whatever you hold.
test.afterEach(async ({ app }) => {
await callBinding(app, 'queue.Queue.Clear').catch(() => {
/* an empty queue is the state we were asking for */
});
const open = await app.locator('queue-panel[open]').count();
if (open > 0) await app.locator('#queue-button').click();
});
test('a single click selects that row and only that row', async ({ app }) => {
await queueSixAndOpen(app);
await row(app, 1).click();
await expect
.poll(() => selected(app), { timeout: HIGHLIGHT_MS })
.toEqual([1]);
// And it *replaces* rather than accumulating, which is the half a
// test of one click cannot see.
await row(app, 4).click();
await expect
.poll(() => selected(app), { timeout: HIGHLIGHT_MS })
.toEqual([4]);
});
test('ctrl adds a row and shift extends a range', async ({ app }) => {
await queueSixAndOpen(app);
await row(app, 1).click();
await row(app, 3).click({ modifiers: ['Control'] });
await expect
.poll(() => selected(app), { timeout: HIGHLIGHT_MS })
.toEqual([1, 3]);
// From the last row touched, so 3→5, keeping the ctrl-picked 1.
await row(app, 5).click({ modifiers: ['Shift'] });
await expect
.poll(() => selected(app), { timeout: HIGHLIGHT_MS })
.toEqual([1, 3, 4, 5]);
// A plain click collapses the whole thing back to one.
await row(app, 2).click();
await expect
.poll(() => selected(app), { timeout: HIGHLIGHT_MS })
.toEqual([2]);
});
test('a double click plays from that row', async ({ app }) => {
await queueSixAndOpen(app);
// Row 3 is the 90-second track. Asked of the backend, because the
// panel's own highlight is a different claim.
await row(app, 3).dblclick();
await expect.poll(() => playing(app)).toEqual({
index: 3,
title: LONG_TRACK,
});
// Playing is not selecting: the double click clears the selection
// it made on the way through, or every play leaves a row looking
// picked out for an action the user did not ask for.
await expect.poll(() => selected(app)).toEqual([]);
});
/**
* The one collision the report is actually about.
*
* Every track, album and artist name in the app navigates
* (`utils/explore-link.ts`), and it does that by **stopping the
* click's propagation** — in its own words, "the row must not also
* treat it as a selection". So a click that lands on the name text
* navigates and selects nothing, in the queue panel and in the track
* list alike.
*
* That is deliberate and it is pinned here rather than argued with,
* because the measurement says the queue is not the surface where it
* hurts: a horizontal hit-scan of a row at three heights makes the
* queue row **12%** link and the track list's row **21%** — the panel
* the report calls broken is *less* covered by links than the list it
* calls correct. What is left is one deliberate exception, and a
* change to it should have to fail a test.
*/
test('a click on a name navigates instead, and that is the exception', async ({
app,
}) => {
await queueSixAndOpen(app);
await row(app, 1).click();
await expect
.poll(() => selected(app), { timeout: HIGHLIGHT_MS })
.toEqual([1]);
await row(app, 2).locator('.explore-link').first().click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'explore-album-details',
);
// Row 2 did not join the selection — the link took the click.
await expect.poll(() => selected(app)).toEqual([1]);
await navigateTo(app, 'tracks');
});
/**
* And the other half of that bargain: the link holds its navigation
* for one double-click interval and drops it if a second click
* arrives, so double-clicking a *name* still plays the row rather
* than navigating away from it. That is what makes the exception
* above survivable, and it is the part most likely to break silently
* if the grace interval is ever removed.
*/
test('a double click on a name plays rather than navigating', async ({
app,
}) => {
await queueSixAndOpen(app);
// Read rather than assumed: which view the app lands on is the
// user's `DefaultPage`, so naming one here would be asserting on a
// config value in a test about a double click.
const before = await app
.getByTestId('main-content')
.getAttribute('data-active-view');
await row(app, 3).locator('.explore-link').first().dblclick();
await expect.poll(() => playing(app)).toEqual({
index: 3,
title: LONG_TRACK,
});
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
before!,
);
});
});
+11 -6
View File
@@ -1,4 +1,4 @@
import { test, expect } from '../support/fixtures.js'; import { test, expect, navigateTo } from '../support/fixtures.js';
/** /**
* Plan 007 phase 5: a11y.1 and a11y.2, frozen against the real app. * Plan 007 phase 5: a11y.1 and a11y.2, frozen against the real app.
@@ -18,16 +18,19 @@ test.describe('Settings is reachable without a mouse', () => {
}) => { }) => {
await app.getByTestId('nav-settings').click(); await app.getByTestId('nav-settings').click();
const headers = app.locator('config-page config-section .header'); // Per *section*, not per `.header`: a section holding a
// `job-panel` (#27) also contains `job-details-drawer`, whose own
// header carries that class and is not a disclosure.
const sections = app.locator('config-page config-section');
await expect(headers.first()).toBeVisible(); await expect(sections.first()).toBeVisible();
const count = await headers.count(); const count = await sections.count();
expect(count).toBeGreaterThan(4); expect(count).toBeGreaterThan(4);
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
const header = headers.nth(i); const header = sections.nth(i).locator('.header').first();
expect(await header.evaluate((el) => el.tagName)).toBe('BUTTON'); expect(await header.evaluate((el) => el.tagName)).toBe('BUTTON');
expect(['true', 'false']).toContain( expect(['true', 'false']).toContain(
@@ -56,7 +59,9 @@ test.describe('Settings is reachable without a mouse', () => {
test.describe("Downloads' tabs are tabs", () => { test.describe("Downloads' tabs are tabs", () => {
test('arrow keys move the selection and swap the panel', async ({ app }) => { test('arrow keys move the selection and swap the panel', async ({ app }) => {
await app.getByTestId('nav-downloads').click(); // By event, not by nav item: with no download client configured
// there is no Downloads destination to click (#25).
await navigateTo(app, 'downloads');
const view = app.locator('downloads-view'); const view = app.locator('downloads-view');
const requests = view.getByRole('tab', { name: 'Requests' }); const requests = view.getByRole('tab', { name: 'Requests' });
+207
View File
@@ -0,0 +1,207 @@
import { test, expect } from '../support/fixtures.js';
/**
* The top bar fits the window it is in (#143).
*
* **This is measured per child, not on the shell**, which is #69's
* lesson repeated one component over: `layout-overflow.spec.ts` asserts
* the *document* needs no sideways scrolling, and clipping inside a
* component is invisible to it — which is exactly why that spec was
* green throughout this defect. What a user sees is a control rendered
* past the edge of the bar it belongs to, so that is what is asserted.
*
* **And it is measured with a job running**, which is the half the
* original report missed. `job-indicator` is `hidden` while idle and up
* to 235px wide when it is not, so the bar was 611px inside 600 sitting
* still and 862px during a scan — 171 to 262px of overflow, arriving
* exactly when a user has reason to look at that bar. Nothing else in
* this suite has ever measured a layout with work in flight;
* `/__test/emit` stages it without staging the scan.
*/
type Page = import('@playwright/test').Page;
/**
* The widths this asks about.
*
* 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.
*/
const WIDTHS = [390, 600, 800, 899, 900, 1440];
/**
* A scan whose title is as long as a real one gets. The label is capped
* at 12rem by the component, so this is the widest the indicator can
* be — measuring with "Scanning" instead reports a bar that fits and a
* defect that is 100px smaller than it is.
*/
const LONG_JOB = {
id: 'top-bar-fit',
kind: 'library-scan',
state: 'running',
title: 'Scanning Music from the external drive',
current: 40,
total: 100,
};
/**
* Every child's right edge against the bar's own content box.
*
* The content box, not `clientWidth`: the bar has a 2em right gutter,
* and a control sitting in the padding is already the failure — it is
* simply one that `scrollWidth` under-reports, because `scrollWidth`
* counts the left padding and not the right.
*/
const overflowingChildren = (page: Page) =>
page.evaluate(() => {
const bar = document.querySelector<HTMLElement>('header.top-bar')!;
const style = getComputedStyle(bar);
const box = bar.getBoundingClientRect();
const left = box.left + parseFloat(style.paddingLeft);
const right = box.right - parseFloat(style.paddingRight);
return [...bar.children]
.filter((child) => {
const cs = getComputedStyle(child);
// Out of flow is out of the question: a collapsed wordmark is
// `position: absolute` and 1px wide precisely so it costs the
// row nothing.
if (cs.display === 'none' || cs.position === 'absolute') return false;
const r = child.getBoundingClientRect();
return r.width > 0 && (r.right > right + 0.5 || r.left < left - 0.5);
})
.map((child) => {
const r = child.getBoundingClientRect();
return `${child.tagName.toLowerCase()}: ${Math.round(r.left)}..${Math.round(r.right)} outside ${Math.round(left)}..${Math.round(right)}`;
});
});
/** What the fit pass gave up, read back off the DOM it changed. */
const collapsed = (page: Page) =>
page.evaluate(() => ({
wordmark: !!document.querySelector('header.top-bar hgroup.yj-collapsed'),
jobLabel: !!document.querySelector('job-indicator[compact]'),
}));
test.describe('the top bar fits the window', () => {
for (const width of WIDTHS) {
test(`no control sits outside the bar at ${width}px, idle`, async ({
app,
}) => {
await app.setViewportSize({ width, height: 600 });
await expect.poll(() => overflowingChildren(app)).toEqual([]);
});
test(`no control sits outside the bar at ${width}px, with a job running`, async ({
app,
testctl,
}) => {
await app.setViewportSize({ width, height: 600 });
await testctl.emit('JobsChanged', [LONG_JOB]);
// The indicator has to actually be up, or this test passes by
// measuring the idle case under another name.
await expect(app.locator('job-indicator')).toBeVisible();
await expect.poll(() => overflowingChildren(app)).toEqual([]);
});
}
/**
* The other half of "measured, never breakpointed": a rule that
* collapses defensively at every narrow width fits just as well and
* is a worse app. 1440 is roomy at any job title; 899 was measured to
* fit with the longest one, because `nav-history` is not there yet.
*/
test('nothing is given up where there is room for it', async ({
app,
testctl,
}) => {
await app.setViewportSize({ width: 1440, height: 900 });
await testctl.emit('JobsChanged', [LONG_JOB]);
await expect(app.locator('job-indicator')).toBeVisible();
await expect.poll(() => collapsed(app)).toEqual({
wordmark: false,
jobLabel: false,
});
});
/**
* And it gives them back. The pass starts from all-visible every
* time, so this is the property that a rule which only ever *added*
* to the collapsed set would fail — the wordmark would be gone for
* the rest of the session after one narrow moment.
*/
test('the wordmark comes back when the window does', async ({
app,
testctl,
}) => {
await testctl.emit('JobsChanged', [LONG_JOB]);
await app.setViewportSize({ width: 600, height: 600 });
await expect.poll(() => collapsed(app)).toEqual({
wordmark: true,
jobLabel: true,
});
await app.setViewportSize({ width: 1440, height: 900 });
await expect.poll(() => collapsed(app)).toEqual({
wordmark: false,
jobLabel: false,
});
});
/**
* The wordmark yields its width and not its existence: `display:
* none` would take the document from one top-level heading to none.
*/
test('the collapsed wordmark is still the document heading', async ({
app,
testctl,
}) => {
await testctl.emit('JobsChanged', [LONG_JOB]);
await app.setViewportSize({ width: 600, height: 600 });
await expect.poll(() => collapsed(app)).toMatchObject({ wordmark: true });
await expect(
app.getByRole('heading', { name: 'YellowJacket', level: 1 }),
).toHaveCount(1);
});
/**
* And the indicator keeps saying what it is doing after its visible
* label goes — the `sr-only` live region is what announces the state,
* which is the same argument the phone's own rule was written on.
*/
test('the job indicator still announces its state without its label', async ({
app,
testctl,
}) => {
await testctl.emit('JobsChanged', [LONG_JOB]);
await app.setViewportSize({ width: 600, height: 600 });
await expect.poll(() => collapsed(app)).toMatchObject({ jobLabel: true });
const spoken = await app
.locator('job-indicator')
.evaluate(
(el) =>
el.shadowRoot?.querySelector('[aria-live]')?.textContent?.trim() ?? '',
);
expect(spoken).toContain('Scanning Music from the external drive');
// Leave the app as the next spec expects to find it.
await app.setViewportSize({ width: 1440, height: 900 });
});
});
+7 -2
View File
@@ -4,6 +4,7 @@ import {
eventNames, eventNames,
resetEvents, resetEvents,
waitForEvent, waitForEvent,
navigateTo,
} from '../support/fixtures.js'; } from '../support/fixtures.js';
/** /**
@@ -39,7 +40,9 @@ test.describe('view lifecycle', () => {
test('a keypress on Settings does not reach the Autotag queue', async ({ test('a keypress on Settings does not reach the Autotag queue', async ({
app, app,
}) => { }) => {
await app.getByTestId('nav-autotag').click(); // By event, not by nav item: Autotag is hidden by default (#25)
// and a hidden view is still reachable.
await navigateTo(app, 'autotag');
await expect(app.getByTestId('main-content')).toHaveAttribute( await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view', 'data-active-view',
'autotag', 'autotag',
@@ -83,7 +86,9 @@ test.describe('view lifecycle', () => {
// The other half of the same bug (H-2): two document keydown handlers // The other half of the same bug (H-2): two document keydown handlers
// with no arbitration meant `s` on this page skipped the album *and* // with no arbitration meant `s` on this page skipped the album *and*
// toggled shuffle. As a panel binding it can only mean one thing. // toggled shuffle. As a panel binding it can only mean one thing.
await app.getByTestId('nav-autotag').click(); // By event, not by nav item: Autotag is hidden by default (#25)
// and a hidden view is still reachable.
await navigateTo(app, 'autotag');
await expect await expect
.poll(() => pendingCount(app)) .poll(() => pendingCount(app))
.toMatch(/^Pending \(\d+\)$/); .toMatch(/^Pending \(\d+\)$/);
+116
View File
@@ -0,0 +1,116 @@
import { test, expect, navigateTo } from '../support/fixtures.js';
/**
* Which destinations the navigation offers (#25).
*
* Eleven sidebar entries is more than most libraries need, so they are
* individually toggleable from Settings, Autotag is off until asked for
* and Downloads is absent until there is a client to download with.
*
* **The assertions are about the navigation, not about the setting.**
* "The config was saved" is the plumbing, and the two most recent bugs
* in this area — #69 and #72 — both shipped green under specs that
* measured exactly that. What a person sees is whether the item is in
* the accessibility tree, and whether the view is still reachable when
* it is not.
*
* This runs against the seeded app, whose config is defaults and whose
* download client list is empty, so the initial state below is what a
* fresh install looks like.
*/
type Page = import('@playwright/test').Page;
const navItem = (page: Page, label: string) =>
page.getByRole('button', { name: label, exact: true });
/** The Navigation section's checkbox for a destination. */
const viewToggle = (page: Page, label: string) =>
page.getByRole('checkbox', { name: `Show ${label} in the navigation` });
async function openNavigationSettings(page: Page): Promise<void> {
await page.getByTestId('nav-settings').click();
const section = page.locator(
'config-page config-section[heading="Navigation"] .header',
);
await expect(section).toBeVisible();
if ((await section.getAttribute('aria-expanded')) === 'false') {
await section.click();
}
await expect(section).toHaveAttribute('aria-expanded', 'true');
}
test.describe('configurable destinations', () => {
test('Autotag is off by default and Downloads needs a client', async ({
app,
}) => {
await expect(app.getByTestId('nav-home')).toBeVisible();
await expect(app.getByTestId('nav-autotag')).toHaveCount(0);
await expect(app.getByTestId('nav-downloads')).toHaveCount(0);
});
/**
* Hiding takes the item away and nothing else. Detail views navigate
* into these and the launch page is one of them, so a destination
* with no nav item still has to open.
*/
test('a hidden destination is still reachable', async ({ app }) => {
await navigateTo(app, 'autotag');
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'autotag',
);
// And nothing is falsely lit while standing on it -- the same rule
// a detail view follows, with no special case for either.
await expect(navItem(app, 'Home')).toHaveAttribute('aria-current', 'false');
});
test('switching Autotag on adds it to the sidebar', async ({ app }) => {
await openNavigationSettings(app);
await viewToggle(app, 'Autotag').check();
await expect(app.getByTestId('nav-autotag')).toBeVisible();
// Clicking it is the point of having it.
await app.getByTestId('nav-autotag').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'autotag',
);
// Put it back, or the next spec against this app sees a library
// this one changed.
await openNavigationSettings(app);
await viewToggle(app, 'Autotag').uncheck();
await expect(app.getByTestId('nav-autotag')).toHaveCount(0);
});
/**
* Settings has no toggle at all, rather than a toggle that refuses:
* a user who hides it cannot get back to unhide it. The backend
* refuses it too, because `config.toml` is hand-editable.
*/
test('Settings cannot be switched off', async ({ app }) => {
await openNavigationSettings(app);
await expect(viewToggle(app, 'Settings')).toBeDisabled();
await expect(app.getByTestId('nav-settings')).toBeVisible();
});
/**
* The launch page is refused while it is the launch page, which is a
* state the user can leave by changing the launch page above it.
*/
test('the launch page cannot be switched off', async ({ app }) => {
await openNavigationSettings(app);
await expect(viewToggle(app, 'Home')).toBeDisabled();
});
});
+29
View File
@@ -111,6 +111,35 @@ export async function bindingCalls(page: Page): Promise<string[]> {
return calls.map(nameOf); return calls.map(nameOf);
} }
/**
* Go to a view without going through the navigation.
*
* `navigate` is the event the shell listens for and every nav item, card
* and detail view dispatches, so this is the app's own mechanism rather
* than a test-only door. It exists because a destination is not
* guaranteed to have a nav item any more (#25): Autotag is hidden until
* the user asks for it and Downloads until a client exists, and a spec
* about what a *view* does should not also be asserting that the
* sidebar offers it.
*/
export async function navigateTo(page: Page, view: string): Promise<void> {
await page.evaluate(
(v) =>
void document.dispatchEvent(
new CustomEvent('navigate', {
detail: { view: v },
bubbles: true,
composed: true,
}),
),
view,
);
await page
.getByTestId('main-content')
.waitFor({ state: 'attached' });
}
/** Thin client for the dev-only /__test/ surface (backend/testctl). */ /** Thin client for the dev-only /__test/ surface (backend/testctl). */
export class TestCtl { export class TestCtl {
constructor(private readonly baseURL: string) {} constructor(private readonly baseURL: string) {}
@@ -112,6 +112,16 @@ export function GetTrackListColumns(): $CancellablePromise<tracklist$0.Column[]
return $Call.ByID(3426289065); return $Call.ByID(3426289065);
} }
/**
* GetViewVisibility reports which primary views the sidebar should
* show, answered for every known view rather than only the ones the
* config mentions -- so the frontend filters on a value and never has
* to hold a second copy of the defaults.
*/
export function GetViewVisibility(): $CancellablePromise<{ [_ in string]?: boolean } | null> {
return $Call.ByID(2798108026);
}
/** /**
* Load reads and parses the config file from disk. * Load reads and parses the config file from disk.
*/ */
@@ -247,6 +257,20 @@ export function SetTrackListColumns(columns: tracklist$0.Column[] | null): $Canc
return $Call.ByID(4226159685, columns); return $Call.ByID(4226159685, columns);
} }
/**
* SetViewVisible shows or hides one primary view.
*
* Two refusals, both about a state the user cannot get out of from the
* UI they would be left with: Settings is never hideable, and the
* launch page is never hideable while it is the launch page (change it
* first). Hiding a view does not make it unreachable -- `navigate`
* still resolves it, which detail views depend on -- it only takes the
* nav item away.
*/
export function SetViewVisible(view: string, visible: boolean): $CancellablePromise<void> {
return $Call.ByID(1751982648, view, visible);
}
/** /**
* Validate returns errors if there is a breaking issue with the config. * Validate returns errors if there is a breaking issue with the config.
*/ */
+69 -1
View File
@@ -104,6 +104,51 @@ p {
flex: 0 1 320px; flex: 0 1 320px;
} }
/* What the bar gives up when it does not fit is decided by measuring
it (`services/top-bar-fit.ts`, #143). Two rules here are what make
that measurement mean anything.
**Nothing but the search box may shrink.** `scrollWidth` reports a
perfect fit while a child quietly truncates -- #69's trap, one
component over -- and the indicator's label is `text-overflow:
ellipsis`, so it would have absorbed the deficit and hidden it. The
search box is exempt because it shrinks between its 320px basis and
the 200px floor its own stylesheet sets, and a narrower input hides
nothing it was showing. */
.top-bar hgroup,
.top-bar library-filter,
.top-bar job-indicator {
flex-shrink: 0;
}
/* **The wordmark yields its width, not its existence.** It is the
app's top-level heading as well as its brand, and `display: none`
would take a document from one `h1` to none at exactly the widths
where the view's own header is the only thing left saying where you
are. This is `styles/sr-only.css.ts`'s recipe, written out because
that one is a `CSSResult` for shadow roots and this is the light
DOM. */
.top-bar hgroup.yj-collapsed {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
/* The bar is `justify-content: space-between`, which with four children
spreads them evenly and left back/forward floating in the middle of
nothing. Collecting the free space *after* this one puts the pair
beside the brand, where a browser keeps them, and leaves the
right-hand group exactly as it was. */
.top-bar nav-history {
margin-right: auto;
}
ul { ul {
list-style-type: none; list-style-type: none;
} }
@@ -133,6 +178,23 @@ ul {
.subtitle { .subtitle {
display: none; display: none;
} }
/* Back/forward is Desktop-band chrome (#6), and 900 is the same
line the sidebar's labels and the subtitle are already given up
at -- below it the shell is narrow enough that the header is
what runs out of room first. Measured at 600, the bottom of the
Compact band: the bar is 611px inside a 600px viewport *before*
this component exists (filed separately), and 695px with it, so
keeping it here would be widening a violation of the promise
that nothing scrolls sideways at a supported size.
Nothing is unreachable as a result, which is the rule that
decides it: Alt+Left / Alt+Right are global and every width has
them, the detail views keep their own back buttons, and the
phone additionally has the platform's gesture. */
.top-bar nav-history {
display: none;
}
} }
body div.sidebar { body div.sidebar {
@@ -346,7 +408,13 @@ body div.sidebar {
/* The search box is the one header control worth its width; the /* The search box is the one header control worth its width; the
library filter is a rarely-changed setting and reachable from library filter is a rarely-changed setting and reachable from
the drawer's Settings. */ 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 { .top-bar library-filter {
display: none; display: none;
} }
+6
View File
@@ -20,6 +20,12 @@
<!-- a11y.29: a heading level was being used for type size. --> <!-- a11y.29: a heading level was being used for type size. -->
<p class="subtitle">Music how it was meant to bee.</p> <p class="subtitle">Music how it was meant to bee.</p>
</hgroup> </hgroup>
<!-- Global back/forward (#6). Before the library filter so the
two navigation controls in this bar are adjacent, and after
the brand because that is where a window's chrome ends and
the app's begins. Hidden below 600px by index.css: the
phone has a system back, and this bar has no room. -->
<nav-history></nav-history>
<library-filter></library-filter> <library-filter></library-filter>
<search-bar></search-bar> <search-bar></search-bar>
<job-indicator></job-indicator> <job-indicator></job-indicator>
+114 -20
View File
@@ -23,6 +23,7 @@ import '@components/now-playing/now-playing.ts';
import '@components/sidebar/app-sidebar.ts'; import '@components/sidebar/app-sidebar.ts';
import '@components/bottom-nav/bottom-nav.ts'; import '@components/bottom-nav/bottom-nav.ts';
import '@components/queue-panel/queue-panel.ts'; import '@components/queue-panel/queue-panel.ts';
import '@components/nav-history/nav-history.ts';
import '@components/search-bar/search-bar.ts'; import '@components/search-bar/search-bar.ts';
import '@components/library-filter/library-filter.ts'; import '@components/library-filter/library-filter.ts';
import '@components/first-run-wizard/first-run-wizard.ts'; import '@components/first-run-wizard/first-run-wizard.ts';
@@ -40,6 +41,8 @@ import { setBasePath } from '@awesome.me/webawesome/dist/webawesome.js';
import { registerBundledIcons } from './src/icons'; import { registerBundledIcons } from './src/icons';
import { queueStore } from '@store/queue-store'; import { queueStore } from '@store/queue-store';
import { searchStore } from '@store/search-store'; import { searchStore } from '@store/search-store';
import { activeViewStore } from '@store/active-view-store';
import { historyStore } from '@store/history-store';
import * as Player from '@go/player/player.js'; import * as Player from '@go/player/player.js';
import * as Queue from '@go/queue/queue.js'; import * as Queue from '@go/queue/queue.js';
import { GetDefaultPage } from '@go/config/config.js'; import { GetDefaultPage } from '@go/config/config.js';
@@ -51,6 +54,7 @@ import '@store/theme-store';
import './src/services/keyboard-shortcut-service'; import './src/services/keyboard-shortcut-service';
import { activateView, deactivateView } from '@utils/view-lifecycle'; import { activateView, deactivateView } from '@utils/view-lifecycle';
import { installLongPressContextMenu } from '@utils/long-press'; import { installLongPressContextMenu } from '@utils/long-press';
import { installTopBarFit } from './src/services/top-bar-fit';
import { import {
hasTrackPayload, hasTrackPayload,
getDragPayload, getDragPayload,
@@ -70,6 +74,14 @@ registerBundledIcons();
// on `pointerType === 'touch'` only. // on `pointerType === 'touch'` only.
installLongPressContextMenu(); installLongPressContextMenu();
// The top bar decides what it can afford to show (#143). Here rather
// than in a component because the bar is light DOM in index.html and
// its children are five separate elements; the shell is the only thing
// that can see all five at once.
const topBar = document.querySelector<HTMLElement>('header.top-bar');
if (topBar) installTopBarFit(topBar);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// View caching navigation system // View caching navigation system
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -98,7 +110,6 @@ const VIEW_TAGS: Record<string, string> = {
explore: 'explore-view', explore: 'explore-view',
autotag: 'autotag-view', autotag: 'autotag-view',
downloads: 'downloads-view', downloads: 'downloads-view',
jobs: 'jobs-view',
settings: 'config-page', settings: 'config-page',
}; };
@@ -116,7 +127,6 @@ const VIEW_LOADERS: Record<string, () => Promise<unknown>> = {
explore: () => import('@components/explore-view/explore-view.ts'), explore: () => import('@components/explore-view/explore-view.ts'),
autotag: () => import('@components/autotag-view/autotag-view.ts'), autotag: () => import('@components/autotag-view/autotag-view.ts'),
downloads: () => import('@components/downloads-view/downloads-view.ts'), downloads: () => import('@components/downloads-view/downloads-view.ts'),
jobs: () => import('@components/jobs/jobs-view.ts'),
settings: () => import('@components/config-page/config-page.ts'), settings: () => import('@components/config-page/config-page.ts'),
}; };
@@ -214,45 +224,101 @@ document.addEventListener('navigate', (e: Event) => {
// go through `history.back()` rather than popping `navStack` // go through `history.back()` rather than popping `navStack`
// themselves, so one press cannot consume two entries. // themselves, so one press cannot consume two entries.
/** The navigation an entry stands for. `undefined` on the entry that /** The navigation an entry stands for, and where it sits in this
* predates the app's own routing, which is the one back exits from. */ * session's list. `undefined` on the entry that predates the app's own
type NavState = { yjNav?: { view: string; [key: string]: any } }; * routing, which is the one back exits from. */
type NavState = { yjNav?: { view: string; [key: string]: any }; yjIdx?: number };
/** Whether the app's first navigation has been recorded. It *replaces* /** Whether the app's first navigation has been recorded. It *replaces*
* the launch entry rather than pushing, or every launch would cost one * the launch entry rather than pushing, or every launch would cost one
* back press before the app would exit. */ * back press before the app would exit. */
let historyStarted = false; let historyStarted = false;
/** How many entries this session has pushed beyond that first one -- // Back and forward are the *same* `popstate` event -- it carries no
* i.e. how deep back can go while staying inside the app. */ // direction, and the History API exposes neither the current position
let pushedEntries = 0; // nor a reachable depth. So the shell numbers its own entries: the
// index of the one showing, and the highest index reachable from here.
//
// The counter this replaced (`pushedEntries`, one number decremented on
// every pop) could not express forward at all: going forward looked
// exactly like going back again, so two presses of a Forward button
// would have claimed the app was at its root.
/** Index of the entry now showing. 0 is the launch entry, which is
* replaced rather than pushed -- so this is also how deep back can go
* while staying inside the app. */
let currentIndex = 0;
/** The highest index reachable from here: how far forward is left.
* A new navigation truncates the forward list, exactly as a browser
* does, so this is reset to the entry being pushed. */
let maxIndex = 0;
function publishDepth(): void {
historyStore.setDepth(currentIndex > 0, currentIndex < maxIndex);
}
function recordNavigation(detail: { view: string; [key: string]: any }): void { function recordNavigation(detail: { view: string; [key: string]: any }): void {
// `_isBack` is bookkeeping, not destination: keeping it in the entry // `_isBack` and `_replace` are bookkeeping, not destination: keeping
// would make a replayed navigation claim to be a back-navigation. // either in the entry would make a replayed navigation claim to be
const { _isBack: _ignored, ...nav } = detail; // one.
const state: NavState = { yjNav: nav }; const { _isBack: _ignored, _replace: replace, ...nav } = detail;
// Still launching: the configured landing page is not a navigation
// *away* from the eager one, it is the same arrival arriving late
// (#142). Pushing it left the app one entry deep before the user
// had touched anything, so the first back press replayed home over
// home -- invisible on desktop until #6 drew a Back button, and on
// Android the press that should have exited the app instead did
// nothing, because `canGoBack()` was true.
//
// Guarded on being at the root rather than on a flag, because
// `GetDefaultPage()` is a backend call and the user can navigate
// while it is in flight: past index 0 this is an ordinary
// navigation, or a slow answer would overwrite an entry they made.
if (historyStarted && replace && currentIndex === 0) {
history.replaceState({ yjNav: nav, yjIdx: 0 }, '');
maxIndex = 0;
publishDepth();
return;
}
// Same URL, deliberately: the app has no routes, and a path a // Same URL, deliberately: the app has no routes, and a path a
// reload cannot resolve is worse than no path at all. // reload cannot resolve is worse than no path at all.
if (historyStarted) { if (historyStarted) {
history.pushState(state, ''); currentIndex += 1;
pushedEntries += 1; // Navigating from the middle of the list drops what was ahead
// of it -- there is no longer a forward to go to.
maxIndex = currentIndex;
history.pushState({ yjNav: nav, yjIdx: currentIndex }, '');
} else { } else {
history.replaceState(state, ''); currentIndex = 0;
maxIndex = 0;
history.replaceState({ yjNav: nav, yjIdx: 0 }, '');
historyStarted = true; historyStarted = true;
} }
publishDepth();
} }
window.addEventListener('popstate', (e: PopStateEvent) => { window.addEventListener('popstate', (e: PopStateEvent) => {
const nav = (e.state as NavState | null)?.yjNav; const state = e.state as NavState | null;
const nav = state?.yjNav;
// Before the app's first navigation, or an entry somebody else // Before the app's first navigation, or an entry somebody else
// pushed: nothing to restore, and the activity should be free to // pushed: nothing to restore, and the activity should be free to
// finish. // finish.
if (!nav) return; if (!nav) return;
pushedEntries = Math.max(0, pushedEntries - 1); // The entry says where it is, so this works in both directions and
// across a jump of more than one -- which a long-press on a
// browser's back button, and `history.go(-n)`, both produce.
// The fallback is for an entry pushed before this numbering
// existed; it can only be wrong about a control's disabled state,
// never about which view is restored.
currentIndex = state?.yjIdx ?? Math.max(0, currentIndex - 1);
publishDepth();
void handleNavigate({ ...nav, _isBack: true }); void handleNavigate({ ...nav, _isBack: true });
}); });
@@ -279,6 +345,20 @@ async function handleNavigate(
// attribute keeps e2e selectors semantic instead of structural. // attribute keeps e2e selectors semantic instead of structural.
mainContent.dataset.activeView = view; mainContent.dataset.activeView = view;
// And publishing it as a *value* is what the nav components read.
// They used to learn the active view from the `navigate` event,
// which only the outbound path dispatches -- so a back-navigation
// left both of them highlighting the view it had just left (#72).
// Re-dispatching `navigate` here is not the fix: this file is a
// document listener for it, so that is an infinite loop, and
// "please go to X" is not the statement being made.
//
// `view in VIEW_TAGS` is the primary/detail split, and it is passed
// rather than re-derived because this table is where it is written
// down. A detail view therefore leaves the tab it was opened from
// lit, which is what the report asks for.
activeViewStore.setView(view, view in VIEW_TAGS);
// --- Primary (cacheable) views ---------------------------------------- // --- Primary (cacheable) views ----------------------------------------
if (view in VIEW_TAGS) { if (view in VIEW_TAGS) {
// Remove any active detail view first // Remove any active detail view first
@@ -497,7 +577,18 @@ function schedule(fn: () => void): void {
// anyway would leave the app: the depth check is what stops a stray // anyway would leave the app: the depth check is what stops a stray
// `navigate-back` closing it. // `navigate-back` closing it.
document.addEventListener('navigate-back', () => { document.addEventListener('navigate-back', () => {
if (pushedEntries > 0) history.back(); if (currentIndex > 0) history.back();
});
// Forward: the other half of #6. The stack was always global -- every
// navigation is an entry and `popstate` restores any of them -- so what
// was missing is a way to ask for one, and a truthful answer to whether
// there is one to ask for. It is guarded for the same reason back is:
// `history.forward()` at the end of the list is silent, so a button
// that offers it when there is nothing there is a button that does
// nothing.
document.addEventListener('navigate-forward', () => {
if (currentIndex < maxIndex) history.forward();
}); });
// Navigate to the user's configured launch page. Falls back to 'home' // Navigate to the user's configured launch page. Falls back to 'home'
@@ -507,14 +598,17 @@ GetDefaultPage()
document.dispatchEvent(new CustomEvent('navigate', { document.dispatchEvent(new CustomEvent('navigate', {
bubbles: true, bubbles: true,
composed: true, composed: true,
detail: { view: view || 'home' }, // Part of launching, not a navigation away from the eager
// 'home' above: it replaces that entry rather than
// stacking on it (#142).
detail: { view: view || 'home', _replace: true },
})); }));
}) })
.catch(() => { .catch(() => {
document.dispatchEvent(new CustomEvent('navigate', { document.dispatchEvent(new CustomEvent('navigate', {
bubbles: true, bubbles: true,
composed: true, composed: true,
detail: { view: 'home' }, detail: { view: 'home', _replace: true },
})); }));
}); });
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2026 Fonticons, Inc. --><path fill="currentColor" d="M502.6 278.6c12.5-12.5 12.5-32.8 0-45.3l-160-160c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L402.7 224 32 224c-17.7 0-32 14.3-32 32s14.3 32 32 32l370.7 0-105.4 105.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l160-160z"/></svg>

After

Width:  |  Height:  |  Size: 532 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--! Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2026 Fonticons, Inc. --><path fill="currentColor" d="M0 256a56 56 0 1 1 112 0 56 56 0 1 1 -112 0zm168 0a56 56 0 1 1 112 0 56 56 0 1 1 -112 0zm224-56a56 56 0 1 1 0 112 56 56 0 1 1 0-112z"/></svg>

After

Width:  |  Height:  |  Size: 443 B

@@ -29,6 +29,7 @@ import { nameDialogsIn } from '../../utils/name-dialog';
import { ViewLifecycleMixin } from '../../utils/view-lifecycle'; import { ViewLifecycleMixin } from '../../utils/view-lifecycle';
import { confirmAction } from '../confirm-dialog/confirm-dialog'; import { confirmAction } from '../confirm-dialog/confirm-dialog';
import '@awesome.me/webawesome/dist/components/dialog/dialog.js'; import '@awesome.me/webawesome/dist/components/dialog/dialog.js';
import '@components/jobs/job-panel';
import { list } from '@utils/binding'; import { list } from '@utils/binding';
type PendingItem = autotagservice.PendingItem; type PendingItem = autotagservice.PendingItem;
@@ -135,8 +136,20 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
} }
.header { /* The header and the apply-job panel share the header row.
A wrapper rather than a third grid row, because the panel
is display:none while nothing is applying and a grid
row would still spend the container's gap on it -- the
idle case, which is nearly always. */
.header-area {
grid-area: header; grid-area: header;
display: flex;
flex-direction: column;
gap: 0.5rem;
min-width: 0;
}
.header {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
@@ -3207,7 +3220,20 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
// sees a blank full-screen "Loading\u2026". // sees a blank full-screen "Loading\u2026".
return html` return html`
<div class="root"> <div class="root">
${this.renderHeader()} <div class="header-area">
${this.renderHeader()}
<!--
Applying rewrites tags on disk, and until #27 the
only way to stop a run was the Jobs tab or the
header popover. The per-album ring says work is
happening; this is what can stop it, and what has
the log when it goes wrong.
-->
<job-panel
kinds="autotag-apply"
heading="Applying tags"
></job-panel>
</div>
${this.renderFolderSidebar()} ${this.renderFolderSidebar()}
${this.renderMain()} ${this.renderMain()}
</div> </div>
@@ -7,6 +7,8 @@ import { designTokens } from '../../styles/tokens.css';
import '../sidebar/app-sidebar.js'; import '../sidebar/app-sidebar.js';
import { nameDialog } from '@utils/name-dialog'; import { nameDialog } from '@utils/name-dialog';
import { ICON_PLAYLIST } from '@utils/icon-language'; import { ICON_PLAYLIST } from '@utils/icon-language';
import { ActiveViewController } from '@store/controllers/active-view-controller';
import { ViewVisibilityController } from '@store/controllers/view-visibility-controller';
type View = 'home' | 'albums' | 'tracks' | 'playlists'; type View = 'home' | 'albums' | 'tracks' | 'playlists';
@@ -114,8 +116,35 @@ export class BottomNav extends LitElement {
} }
`]; `];
@state() /**
private activeView = 'home'; * Which tab is lit, read from the shell rather than tracked here.
*
* This was a `@state()` field set from the `navigate` event, which
* only the outbound path dispatches -- so backing out of a detail
* view left the highlight wherever it had been (#72). It had no
* equivalent of `app-sidebar`'s `navItems.some(...)` guard either,
* so a detail view set it to a name matching no tab and *nothing*
* was lit; that asymmetry is why one nav looked broken and the
* other looked fine. The store answers both: a detail view leaves
* the tab it was opened from lit, in both components.
*/
private activeCtrl = new ActiveViewController(this);
/**
* The tab bar honours the sidebar's toggles (#25), and the reason is
* inside this component rather than a general rule about phones.
* `PHONE_COLUMN_IDS` is the precedent for "what a phone shows is a
* different question", and it would apply here too -- except that
* "More" opens the *same* `<app-sidebar>`, which filters. An
* unfiltered bar would therefore contradict its own drawer, one tap
* apart, and a destination the user switched off is off wherever it
* is offered.
*
* Which four tabs remains plan 016's committed subset; this only
* removes from it. Hiding all four leaves "More", which is always
* present and reaches everything.
*/
private visibilityCtrl = new ViewVisibilityController(this);
/** /**
* Whether the drawer has been asked for. * Whether the drawer has been asked for.
@@ -167,12 +196,9 @@ export class BottomNav extends LitElement {
nameDialog(this.drawer); nameDialog(this.drawer);
} }
private onGlobalNavigate = (e: Event) => { private onGlobalNavigate = () => {
const detail = (e as CustomEvent<{ view?: string }>).detail;
if (detail?.view) this.activeView = detail.view;
// A navigation from inside the drawer is the drawer's job done. // A navigation from inside the drawer is the drawer's job done.
// The highlight is not this listener's business any more.
this.drawerOpen = false; this.drawerOpen = false;
}; };
@@ -202,13 +228,17 @@ export class BottomNav extends LitElement {
return html` return html`
<nav aria-label="Primary"> <nav aria-label="Primary">
<ul> <ul>
${BottomNav.TABS.map((tab) => html` ${BottomNav.TABS
.filter((tab) => this.visibilityCtrl.visible(tab.id))
.map((tab) => html`
<li> <li>
<button <button
type="button" type="button"
class=${this.activeView === tab.id ? 'active' : ''} class=${this.activeCtrl.isActive(tab.id)
? 'active'
: ''}
data-testid="tab-${tab.id}" data-testid="tab-${tab.id}"
aria-current=${this.activeView === tab.id aria-current=${this.activeCtrl.isActive(tab.id)
? 'page' ? 'page'
: 'false'} : 'false'}
@click=${() => this.navigate(tab.id)} @click=${() => this.navigate(tab.id)}
@@ -9,7 +9,13 @@ import {
RemoveLibrary, RemoveLibrary,
GetRemovalImpact, GetRemovalImpact,
GetAllLibrariesWithTrackCounts, GetAllLibrariesWithTrackCounts,
ScanLibrary,
ScanAllLibraries,
FullRescan,
} from '@go/library/library.js'; } from '@go/library/library.js';
import { jobStore } from '@store/job-store';
import type { Job } from '@store/job-store';
import '@components/jobs/job-panel';
import { import {
GetScanConcurrency, GetScanConcurrency,
SetScanConcurrency, SetScanConcurrency,
@@ -27,6 +33,9 @@ import type * as library from '@go/library/models.js';
import { ThemeController } from '@store/controllers/theme-controller'; import { ThemeController } from '@store/controllers/theme-controller';
import { TrackListController } from '@store/controllers/tracklist-controller'; import { TrackListController } from '@store/controllers/tracklist-controller';
import { FavoritesController } from '@store/controllers/favorites-controller'; import { FavoritesController } from '@store/controllers/favorites-controller';
import { ViewVisibilityController } from '@store/controllers/view-visibility-controller';
import { VIEW_META } from '../../services/view-meta';
import { downloadStore } from '@store/download-store';
import { GetAllPlaylists } from '@go/playlist/service.js'; import { GetAllPlaylists } from '@go/playlist/service.js';
import type * as playlist from '@go/playlist/models.js'; import type * as playlist from '@go/playlist/models.js';
import { Events } from '../../events'; import { Events } from '../../events';
@@ -71,6 +80,27 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
// --- Favorites controller --- // --- Favorites controller ---
private favCtrl = new FavoritesController(this); private favCtrl = new FavoritesController(this);
/** Which destinations the navigation offers (#25). */
private viewsCtrl = new ViewVisibilityController(this);
/**
* The job snapshot, for the per-library scan status (#27).
*
* Held as state rather than read from the store in `render()` so
* Lit sees the dependency: the store notifies, and a getter read
* inside a template is not a reactive input.
*/
@state() private jobs: Job[] = [];
/**
* Set between pressing a scan button and the job snapshot that
* proves it started -- `JobsChanged` is coalesced at 250 ms, which
* is long enough for a second click to start a second scan.
*/
@state() private startingScan = false;
private unsubscribeJobs: (() => void) | null = null;
// --- Shortcuts controller --- // --- Shortcuts controller ---
private shortcutsCtrl = new ShortcutsController(this); private shortcutsCtrl = new ShortcutsController(this);
@@ -469,6 +499,12 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
flex: 1; flex: 1;
} }
.view-note {
color: var(--yj-text-tertiary, #888);
font-size: var(--yj-font-size-sm, 0.85rem);
margin-left: auto;
}
.column-arrows { .column-arrows {
display: flex; display: flex;
gap: 0.15em; gap: 0.15em;
@@ -852,8 +888,14 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
this.scrollMode = this.scrollMode =
localStorage.getItem(SCROLL_STORAGE_KEY) || 'hover'; localStorage.getItem(SCROLL_STORAGE_KEY) || 'hover';
// Scan progress lives in the jobs panel now — this page only // Scanning is started and watched here (#27), so the job
// needs to know when the library list itself changes. // snapshot is a live input to this page.
this.unsubscribeJobs = jobStore.subscribe(() => {
this.jobs = jobStore.jobs;
});
this.jobs = jobStore.jobs;
void jobStore.init();
this.cancelLibraryAdded = EventsOn( this.cancelLibraryAdded = EventsOn(
Events.LibraryAdded, Events.LibraryAdded,
() => void this.loadLibraries(), () => void this.loadLibraries(),
@@ -884,6 +926,9 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
} }
protected override onViewDeactivate(): void { protected override onViewDeactivate(): void {
this.unsubscribeJobs?.();
this.unsubscribeJobs = null;
this.cancelLibraryAdded?.(); this.cancelLibraryAdded?.();
this.cancelLibraryRenamed?.(); this.cancelLibraryRenamed?.();
this.cancelLibraryRemoved?.(); this.cancelLibraryRemoved?.();
@@ -1084,6 +1129,133 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
} }
} }
// ===================================================================
// SCANNING (#27 — back from the Jobs tab)
// ===================================================================
/** The scan job for a library, if one is registered. */
private jobForLibrary(id: number): Job | undefined {
return this.jobs.find((job) => job.id === `scan:${id}`);
}
/** The status line under a library name while it is being scanned. */
private libraryScanStatus(id: number): string | null {
const job = this.jobForLibrary(id);
if (!job) return null;
switch (job.state) {
case 'running':
return job.phase ? `Scanning · ${job.phase}` : 'Scanning';
case 'queued':
return 'Queued';
case 'paused':
return 'Paused';
case 'pausing':
return 'Pausing…';
case 'cancelling':
return 'Stopping…';
default:
return null;
}
}
private get anyScanning(): boolean {
return this.libraries.some(
(lib) => this.libraryScanStatus(lib.id) !== null,
);
}
/**
* Run something that starts a job, holding the buttons until the
* snapshot lands and saying so when it does not start at all.
*
* Persistent, not a toast: the user asked for work to happen, it
* did not, and retrying is exactly the useful response.
*/
private async startJob(
what: string,
start: () => Promise<unknown>,
retry: () => void,
): Promise<void> {
if (this.startingScan) return;
this.startingScan = true;
try {
await start();
} catch (err) {
console.error(`${what} failed:`, err);
notificationStore.persistent({
key: 'scan-start',
title: 'Scan did not start',
text: `${what} failed. ${describeError(err)}`,
detail: String(err),
action: { label: 'Try again', run: retry },
});
} finally {
this.startingScan = false;
}
}
private handleScanLibrary = (id: number): void => {
this.activeMenuId = null;
void this.startJob(
'Scanning that library',
() => ScanLibrary(id),
() => this.handleScanLibrary(id),
);
};
private handleScanAll = (): void => {
void this.startJob(
'Scanning your libraries',
() => ScanAllLibraries(),
() => this.handleScanAll(),
);
};
private handleFullRescan = async (): Promise<void> => {
const ok = await confirmAction({
title: 'Full rescan',
message:
'This deletes all library data — including downloaded '
+ 'cover art — and rebuilds it from your files.',
impact:
'It is not the same as “Scan now”, which only picks up '
+ 'what changed.',
confirmLabel: 'Rebuild everything',
danger: true,
});
if (!ok) return;
await this.startJob(
'The full rescan',
() => FullRescan(),
() => void this.handleFullRescan(),
);
};
private handleViewToggle = (
view: string,
visible: boolean,
): void => {
this.viewsCtrl
.setVisible(view, visible)
.catch((err: unknown) => {
console.error('Failed to save view visibility:', err);
notificationStore.transient({
key: 'view-visibility',
text: `Could not change which views are shown. ${describeError(err)}`,
detail: String(err),
});
// The checkbox has already flipped itself; the store is
// the truth, so redraw from it.
this.requestUpdate();
});
};
private handleDefaultPageChange = ( private handleDefaultPageChange = (
e: CustomEvent<ConfigFieldChangeEvent>, e: CustomEvent<ConfigFieldChangeEvent>,
): void => { ): void => {
@@ -1428,6 +1600,7 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
--> -->
${this.renderLibrarySection()} ${this.renderLibrarySection()}
${this.renderGeneralSection()} ${this.renderGeneralSection()}
${this.renderNavigationSection()}
${this.renderNowPlayingSection()} ${this.renderNowPlayingSection()}
${this.renderThemeSection()} ${this.renderThemeSection()}
${this.renderTrackListSection()} ${this.renderTrackListSection()}
@@ -1522,6 +1695,20 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
.value=${this.allowMeteredCatalogDownload} .value=${this.allowMeteredCatalogDownload}
@config-change=${this.handleAllowMeteredChange} @config-change=${this.handleAllowMeteredChange}
></config-field> ></config-field>
<!--
The tier list above says what the build is *doing*;
this says it is a job, and gives it the pause, cancel
and log the tier list never had (#27). Cancelling one
still asks first that confirmation is inside
applyJobControl, keyed on the kind, which is why
this embeds the shared rows rather than drawing its
own.
-->
<job-panel
kinds="index-build,catalog-enrich"
heading="Index jobs"
></job-panel>
</config-section> </config-section>
`; `;
} }
@@ -1642,18 +1829,15 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
description: description:
'The page the app opens to on launch.', 'The page the app opens to on launch.',
type: 'select' as const, type: 'select' as const,
options: [ // Derived, not written out: this list was a
{ value: 'home', label: 'Home' }, // second copy of the launchable set, and #27
{ value: 'tracks', label: 'Tracks' }, // removing a destination is exactly the change
{ value: 'albums', label: 'Albums' }, // that would have left the two disagreeing.
{ value: 'artists', label: 'Artists' }, // Settings is excluded because it is the one
{ value: 'genres', label: 'Genres' }, // view the backend refuses to launch into.
{ value: 'playlists', label: 'Playlists' }, options: VIEW_META
{ value: 'explore', label: 'Explore' }, .filter((v) => v.alwaysShown !== true)
{ value: 'downloads', label: 'Downloads' }, .map((v) => ({ value: v.id, label: v.label })),
{ value: 'autotag', label: 'Autotag' },
{ value: 'jobs', label: 'Jobs' },
],
}} }}
.value=${this.defaultPage} .value=${this.defaultPage}
@config-change=${this.handleDefaultPageChange} @config-change=${this.handleDefaultPageChange}
@@ -1678,6 +1862,79 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
`; `;
} }
// --- Navigation section ---
/**
* Which destinations the sidebar and the phone's tab bar offer.
*
* Two items are drawn but not editable, and both say why in place
* rather than being silently inert. Settings is never hideable --
* the backend refuses it too, because `config.toml` is
* hand-editable. The launch page is not hideable *while it is the
* launch page*, which is a state the user can leave by changing the
* launch page above; refusing is preferable to the alternatives,
* since resetting their launch page silently changes a second thing
* they chose and allowing it lands the app on a page nothing points
* at.
*/
private renderNavigationSection() {
return html`
<config-section
heading="Navigation"
description="Choose which destinations the sidebar and the phone's tab bar offer. Hiding one does not remove it — links and the launch page still open it."
>
<ul class="column-list">
${repeat(VIEW_META, (v) => v.id, (v) => {
const checked = this.viewsCtrl.enabled(v.id);
const isLaunchPage = this.defaultPage === v.id;
const locked = v.alwaysShown === true || isLaunchPage;
let note = '';
if (v.alwaysShown === true) {
note = 'Always shown.';
} else if (isLaunchPage) {
note = 'This is the launch page.';
} else if (
v.id === 'downloads' &&
checked &&
!downloadStore.available
) {
// The config says show it and the nav does not, which
// would otherwise read as the checkbox not working.
note = 'Hidden until a download client is configured.';
}
return html`
<li
class="column-item ${checked ? 'enabled' : 'disabled'}"
>
<input
type="checkbox"
class="column-toggle"
aria-label="Show ${v.label} in the navigation"
.checked=${checked}
?disabled=${locked}
@change=${(e: Event) =>
this.handleViewToggle(
v.id,
(e.target as HTMLInputElement).checked,
)}
/>
<span class="column-label">
${v.label}
</span>
${note
? html`<span class="view-note">${note}</span>`
: nothing}
</li>
`;
})}
</ul>
</config-section>
`;
}
// --- Theme section --- // --- Theme section ---
private renderThemeSection() { private renderThemeSection() {
@@ -2056,8 +2313,8 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
return html` return html`
<config-section <config-section
heading="Libraries" heading="Libraries"
description="Manage your music library folders. Scanning and its description="Manage your music library folders, and scan them
progress live in the Jobs panel." for new and changed files."
.open=${true} .open=${true}
> >
<div class="scan-actions"> <div class="scan-actions">
@@ -2067,6 +2324,23 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
> >
Add Library Add Library
</button> </button>
<button
?disabled=${this.anyScanning
|| this.startingScan
|| this.libraries.length === 0}
@click=${this.handleScanAll}
>
Scan All
</button>
<button
class="btn-danger"
?disabled=${this.anyScanning
|| this.startingScan
|| this.libraries.length === 0}
@click=${this.handleFullRescan}
>
Full Rescan
</button>
</div> </div>
${this.libraries.length > 0 ${this.libraries.length > 0
@@ -2104,7 +2378,8 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
<span class="library-count"> <span class="library-count">
${this.removingLibraryId === lib.id ${this.removingLibraryId === lib.id
? 'Removing…' ? 'Removing…'
: html`${lib.trackCount} tracks`} : this.libraryScanStatus(lib.id)
?? html`${lib.trackCount} tracks`}
</span> </span>
<div class="overflow-wrapper"> <div class="overflow-wrapper">
<button <button
@@ -2127,6 +2402,16 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
> >
Rename Rename
</div> </div>
${this.libraryScanStatus(lib.id) === null
? html`
<div
class="overflow-item"
@click=${() => this.handleScanLibrary(lib.id)}
>
Scan now
</div>
`
: nothing}
<div <div
class="overflow-item overflow-item--danger" class="overflow-item overflow-item--danger"
@click=${() => void this.handleRemoveClick(lib.id)} @click=${() => void this.handleRemoveClick(lib.id)}
@@ -2170,6 +2455,11 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
@config-change=${this.handleConcurrencyChange} @config-change=${this.handleConcurrencyChange}
></config-field> ></config-field>
<job-panel
kinds="library-scan"
heading="Scans"
></job-panel>
</config-section> </config-section>
`; `;
} }
@@ -22,6 +22,7 @@ import { compact } from '@utils/binding';
import { describeError, explainError } from '@utils/describe-error'; import { describeError, explainError } from '@utils/describe-error';
import { confirmAction } from '@components/confirm-dialog/confirm-dialog'; import { confirmAction } from '@components/confirm-dialog/confirm-dialog';
import './config-section'; import './config-section';
import '@components/jobs/job-panel';
import { pickDirectory } from '../../utils/pick-directory'; import { pickDirectory } from '../../utils/pick-directory';
/** /**
@@ -274,6 +275,17 @@ export class DownloadClients extends LitElement {
</wa-button> </wa-button>
</div> </div>
`} `}
<!--
The Downloads view already shows every download's
lifecycle state; what it has never had is pause,
cancel and the log, which the Jobs tab carried (#27).
Renders nothing while nothing is downloading.
-->
<job-panel
kinds="download"
heading="Downloads in progress"
></job-panel>
</config-section> </config-section>
<config-section <config-section
@@ -246,7 +246,7 @@ export class DownloadPicker extends LitElement {
return html` return html`
<wa-callout variant="success"> <wa-callout variant="success">
Found a clear match and started downloading it. Progress is Found a clear match and started downloading it. Progress is
in the background jobs panel. on the Downloads page.
</wa-callout> </wa-callout>
`; `;
} }
@@ -3,6 +3,7 @@ import { customElement, state } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js'; import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@awesome.me/webawesome/dist/components/button/button.js'; import '@awesome.me/webawesome/dist/components/button/button.js';
import '@components/page-header/page-header'; import '@components/page-header/page-header';
import type { PageAction } from '@components/page-header/page-header';
import { designTokens } from '../../styles/tokens.css'; import { designTokens } from '../../styles/tokens.css';
import { downloadStore, stateLabel } from '@store/download-store'; import { downloadStore, stateLabel } from '@store/download-store';
import type { Request, RequestSummary, DownloadView as DownloadRecord } from '@store/download-store'; import type { Request, RequestSummary, DownloadView as DownloadRecord } from '@store/download-store';
@@ -246,23 +247,23 @@ export class DownloadsView extends ViewLifecycleMixin(LitElement) {
override render() { override render() {
return html` return html`
<page-header heading="Downloads"> <page-header
${this.tab === 'requests' heading="Downloads"
? html` .actions=${this.tab === 'requests'
<wa-button ? ([
slot="actions" {
size="small" id: 'check-now',
appearance="outlined" label: this.checking
?disabled=${this.checking} ? 'Searching\u2026'
title="Search every download client for everything on this list right now, instead of waiting for the next scheduled check" : 'Check now',
@click=${() => void this.checkNow()} icon: 'rotate',
> disabled: this.checking,
<wa-icon slot="start" name="rotate"></wa-icon> title: 'Search every download client for everything on this list right now, instead of waiting for the next scheduled check',
${this.checking ? 'Searching…' : 'Check now'} onSelect: () => void this.checkNow(),
</wa-button> },
` ] satisfies PageAction[])
: nothing} : []}
</page-header> ></page-header>
<p class="subtitle"> <p class="subtitle">
Music you have requested, and the download attempts that Music you have requested, and the download attempts that
+63 -38
View File
@@ -1,8 +1,9 @@
import { LitElement, html, css, nothing } from 'lit'; import { LitElement, html, css, nothing } from 'lit';
import { customElement, state } from 'lit/decorators.js'; import { customElement, state } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js'; import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@awesome.me/webawesome/dist/components/button/button.js';
import { GetShelves } from '@go/home/service.js'; import { GetShelves } from '@go/home/service.js';
import { ICON_SHUFFLE } from '@utils/icon-language';
import type { PageAction } from '@components/page-header/page-header';
import { GetAlbumTracks } from '@go/library/library.js'; import { GetAlbumTracks } from '@go/library/library.js';
import type * as home from '@go/home/models.js'; import type * as home from '@go/home/models.js';
import type * as library from '@go/library/models.js'; import type * as library from '@go/library/models.js';
@@ -160,29 +161,52 @@ export class HomeView extends ViewLifecycleMixin(LitElement) {
user-select: none; user-select: none;
} }
/*
* The hover play button is a *hover* affordance, so it is
* gated on the device having hover rather than on width. A
* touch long-press synthesises a hover state in the WebView,
* so on a phone it flashed into view during the 500ms hold
* that utils/long-press.ts is measuring for a context menu
* a control appearing because you were reaching for a
* different one. A phone user taps the album and plays from
* the detail view, so there is nothing to replace it with.
*
* display:none outside the query rather than opacity:0 on
* its own: an opacity-0 button still takes taps and is
* still in the accessibility tree, so the invisible control
* would keep the hit area it was never meant to have on
* touch. Everything else stays inside, so the desktop
* animation is unchanged.
*/
.play { .play {
position: absolute; display: none;
right: 8px;
bottom: 8px;
width: 38px;
height: 38px;
border: none;
border-radius: 50%;
background: var(--yj-accent, #ffd43b);
color: var(--yj-accent-fg, #000);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
opacity: 0;
transform: translateY(6px);
transition: opacity 0.12s ease, transform 0.12s ease;
} }
.card:hover .play, @media (hover: hover) and (pointer: fine) {
.card:focus-within .play { .play {
opacity: 1; position: absolute;
transform: translateY(0); right: 8px;
bottom: 8px;
width: 38px;
height: 38px;
border: none;
border-radius: 50%;
background: var(--yj-accent, #ffd43b);
color: var(--yj-accent-fg, #000);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
opacity: 0;
transform: translateY(6px);
transition: opacity 0.12s ease, transform 0.12s ease;
}
.card:hover .play,
.card:focus-within .play {
opacity: 1;
transform: translateY(0);
}
} }
.name { .name {
@@ -260,23 +284,24 @@ export class HomeView extends ViewLifecycleMixin(LitElement) {
override render() { override render() {
return html` return html`
<page-header heading="Home"> <page-header
<!-- "Shuffle" alone was two different controls with one heading="Home"
name: this one and the transport's shuffle mode. .actions=${[
They were never on screen together until the app {
started landing on Home (H-8), and a cached view is // "Shuffle" alone was two different controls
in the accessibility tree either way. --> // with one name: this one and the transport's
<wa-button // shuffle mode. They were never on screen
slot="actions" // together until the app started landing on
size="small" // Home (H-8), and a cached view is in the
appearance="plain" // accessibility tree either way.
title="Reshuffle the suggestions" id: 'shuffle-suggestions',
@click=${() => void this.load()} label: 'Shuffle suggestions',
> icon: ICON_SHUFFLE,
<wa-icon slot="start" name="shuffle"></wa-icon> title: 'Reshuffle the suggestions',
Shuffle suggestions onSelect: () => void this.load(),
</wa-button> },
</page-header> ] satisfies PageAction[]}
></page-header>
<p class="lede">Somewhere to start listening.</p> <p class="lede">Somewhere to start listening.</p>
${this.renderBody()} ${this.renderBody()}
`; `;
+15 -1
View File
@@ -155,13 +155,27 @@ export class JobIndicator extends LitElement {
goes -- the live region in render() is what announces goes -- the live region in render() is what announces
this, and it is unaffected, so the ring keeps its this, and it is unaffected, so the ring keeps its
accessible name and screen readers keep hearing the accessible name and screen readers keep hearing the
state change. */ state change.
[compact] is the same removal asked for by measurement
rather than by width, and it is set from outside: the
shell's fit pass (services/top-bar-fit.ts, #143) owns
it, because between 600 and 900 whether this label fits
depends on what else is in the bar and on how long the
running job's title is -- 235px for "Scanning Music from
the external drive" -- rather than on the viewport. Two
triggers, one effect, and the phone's is unconditional
because it was argued and pinned before this existed. */
@media (max-width: 599px) { @media (max-width: 599px) {
.label { .label {
display: none; display: none;
} }
} }
:host([compact]) .label {
display: none;
}
.alert-dot { .alert-dot {
width: 6px; width: 6px;
height: 6px; height: 6px;
+229
View File
@@ -0,0 +1,229 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { designTokens } from '../../styles/tokens.css';
import { jobStore } from '@store/job-store';
import type { Job, JobKind } from '@store/job-store';
import { isTerminal } from '@store/job-store';
import './job-row';
import './job-details-drawer';
import { applyJobControl } from './job-controls';
import { jobStateStyles } from './job-format';
/**
* The background work of one kind, rendered wherever that work is
* started or configured.
*
* #27 folded the Jobs tab away, and the shape it folded into is this
* rather than one "Background jobs" panel in Settings which would
* have been the tab again under another name. Reading the app first
* turned up that **four of the five job kinds already had a home**
* showing their work: Settings Search Index draws per-tier index
* progress, `downloads-view` draws every download's lifecycle state,
* `autotag-view` draws its own apply ring, and only `library-scan` had
* nowhere but the tab. What none of those four had is the *generic*
* affordances pause, cancel, "Details", the log, and a finished job
* you can dismiss which is what this carries to each of them.
*
* Three things about it are load-bearing.
*
* **The controls are `applyJobControl`, not a reimplementation.** That
* is what keeps the "you will discard hours of downloading"
* confirmation on an index build alive across the move: it is keyed on
* `KindIndexBuild` inside the shared handler, and a host that rendered
* its own buttons would silently drop it.
*
* **A panel with nothing to say renders nothing at all**, host padding
* included an idle panel in four places is four pieces of furniture
* describing an absence. That is the rule `startBackfillJob` follows
* for the indicator, one layer up.
*
* **There is no "Clear finished" here**, because `ClearFinishedJobs` is
* global: a Clear in the Libraries panel would silently discard the
* index build's history too. A finished row dismisses itself, which is
* per-job and is what `job-row` already offers.
*/
@customElement('job-panel')
export class JobPanel extends LitElement {
/**
* Comma-separated job kinds, e.g. `index-build,catalog-enrich`.
*
* An attribute rather than a property because every call site is a
* 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.
*/
@property({ type: String })
kinds = '';
/** Heading above the rows. Omitted renders no heading. */
@property({ type: String })
heading = '';
@state()
private jobs: Job[] = [];
@state()
private drawerJobId = '';
@state()
private drawerOpen = false;
private unsubscribe: (() => void) | null = null;
static override styles = [
designTokens,
jobStateStyles,
css`
:host {
display: block;
margin-top: 1em;
}
/* An empty panel takes no room at all, margin included. */
:host([hidden]) {
display: none;
}
h3 {
font-size: var(--yj-text-sm);
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--yj-text-tertiary, #868e96);
margin: 0 0 0.5em;
}
.card {
background: var(--yj-bg-surface, #2b3035);
border: 1px solid var(--yj-border, #495057);
border-radius: 6px;
overflow: hidden;
}
.job-entry {
display: flex;
align-items: center;
gap: 0.75em;
padding: 0.6em 0.8em;
border-bottom: 1px solid var(--yj-border-subtle, #3a4046);
}
.job-entry:last-child {
border-bottom: none;
}
job-row {
flex: 1;
/* A grid child's implicit minimum is its content, and a
job title is long. */
min-width: 0;
}
.details-btn {
background: none;
border: 1px solid var(--yj-border, #495057);
border-radius: 4px;
color: var(--yj-text-secondary, #adb5bd);
cursor: pointer;
font-family: inherit;
font-size: var(--yj-text-sm);
padding: 0.3em 0.6em;
white-space: nowrap;
}
.details-btn:hover {
color: var(--yj-text-primary, #e9ecef);
}
`,
];
override connectedCallback() {
super.connectedCallback();
this.unsubscribe = jobStore.subscribe(() => {
this.jobs = jobStore.jobs;
});
this.jobs = jobStore.jobs;
void jobStore.init();
}
override disconnectedCallback() {
super.disconnectedCallback();
this.unsubscribe?.();
this.unsubscribe = null;
}
/** The kinds this panel answers for. */
private get wanted(): ReadonlySet<string> {
return new Set(
this.kinds
.split(',')
.map((k) => k.trim())
.filter(Boolean),
);
}
private get mine(): Job[] {
const wanted = this.wanted;
return this.jobs.filter((job) => wanted.has(job.kind as JobKind));
}
private openDetails(id: string) {
this.drawerJobId = id;
this.drawerOpen = true;
}
private onDrawerClosed = () => {
this.drawerOpen = false;
};
override render() {
const mine = this.mine;
// Hidden rather than empty: see the class comment. The drawer
// goes with it, since it can only have been opened from a row.
this.hidden = mine.length === 0;
if (mine.length === 0) return nothing;
const active = mine.filter((job) => !isTerminal(job));
const finished = mine.filter(isTerminal);
return html`
${this.heading ? html`<h3>${this.heading}</h3>` : nothing}
<div class="card">
${[...active, ...finished].map(
(job) => html`
<div class="job-entry">
<job-row
.job=${job}
variant="full"
@job-control=${applyJobControl}
></job-row>
<button
type="button"
class="details-btn"
@click=${() => this.openDetails(job.id)}
>
Details${job.warnCount
? ` · ${job.warnCount}`
: ''}
</button>
</div>
`,
)}
</div>
<job-details-drawer
job-id=${this.drawerJobId}
?open=${this.drawerOpen}
@drawer-closed=${this.onDrawerClosed}
></job-details-drawer>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'job-panel': JobPanel;
}
}
-568
View File
@@ -1,568 +0,0 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@components/page-header/page-header';
import { designTokens } from '../../styles/tokens.css';
import {
GetAllLibrariesWithTrackCounts,
ScanLibrary,
ScanAllLibraries,
FullRescan,
} from '@go/library/library.js';
import type * as library from '@go/library/models.js';
import { EventsOn } from '@runtime/runtime';
import { Events } from '../../events';
import { jobStore } from '@store/job-store';
import type { Job } from '@store/job-store';
import { notificationStore } from '@store/notification-store';
import { describeError } from '@utils/describe-error';
import { confirmAction } from '../confirm-dialog/confirm-dialog';
import './job-row';
import './job-details-drawer';
import { applyJobControl } from './job-controls';
import { jobStateStyles } from './job-format';
import { ViewLifecycleMixin } from '../../utils/view-lifecycle';
type LibraryInfo = library.Info;
/** Job states meaning the job will not progress further. */
const TERMINAL_STATES: ReadonlySet<string> = new Set([
'complete',
'cancelled',
'error',
]);
/**
* Full-page view of background work: everything running right now, the
* per-library scan controls that used to live in Settings, and a short
* history of what recently finished.
*
* This is the same job rows as the top-bar popover at a larger density
* one implementation, two placements, so the two can never disagree.
*/
@customElement('jobs-view')
export class JobsView extends ViewLifecycleMixin(LitElement) {
@state()
private jobs: Job[] = [];
@state()
private libraries: LibraryInfo[] = [];
@state()
private drawerJobId = '';
@state()
private drawerOpen = false;
/**
* Set between pressing a scan button and the job snapshot that
* proves it started. `anyScanning` is derived from `JobsChanged`,
* which is coalesced at 250 ms long enough for a second click to
* start a second scan (errors.M5).
*/
@state()
private starting = false;
private unsubscribe: (() => void) | null = null;
private eventCleanups: Array<() => void> = [];
static override styles = [
designTokens,
jobStateStyles,
css`
:host {
display: block;
overflow-y: auto;
height: 100%;
padding: 1.5em 1.75em 3em;
box-sizing: border-box;
}
/* The header supplies its own padding and rule, so it runs
to the edge of a host that pads its own content. */
page-header {
margin: -1.5em -1.75em 1em;
}
h1 {
font-size: var(--yj-text-xl);
color: var(--yj-text-primary, #e9ecef);
margin: 0 0 0.2em;
}
.page-sub {
font-size: var(--yj-text-md);
color: var(--yj-text-tertiary, #868e96);
margin: 0 0 1.75em;
}
section {
margin-bottom: 2em;
}
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1em;
margin-bottom: 0.75em;
}
h2 {
font-size: var(--yj-text-sm);
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--yj-text-tertiary, #868e96);
margin: 0;
}
.card {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 10px;
overflow: hidden;
}
.card > * + * {
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.job-entry {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: 0.5em;
padding-right: 0.75em;
}
.empty {
padding: 1.1em;
font-size: var(--yj-text-md);
color: var(--yj-text-tertiary, #868e96);
font-style: italic;
}
.library-row {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: 1em;
padding: 0.75em 0.9em;
}
.library-name {
font-size: var(--yj-text-md);
color: var(--yj-text-primary, #e9ecef);
}
.library-meta {
font-size: var(--yj-text-sm);
color: var(--yj-text-tertiary, #868e96);
margin-top: 0.15em;
overflow-wrap: anywhere;
}
.library-state {
font-size: var(--yj-text-sm);
color: var(--job-tone);
margin-top: 0.15em;
}
button.action {
display: inline-flex;
align-items: center;
gap: 0.45em;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 7px;
background: rgba(255, 255, 255, 0.05);
color: var(--yj-text-primary, #e9ecef);
font-size: var(--yj-text-sm);
padding: 0.42em 0.85em;
cursor: pointer;
white-space: nowrap;
transition:
background-color 120ms ease,
border-color 120ms ease;
}
button.action:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.11);
}
button.action:disabled {
opacity: 0.4;
cursor: default;
}
button.action:focus-visible {
outline: 2px solid var(--yj-accent, #ffd43b);
outline-offset: 2px;
}
button.action.danger {
color: var(--yj-error-text, #ff8787);
border-color: rgba(255, 107, 107, 0.35);
}
button.action.danger:hover:not(:disabled) {
background: rgba(255, 107, 107, 0.12);
}
button.link {
border: none;
background: transparent;
color: var(--yj-accent-text, #ffd43b);
font-size: var(--yj-text-sm);
cursor: pointer;
padding: 0.2em 0.4em;
border-radius: 5px;
}
button.link:hover {
text-decoration: underline;
}
.details-btn {
border: none;
background: transparent;
color: var(--yj-text-secondary, #adb5bd);
font-size: var(--yj-text-sm);
cursor: pointer;
padding: 0.3em 0.5em;
border-radius: 6px;
white-space: nowrap;
}
.details-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: var(--yj-text-primary, #e9ecef);
}
`,
];
protected override onViewActivate(): void {
this.unsubscribe = jobStore.subscribe(() => {
this.jobs = jobStore.jobs;
});
void jobStore.init();
this.jobs = jobStore.jobs;
void this.loadLibraries();
// Library CRUD happens elsewhere; keep the picker in step.
for (const event of [
Events.LibraryAdded,
Events.LibraryRemoved,
Events.LibraryRenamed,
Events.LibraryScanComplete,
]) {
this.eventCleanups.push(
EventsOn(event, () => void this.loadLibraries()),
);
}
}
protected override onViewDeactivate(): void {
this.unsubscribe?.();
this.unsubscribe = null;
this.eventCleanups.forEach((off) => off());
this.eventCleanups = [];
}
private async loadLibraries(): Promise<void> {
try {
this.libraries = (await GetAllLibrariesWithTrackCounts()) ?? [];
} catch (err) {
console.error('Failed to load libraries:', err);
}
}
/** The scan job for a library, if one is registered. */
private jobForLibrary(id: number): Job | undefined {
return jobStore.getJob(`scan:${id}`);
}
private openDetails(id: string) {
this.drawerJobId = id;
this.drawerOpen = true;
}
private onDrawerClosed = () => {
this.drawerOpen = false;
};
/**
* Run something that starts a job, holding the buttons until the
* snapshot lands and saying so when it does not start at all.
*
* Persistent, not a toast: the user asked for work to happen, it
* did not, and retrying is exactly the useful response.
*/
private async startJob(
what: string,
start: () => Promise<unknown>,
retry: () => void,
): Promise<void> {
if (this.starting) return;
this.starting = true;
try {
await start();
} catch (err) {
console.error(`${what} failed:`, err);
notificationStore.persistent({
key: 'scan-start',
title: 'Scan did not start',
text: `${what} failed. ${describeError(err)}`,
detail: String(err),
action: { label: 'Try again', run: retry },
});
} finally {
this.starting = false;
}
}
private async startScan(id: number) {
await this.startJob(
'Scanning that library',
() => ScanLibrary(id),
() => void this.startScan(id),
);
}
private async startAllScans() {
await this.startJob(
'Scanning your libraries',
() => ScanAllLibraries(),
() => void this.startAllScans(),
);
}
private async clearFinished() {
await jobStore.clearFinished();
}
private async fullRescan() {
const ok = await confirmAction({
title: 'Full rescan',
message:
'This deletes all library data — including downloaded ' +
'cover art — and rebuilds it from your files.',
impact:
'It is not the same as “Scan now”, which only picks up ' +
'what changed.',
confirmLabel: 'Rebuild everything',
danger: true,
});
if (!ok) return;
await this.startJob(
'The full rescan',
() => FullRescan(),
() => void this.fullRescan(),
);
}
private renderJobList(list: Job[], emptyText: string) {
if (list.length === 0) {
return html`<div class="card">
<div class="empty">${emptyText}</div>
</div>`;
}
return html`
<div class="card">
${list.map(
(job) => html`
<div class="job-entry">
<job-row
.job=${job}
variant="full"
@job-control=${applyJobControl}
></job-row>
<button
class="details-btn"
@click=${() => this.openDetails(job.id)}
>
Details${job.warnCount
? ` · ${job.warnCount}`
: ''}
</button>
</div>
`,
)}
</div>
`;
}
/** The status line under a library name in the scan-control list. */
private libraryStatus(job: Job | undefined): string | null {
if (!job) return null;
switch (job.state) {
case 'running':
return job.phase ? `Scanning · ${job.phase}` : 'Scanning';
case 'queued':
return 'Queued';
case 'paused':
return 'Paused';
case 'pausing':
return 'Pausing…';
case 'cancelling':
return 'Stopping…';
default:
return null;
}
}
private renderLibraryRow(lib: LibraryInfo) {
const job = this.jobForLibrary(lib.id);
const status = this.libraryStatus(job);
const busy = status !== null;
return html`
<div class="library-row">
<div>
<div class="library-name">${lib.name}</div>
<div class="library-meta">
${lib.trackCount.toLocaleString()} tracks · ${lib.path}
</div>
${status
? html`<div class="library-state">${status}</div>`
: nothing}
</div>
${busy
? html`
<button
class="link"
@click=${() => this.openDetails(`scan:${lib.id}`)}
>
View progress
</button>
`
: html`
<button
class="action"
?disabled=${this.starting}
@click=${() => this.startScan(lib.id)}
>
<wa-icon name="arrows-rotate"></wa-icon>
Scan now
</button>
`}
</div>
`;
}
override render() {
// Derived from `this.jobs` rather than the store getters so Lit
// sees the reactive dependency and re-renders on every snapshot.
const active = this.jobs.filter((j) => !TERMINAL_STATES.has(j.state));
const finished = this.jobs.filter((j) => TERMINAL_STATES.has(j.state));
const anyScanning = this.libraries.some((lib) =>
Boolean(this.libraryStatus(this.jobForLibrary(lib.id))),
);
return html`
<page-header heading="Background jobs"></page-header>
<p class="page-sub">
Library scans and search index builds, with their progress and
output.
</p>
<section>
<div class="section-head">
<h2>Running now</h2>
</div>
${this.renderJobList(active, 'Nothing is running.')}
</section>
<section>
<div class="section-head">
<h2>Libraries</h2>
<button
class="action"
?disabled=${anyScanning ||
this.starting ||
this.libraries.length === 0}
@click=${this.startAllScans}
>
<wa-icon name="arrows-rotate"></wa-icon>
Scan all
</button>
</div>
<div class="card">
${this.libraries.length === 0
? html`<div class="empty">
No libraries yet add one in Settings.
</div>`
: this.libraries.map((lib) =>
this.renderLibraryRow(lib),
)}
</div>
</section>
<section>
<div class="section-head">
<h2>Maintenance</h2>
</div>
<div class="card">
<div class="library-row">
<div>
<div class="library-name">Full rescan</div>
<div class="library-meta">
Wipes all library data and cover art, then
rebuilds from your files. Only needed when the
library is corrupt a normal scan already
picks up changes.
</div>
</div>
<button
class="action danger"
?disabled=${anyScanning ||
this.starting ||
this.libraries.length === 0}
@click=${this.fullRescan}
>
<wa-icon name="triangle-exclamation"></wa-icon>
Full rescan
</button>
</div>
</div>
</section>
${finished.length > 0
? html`
<section>
<div class="section-head">
<h2>Recently finished</h2>
<button
class="link"
@click=${this.clearFinished}
>
Clear
</button>
</div>
${this.renderJobList(finished, '')}
</section>
`
: nothing}
<job-details-drawer
job-id=${this.drawerJobId}
?open=${this.drawerOpen}
@drawer-closed=${this.onDrawerClosed}
></job-details-drawer>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'jobs-view': JobsView;
}
}
@@ -0,0 +1,133 @@
import { LitElement, html, css } from 'lit';
import { customElement } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import { designTokens } from '../../styles/tokens.css';
import { HistoryController } from '@store/controllers/history-controller';
/**
* Global back and forward, in the top bar (#6).
*
* **The stack was already global; the affordance was not.** Every
* navigation has been a history entry since the Android back gesture
* landed, and `popstate` restores any of them in either direction --
* `back-navigation.spec.ts` has asserted `goForward()` since it was
* written. What the report describes as "back is tab-scoped" is that
* the *only* way back was a detail view's own button, which vanishes
* the moment you leave for another tab: the album you were reading is
* still one entry away, and nothing on screen says so or offers it.
*
* Four things about this are load-bearing.
*
* **It asks the shell rather than the History API.** `history.length`
* counts entries this app did not push and never shrinks, and there is
* no way to ask where in the list you are -- so a control derived from
* it is confidently wrong at both ends. `historyStore` is the shell's
* own numbering.
*
* **A control that cannot act is `disabled`, not hidden.** This is the
* one place in the app where that is right rather than the fault
* `library-status-indicator` was: back and forward are a *pair* whose
* positions the user learns, and a button that disappears at the end
* of the list moves the other one under the cursor. It is also what
* every browser does, which is the whole design brief here.
*
* **The buttons dispatch the events the rest of the app already
* dispatches**, `navigate-back` and `navigate-forward`, rather than
* calling `history.back()` themselves. The shell owns the guard -- one
* press is one entry, and at the root there is nothing of ours to go
* back to -- and a second caller reaching for `history` directly is
* how the old `navStack` came to disagree with the platform.
*
* **It is desktop chrome.** Below 600px the phone has a system back
* gesture (and, on Android, a hardware/gesture Back that this app
* hooks), the top bar is 3.25em with three other things in it, and two
* more 32px targets there would be the first thing to overflow. Hidden
* by `index.css` at that width, next to the rest of the phone header's
* concessions.
*/
@customElement('nav-history')
export class NavHistory extends LitElement {
private historyCtrl = new HistoryController(this);
static override styles = [designTokens, css`
:host {
display: flex;
align-items: center;
gap: 0.25em;
/* A grid item's implicit minimum is its content; this one
genuinely cannot shrink, so it says so rather than
letting the header widen the body. */
flex: 0 0 auto;
}
button {
display: flex;
align-items: center;
justify-content: center;
width: 2em;
height: 2em;
padding: 0;
border: none;
border-radius: 50%;
background: transparent;
color: var(--yj-text-primary, #f8f9fa);
cursor: pointer;
font-size: 1em;
}
button:hover:not(:disabled) {
background-color: var(--yj-bg-overlay, #495057);
}
button:focus-visible {
outline: 2px solid var(--yj-accent, #ffd43b);
outline-offset: 2px;
}
button:disabled {
/* Not a contrast failure: a disabled control is exempt from
1.4.3, and the pair has to read as unavailable rather
than merely quiet. */
color: var(--yj-text-tertiary, #868e96);
cursor: default;
}
`];
private go(direction: 'back' | 'forward') {
this.dispatchEvent(new CustomEvent(`navigate-${direction}`, {
bubbles: true,
composed: true,
}));
}
override render() {
const { canBack, canForward } = this.historyCtrl.depth;
return html`
<button
type="button"
data-testid="history-back"
aria-label="Back"
?disabled=${!canBack}
@click=${() => this.go('back')}
>
<wa-icon name="arrow-left"></wa-icon>
</button>
<button
type="button"
data-testid="history-forward"
aria-label="Forward"
?disabled=${!canForward}
@click=${() => this.go('forward')}
>
<wa-icon name="arrow-right"></wa-icon>
</button>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'nav-history': NavHistory;
}
}
@@ -13,6 +13,7 @@ import {
isQueueSourceNavigable, isQueueSourceNavigable,
navigateToQueueSource, navigateToQueueSource,
} from '@utils/queue-source-link'; } from '@utils/queue-source-link';
import { PHONE_QUERY } from '@utils/breakpoints';
import { PlayerController } from '@store/controllers/player-controller'; import { PlayerController } from '@store/controllers/player-controller';
import { creditStore } from '@store/credit-store'; import { creditStore } from '@store/credit-store';
import { QueueController } from '@store/controllers/queue-controller'; import { QueueController } from '@store/controllers/queue-controller';
@@ -80,6 +81,19 @@ export class NowPlaying extends LitElement {
private reduceMotionQuery?: MediaQueryList; private reduceMotionQuery?: MediaQueryList;
/**
* Phone width, from the shell's own breakpoint.
*
* This is in JS rather than in the stylesheet because what changes
* is the *content*, not its appearance: the title, artist and
* source render as plain text instead of as links, and no CSS rule
* can take a click handler off an element.
*/
@state()
private phone = false;
private phoneQuery?: MediaQueryList;
/** Whether each field is actively mid-scroll (class toggle). */ /** Whether each field is actively mid-scroll (class toggle). */
@state() @state()
private titleScrolling = false; private titleScrolling = false;
@@ -341,6 +355,12 @@ export class NowPlaying extends LitElement {
this.reduceMotion = this.reduceMotionQuery?.matches ?? false; this.reduceMotion = this.reduceMotionQuery?.matches ?? false;
this.reduceMotionQuery?.addEventListener('change', this.handleReduceMotionChange); this.reduceMotionQuery?.addEventListener('change', this.handleReduceMotionChange);
// Same reasoning as above: looked up here, not at module load,
// so a test can install its own matchMedia first.
this.phoneQuery = window.matchMedia?.(PHONE_QUERY);
this.phone = this.phoneQuery?.matches ?? false;
this.phoneQuery?.addEventListener('change', this.handlePhoneChange);
this.resizeObserver = new ResizeObserver(() => { this.resizeObserver = new ResizeObserver(() => {
this.geometryDirty = true; this.geometryDirty = true;
this.requestUpdate(); this.requestUpdate();
@@ -364,6 +384,7 @@ export class NowPlaying extends LitElement {
this.attachDragListeners(false); this.attachDragListeners(false);
window.removeEventListener(SCROLL_CHANGE_EVENT, this.handleScrollModeEvent); window.removeEventListener(SCROLL_CHANGE_EVENT, this.handleScrollModeEvent);
this.reduceMotionQuery?.removeEventListener('change', this.handleReduceMotionChange); this.reduceMotionQuery?.removeEventListener('change', this.handleReduceMotionChange);
this.phoneQuery?.removeEventListener('change', this.handlePhoneChange);
this.resizeObserver?.disconnect(); this.resizeObserver?.disconnect();
this.stopScrollCycle('title'); this.stopScrollCycle('title');
this.stopScrollCycle('artist'); this.stopScrollCycle('artist');
@@ -488,7 +509,7 @@ export class NowPlaying extends LitElement {
@mouseleave=${this.handleTitleMouseLeave} @mouseleave=${this.handleTitleMouseLeave}
@transitionend=${() => this.onScrollCycleEnd('title')} @transitionend=${() => this.onScrollCycleEnd('title')}
> >
<span class="scroll-content">${trackLink(track.title, track.album, track.releaseGroupMbid, track.recordingMbid) || track.title}</span> <span class="scroll-content">${this.phone ? track.title : trackLink(track.title, track.album, track.releaseGroupMbid, track.recordingMbid) || track.title}</span>
</span> </span>
<span <span
class="track-artist ${artistScrolling ? 'will-scroll' : ''} ${this.artistScrolling ? 'scrolling' : ''}" class="track-artist ${artistScrolling ? 'will-scroll' : ''} ${this.artistScrolling ? 'scrolling' : ''}"
@@ -498,14 +519,15 @@ export class NowPlaying extends LitElement {
@mouseleave=${this.handleArtistMouseLeave} @mouseleave=${this.handleArtistMouseLeave}
@transitionend=${() => this.onScrollCycleEnd('artist')} @transitionend=${() => this.onScrollCycleEnd('artist')}
> >
<span class="scroll-content">${creditLink(creditStore.credits(track.recordingMbid), track.artist, track.artistMbid) || 'Unknown Artist'}</span> <span class="scroll-content">${this.phone ? track.artist || 'Unknown Artist' : creditLink(creditStore.credits(track.recordingMbid), track.artist, track.artistMbid) || 'Unknown Artist'}</span>
</span> </span>
${describeQueueSource(this.queue.source) ${describeQueueSource(this.queue.source)
? html` ? html`
<span <span
class="track-source ${isQueueSourceNavigable(this.queue.source) ? 'navigable' : ''}" class="track-source ${!this.phone && isQueueSourceNavigable(this.queue.source) ? 'navigable' : ''}"
data-testid="now-playing-source" data-testid="now-playing-source"
@click=${(e: MouseEvent) => { @click=${(e: MouseEvent) => {
if (this.phone) return;
if (!isQueueSourceNavigable(this.queue.source)) return; if (!isQueueSourceNavigable(this.queue.source)) return;
navigateToQueueSource( navigateToQueueSource(
e.currentTarget as EventTarget, e.currentTarget as EventTarget,
@@ -571,6 +593,10 @@ export class NowPlaying extends LitElement {
this.reduceMotion = e.matches; this.reduceMotion = e.matches;
}; };
private handlePhoneChange = (e: MediaQueryListEvent): void => {
this.phone = e.matches;
};
private shouldScroll(field: 'title' | 'artist'): boolean { private shouldScroll(field: 'title' | 'artist'): boolean {
const overflows = field === 'title' ? this.titleOverflows : this.artistOverflows; const overflows = field === 'title' ? this.titleOverflows : this.artistOverflows;
@@ -606,6 +632,12 @@ export class NowPlaying extends LitElement {
track?.artist ?? '', track?.artist ?? '',
this.shouldScroll('title') ? '1' : '0', this.shouldScroll('title') ? '1' : '0',
this.shouldScroll('artist') ? '1' : '0', this.shouldScroll('artist') ? '1' : '0',
// Crossing the breakpoint swaps a link for a bare string,
// and a link is not guaranteed to measure the same as the
// text inside it. The marquee travels a distance read from
// that measurement, so this belongs in the key even though
// the words are identical either side.
this.phone ? '1' : '0',
].join('\u0000'); ].join('\u0000');
} }
@@ -1,8 +1,16 @@
import { LitElement, html, css, nothing } from 'lit'; import { LitElement, html, css, nothing } from 'lit';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property, query, state } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js'; import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@awesome.me/webawesome/dist/components/popup/popup.js';
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js';
import { designTokens } from '../../styles/tokens.css'; import { designTokens } from '../../styles/tokens.css';
import {
MenuKeyboard,
contextMenuStyles,
} from '../../utils/context-menu-controller';
import { ICON_MORE_ACTIONS } from '../../utils/icon-language';
/** /**
* The one arrangement every primary view uses to say what it is. * The one arrangement every primary view uses to say what it is.
@@ -18,6 +26,19 @@ import { designTokens } from '../../styles/tokens.css';
* Title, count, sort, actions in that order, in one component, so a * Title, count, sort, actions in that order, in one component, so a
* new view gets the shape by using it rather than by copying whichever * new view gets the shape by using it rather than by copying whichever
* neighbour it happened to read. * neighbour it happened to read.
*
* **Actions are data, and `<slot name="actions">` is the exception.**
* Playlists' three buttons totalled 390px inside a header that gets
* 700px at 900×600 and clipped "New Smart Playlist" to 114 of its 162
* (#69) a live defect at a size the app promises, against plan 018's
* *no action is ever unreachable at any supported size*. The header
* cannot fix that for slotted markup: it cannot move another
* component's light-DOM children into a dropdown and keep their
* behaviour, and arbitrary markup offers nothing generic to render as
* a menu item. So a host declares `PageAction[]` and the header picks
* the rendering. The slot survives for markup a data list genuinely
* cannot express, at the stated cost that **a slotted action does not
* collapse** and must therefore fit at 800×600.
*/ */
export interface SortOption { export interface SortOption {
@@ -27,6 +48,50 @@ export interface SortOption {
export type SortDirection = 'asc' | 'desc'; export type SortDirection = 'asc' | 'desc';
/**
* An action that only makes sense while it is a button.
*
* A drop target is the case: you cannot drag a track onto a closed
* menu, so the affordance is absent from the overflow rather than
* approximated there. The header wires these onto the button it
* renders and owns none of them the same division the sort control
* already lives by.
*/
export interface PageActionDrop {
/** True while an acceptable payload is over the button. */
active?: boolean;
onDragOver: (e: DragEvent) => void;
onDragLeave: (e: DragEvent) => void;
onDrop: (e: DragEvent) => void;
}
/**
* One thing a view can do, as data rather than as markup.
*
* `<slot name="actions">` cannot be collapsed, and that is a fact about
* the API rather than an effort estimate (#69): a component cannot move
* another component's light-DOM children into a dropdown and keep their
* behaviour, and there is nothing generic in arbitrary markup to render
* as a menu item. Declaring an action instead is what lets the header
* choose between the two renderings.
*/
export interface PageAction {
id: string;
label: string;
/** From `utils/icon-language`, never a literal. */
icon: string;
onSelect: () => void;
/**
* Higher survives longer. The lowest collapses first, ties broken
* by declaration order from the right, so a host that says nothing
* gets "the last one written goes first".
*/
priority?: number;
disabled?: boolean;
title?: string;
drop?: PageActionDrop;
}
@customElement('page-header') @customElement('page-header')
export class PageHeader extends LitElement { export class PageHeader extends LitElement {
/** /**
@@ -80,8 +145,82 @@ export class PageHeader extends LitElement {
@property({ type: Boolean }) @property({ type: Boolean })
busy = false; busy = false;
/**
* What this view can do, in the order it wants them shown.
*
* The header decides what *fits*; the host decides what *happens*.
* That is the rule the sort control already lives by it asks for
* a sort rather than performing one and actions follow it, which
* is why an action carries a handler rather than the header
* carrying a verb it would have to interpret.
*/
@property({ attribute: false })
actions: PageAction[] = [];
/** Action ids currently in the overflow menu. Derived, never set by a host. */
@state()
private collapsed: ReadonlySet<string> = new Set();
@state()
private menuOpen = false;
@query('.page-header')
private headerEl?: HTMLElement;
@query('.more-button')
private moreButton?: HTMLButtonElement;
@query('#page-header-overflow')
private menuPanel?: HTMLElement;
@query('wa-popup')
private popup?: WaPopup;
private menuKeyboard = new MenuKeyboard(() => this.closeMenu());
private resizeObserver?: ResizeObserver;
/**
* Whether the outside-click listener is attached.
*
* A `removeEventListener` with no matching `add` is not harmless
* here: `view-lifecycle.test.ts` counts document listeners across a
* view's life and an unconditional detach on disconnect shows up as
* `held: -1`, which is the same accounting that would hide a real
* leak in the other direction.
*/
private outsideCloseAttached = false;
/**
* What the last fit was measured against.
*
* `updated()` runs on every pass, so it has to say what it depends
* on or it re-measures and a measurement here forces synchronous
* layout. Width changes arrive through the ResizeObserver; this key
* covers everything *else* in the flex row that can change how much
* of it the actions are left.
*/
private lastFitKey = '';
override connectedCallback(): void {
super.connectedCallback();
this.resizeObserver = new ResizeObserver(() => this.measureFit());
this.resizeObserver.observe(this);
}
override disconnectedCallback(): void {
super.disconnectedCallback();
this.resizeObserver?.disconnect();
this.resizeObserver = undefined;
this.detachOutsideClose();
}
static override styles = [ static override styles = [
designTokens, designTokens,
contextMenuStyles,
css` css`
:host { :host {
display: block; display: block;
@@ -96,12 +235,26 @@ export class PageHeader extends LitElement {
border-bottom: 1px solid var(--yj-border-subtle, #333); border-bottom: 1px solid var(--yj-border-subtle, #333);
} }
/* The title gives way before an action does.
Everything in this row was flex-shrink: 0, so whatever
came last lost and the actions come last, which is how
the "More actions" button ended up 76px off the right
edge of a 320px viewport with every action already
collapsed into it. The title is the one thing here the
navigation also says (the sidebar item is selected, the
bottom-nav tab is current), so it is the cheapest thing
to truncate; the count, the sort and the actions are each
the only place they are said. */
h1 { h1 {
margin: 0; margin: 0;
font-size: var(--yj-text-xl, 18px); font-size: var(--yj-text-xl, 18px);
font-weight: 600; font-weight: 600;
color: var(--yj-text-primary, #fff); color: var(--yj-text-primary, #fff);
white-space: nowrap; white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
} }
.count { .count {
@@ -191,6 +344,98 @@ export class PageHeader extends LitElement {
::slotted(*) { ::slotted(*) {
flex-shrink: 0; flex-shrink: 0;
} }
.actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.action,
.more-button {
background: none;
border: 1px solid var(--yj-border-subtle, #555);
border-radius: 4px;
color: var(--yj-text-primary, #fff);
padding: 6px 12px;
font-size: var(--yj-text-md, 13px);
font-family: inherit;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
white-space: nowrap;
flex-shrink: 0;
}
.more-button {
padding: 6px 10px;
}
/* The display: flex above outranks the UA stylesheet's
rule for [hidden], and hiding is how an action
collapses. (No backticks in here: one ends the css
literal, and what you get is "css(...) is not a
function" a long way from the cause.) */
.action[hidden],
.more-button[hidden] {
display: none;
}
.action:hover,
.more-button:hover,
.action.drag-over {
border-color: var(--yj-accent, #ffd43b);
color: var(--yj-accent-text, #ffd43b);
}
.action.drag-over {
background-color: var(
--yj-accent-bg-strong,
rgba(255, 212, 59, 0.15)
);
}
.action:disabled {
opacity: 0.5;
cursor: default;
}
.action:focus-visible,
.more-button:focus-visible {
outline: 2px solid var(--yj-accent, #ffd43b);
outline-offset: -1px;
}
wa-popup {
z-index: 200;
}
/* A component states what it drops at phone width itself,
in its own stylesheet, because a media query inside a
shadow root is answered by the viewport and the shell
cannot reach in. Here that is one word: the sort control
is 172px of a 320px header, and "Sort:" is ~40px of it
for a label the adjacent direction arrow already implies.
It stays in the accessibility tree it is the select's
accessible name, so hiding it outright would rename the
control to nothing which is config-field's bug, one
component over. clip-path rather than display: none for
the reason styles/sr-only.css.ts gives. */
@media (max-width: 599px) {
.sort-label {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
}
`, `,
]; ];
@@ -212,11 +457,293 @@ export class PageHeader extends LitElement {
${this.renderCount()} ${this.renderCount()}
<div class="spacer"></div> <div class="spacer"></div>
${this.renderScope()} ${this.renderSort()} ${this.renderScope()} ${this.renderSort()}
${this.renderActions()}
<slot name="actions"></slot> <slot name="actions"></slot>
</header> </header>
`; `;
} }
protected override updated(): void {
const key = [
this.heading,
this.count,
this.countNoun,
this.countPlural,
this.searchTerm,
this.sortOptions.length,
this.sortField,
this.sortDirection,
this.busy,
this.actions.map((a) => `${a.id}:${a.label}:${a.disabled ?? false}`).join(','),
].join('|');
if (key === this.lastFitKey) return;
this.lastFitKey = key;
this.measureFit();
}
// =================================================================
// What fits
// =================================================================
/**
* Decide which actions are buttons and which are menu items.
*
* Two things about the shape of this are load-bearing.
*
* **Every pass starts from all-visible**, so the collapsed set is a
* pure function of the current width rather than of the order the
* widths arrived in. A rule that only ever *added* to the set would
* never give an action back when the window grew, and one that
* adjusted by a step would need a hysteresis band to stop it
* oscillating on the pixel where a button exactly fits.
*
* **It flips `hidden` on the rendered nodes rather than re-rendering
* between steps.** Reading `scrollWidth` forces layout, which is the
* point; awaiting a Lit update between steps instead would let the
* intermediate all-visible state paint, so the fix would flash the
* overflow it exists to prevent. The reactive state is set once, at
* the end, and the next render agrees with what was measured.
*
* The budget is the *header's* overflow and not the actions row's,
* because the count and the sort control are `flex-shrink: 0` and
* are therefore competing for the same width only `.scope` gives
* way, which is what it has an ellipsis for.
*/
private measureFit(): void {
const header = this.headerEl;
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>(
'[data-action-id]',
)) {
const id = el.dataset['actionId'];
if (id !== undefined) buttons.set(id, el);
}
const more = this.moreButton;
const title = this.renderRoot.querySelector('h1');
/**
* Nothing is clipped which is not the same as the header not
* overflowing, and the difference is a trap worth naming.
*
* Once the title can ellipsis, it absorbs the pressure and
* `scrollWidth` reports a header that fits perfectly while the
* heading reads "Playlis…". That is this issue's own failure
* mode moved from the button to the title, and it is invisible
* to exactly the same measurement that missed it the first time.
* So the title's own truncation counts as not fitting, and
* collapsing an action is tried before the title gives way.
*/
const fits = () =>
header.scrollWidth <= header.clientWidth &&
(title === null || title.scrollWidth <= title.clientWidth + 1);
for (const el of buttons.values()) el.hidden = false;
if (more) more.hidden = true;
const collapsed = new Set<string>();
if (!fits()) {
if (more) more.hidden = false;
for (const action of this.collapseOrder()) {
collapsed.add(action.id);
const el = buttons.get(action.id);
if (el) el.hidden = true;
if (fits()) break;
}
}
this.commitCollapsed(collapsed);
}
/** Lowest priority first; ties broken from the right. */
private collapseOrder(): PageAction[] {
return this.actions
.map((action, index) => ({ action, index }))
.sort(
(a, b) =>
(a.action.priority ?? 0) - (b.action.priority ?? 0) ||
b.index - a.index,
)
.map(({ action }) => action);
}
private commitCollapsed(next: Set<string>): void {
const same =
next.size === this.collapsed.size &&
[...next].every((id) => this.collapsed.has(id));
if (same) return;
this.collapsed = next;
// Nothing left to show in it. Closing rather than leaving an
// empty menu open is the same rule the shelves follow.
if (next.size === 0 && this.menuOpen) this.closeMenu();
}
// =================================================================
// Rendering
// =================================================================
private renderActions() {
if (this.actions.length === 0) return nothing;
const overflowed = this.actions.filter((a) => this.collapsed.has(a.id));
return html`
<div class="actions">
${this.actions.map((a) => this.renderActionButton(a))}
<wa-popup
placement="bottom-end"
flip
shift
.active=${this.menuOpen}
>
<button
slot="anchor"
class="more-button"
type="button"
data-testid="page-actions-more"
aria-label="More actions"
aria-haspopup="menu"
aria-expanded=${this.menuOpen ? 'true' : 'false'}
aria-controls="page-header-overflow"
?hidden=${overflowed.length === 0}
@click=${this.onMoreClick}
>
<wa-icon name=${ICON_MORE_ACTIONS}></wa-icon>
</button>
<div
id="page-header-overflow"
class="context-menu-panel"
role="menu"
aria-label="More actions"
>
${overflowed.map(
(a) => html`
<wa-dropdown-item
?disabled=${a.disabled ?? false}
@click=${() => this.onActionSelect(a)}
>
<wa-icon
slot="icon"
name=${a.icon}
></wa-icon>
${a.label}
</wa-dropdown-item>
`,
)}
</div>
</wa-popup>
</div>
`;
}
private renderActionButton(a: PageAction) {
const drop = a.drop;
return html`
<button
class="action ${drop?.active === true ? 'drag-over' : ''}"
type="button"
data-action-id=${a.id}
data-testid=${`page-action-${a.id}`}
title=${a.title ?? nothing}
?disabled=${a.disabled ?? false}
?hidden=${this.collapsed.has(a.id)}
@click=${() => a.onSelect()}
@dragover=${(e: DragEvent) => drop?.onDragOver(e)}
@dragleave=${(e: DragEvent) => drop?.onDragLeave(e)}
@drop=${(e: DragEvent) => drop?.onDrop(e)}
>
<wa-icon name=${a.icon}></wa-icon>
${a.label}
</button>
`;
}
// =================================================================
// The overflow menu
// =================================================================
private onActionSelect(a: PageAction): void {
if (a.disabled === true) return;
this.closeMenu();
a.onSelect();
}
private onMoreClick = (): void => {
if (this.menuOpen) {
this.closeMenu();
return;
}
this.menuOpen = true;
void this.updateComplete.then(() => {
if (!this.menuOpen) return;
this.popup?.reposition();
this.menuKeyboard.open(this.menuPanel ?? null, this.moreButton);
this.attachOutsideClose();
});
};
private closeMenu(): void {
if (!this.menuOpen) return;
this.detachOutsideClose();
this.menuKeyboard.close();
this.menuOpen = false;
}
/**
* A click anywhere else closes it. `composedPath` rather than
* `contains`, because the trigger and the panel are both inside
* this shadow root and a click retargets at the host.
*/
private onOutsideDown = (e: Event): void => {
if (e.composedPath().includes(this.menuPanel as EventTarget)) return;
if (e.composedPath().includes(this.moreButton as EventTarget)) return;
this.closeMenu();
};
private attachOutsideClose(): void {
if (this.outsideCloseAttached) return;
this.outsideCloseAttached = true;
document.addEventListener('mousedown', this.onOutsideDown, true);
}
private detachOutsideClose(): void {
if (!this.outsideCloseAttached) return;
this.outsideCloseAttached = false;
document.removeEventListener('mousedown', this.onOutsideDown, true);
}
private renderCount() { private renderCount() {
if (this.count === null) return nothing; if (this.count === null) return nothing;
@@ -258,7 +785,10 @@ export class PageHeader extends LitElement {
if (this.sortOptions.length === 1) { if (this.sortOptions.length === 1) {
return html` return html`
<div class="sort"> <div class="sort">
<span>Sort: ${this.sortOptions[0]?.label}</span> <span
><span class="sort-label">Sort: </span
>${this.sortOptions[0]?.label}</span
>
${this.renderDirectionButton(ascending)} ${this.renderDirectionButton(ascending)}
</div> </div>
`; `;
@@ -267,7 +797,7 @@ export class PageHeader extends LitElement {
return html` return html`
<div class="sort"> <div class="sort">
<label> <label>
Sort: <span class="sort-label">Sort:</span>
<select <select
data-testid="page-sort" data-testid="page-sort"
.value=${this.sortField} .value=${this.sortField}
@@ -40,7 +40,10 @@ import type { DuplicateTracksDialog } from '@components/duplicate-tracks-dialog/
import { import {
ICON_NEW, ICON_NEW,
ICON_PLAYLIST, ICON_PLAYLIST,
ICON_SMART_PLAYLIST,
} from '@utils/icon-language'; } from '@utils/icon-language';
import '@components/page-header/page-header';
import type { PageAction } from '@components/page-header/page-header';
const SCROLL_DEBOUNCE_MS = 100; const SCROLL_DEBOUNCE_MS = 100;
@@ -194,11 +197,6 @@ export class PlaylistView extends ViewLifecycleMixin(LitElement) {
contain: layout style; contain: layout style;
} }
.header-actions {
display: flex;
gap: 8px;
}
.header-spinner { .header-spinner {
display: inline-block; display: inline-block;
width: 14px; width: 14px;
@@ -209,33 +207,6 @@ export class PlaylistView extends ViewLifecycleMixin(LitElement) {
animation: spin 0.6s linear infinite; animation: spin 0.6s linear infinite;
} }
.new-playlist-button {
background: none;
border: 1px solid var(--yj-border-subtle, #555);
border-radius: 4px;
color: var(--yj-text-primary, #fff);
padding: 6px 12px;
font-size: 13px;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
font-family: inherit;
}
.new-playlist-button:hover,
.new-playlist-button.drag-over {
border-color: var(--yj-accent, #ffd43b);
color: var(--yj-accent-text, #ffd43b);
}
.new-playlist-button.drag-over {
background-color: var(
--yj-accent-bg-strong,
rgba(255, 212, 59, 0.15)
);
}
.create-form { .create-form {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -455,25 +426,6 @@ export class PlaylistView extends ViewLifecycleMixin(LitElement) {
min-width: 0; min-width: 0;
} }
.import-button {
background: none;
border: 1px solid var(--yj-border-subtle, #555);
border-radius: 4px;
color: var(--yj-text-primary, #fff);
padding: 6px 12px;
font-size: 13px;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
font-family: inherit;
}
.import-button:hover {
border-color: var(--yj-accent, #ffd43b);
color: var(--yj-accent-text, #ffd43b);
}
.import-error { .import-error {
padding: 0.5em 0.75em; padding: 0.5em 0.75em;
margin: 0.5em 16px 0; margin: 0.5em 16px 0;
@@ -1022,10 +974,11 @@ export class PlaylistView extends ViewLifecycleMixin(LitElement) {
) => { ) => {
const related = const related =
e.relatedTarget as Node | null; e.relatedTarget as Node | null;
const btn = // The button the event was bound to, rather than a selector for
this.shadowRoot?.querySelector( // it: `page-header` renders it now, so it is not in this shadow
'.new-playlist-button', // root at all and the old `.new-playlist-button` lookup would
); // find nothing and leave the highlight stuck on.
const btn = e.currentTarget as Element | null;
if (btn && !btn.contains(related)) { if (btn && !btn.contains(related)) {
this.dragOverNewButton = false; this.dragOverNewButton = false;
@@ -1470,6 +1423,49 @@ export class PlaylistView extends ViewLifecycleMixin(LitElement) {
this.saveSortPreferences(); this.saveSortPreferences();
}; };
/**
* The three things this page can do, as data.
*
* The priority order is what #69's Direction asks for and it is
* only interesting for one of them: **New Playlist is highest
* because it is the drop target**. You cannot drag a track onto a
* closed menu, so collapsing it is the one collapse here that
* removes a capability rather than relocating it. Import is lowest
* because it is the rarest, and at 900×600 it is the only one that
* has to go.
*/
private headerActions(): PageAction[] {
return [
{
id: 'import',
label: 'Import',
icon: 'file-import',
priority: 0,
onSelect: () => void this.handleImportPlaylist(),
},
{
id: 'new-playlist',
label: 'New Playlist',
icon: ICON_NEW,
priority: 2,
onSelect: () => this.handleNewPlaylistClick(),
drop: {
active: this.dragOverNewButton,
onDragOver: this.onNewButtonDragOver,
onDragLeave: this.onNewButtonDragLeave,
onDrop: this.onNewButtonDrop,
},
},
{
id: 'new-smart-playlist',
label: 'New Smart Playlist',
icon: ICON_SMART_PLAYLIST,
priority: 1,
onSelect: () => this.handleNewSmartPlaylistClick(),
},
];
}
override render() { override render() {
return html` return html`
<page-header <page-header
@@ -1484,33 +1480,8 @@ export class PlaylistView extends ViewLifecycleMixin(LitElement) {
search-term=${this.searchCtrl.term} search-term=${this.searchCtrl.term}
?busy=${this.refreshing} ?busy=${this.refreshing}
@sort-change=${this.onPageHeaderSort} @sort-change=${this.onPageHeaderSort}
.actions=${this.headerActions()}
> >
<div slot="actions" class="header-actions">
<button
class="import-button"
@click=${this.handleImportPlaylist}
>
<wa-icon name="file-import"></wa-icon>
Import
</button>
<button
class="new-playlist-button ${this.dragOverNewButton ? 'drag-over' : ''}"
@click=${this.handleNewPlaylistClick}
@dragover=${this.onNewButtonDragOver}
@dragleave=${this.onNewButtonDragLeave}
@drop=${this.onNewButtonDrop}
>
<wa-icon name=${ICON_NEW}></wa-icon>
New Playlist
</button>
<button
class="new-playlist-button"
@click=${this.handleNewSmartPlaylistClick}
>
<wa-icon name="filter"></wa-icon>
New Smart Playlist
</button>
</div>
</page-header> </page-header>
${this.importError ${this.importError
@@ -1765,7 +1736,7 @@ export class PlaylistView extends ViewLifecycleMixin(LitElement) {
: entry.summary.IsSmart : entry.summary.IsSmart
? html`<wa-icon ? html`<wa-icon
class="playlist-icon" class="playlist-icon"
name="filter" name=${ICON_SMART_PLAYLIST}
></wa-icon>` ></wa-icon>`
: nothing} : nothing}
${isRenaming ${isRenaming
@@ -277,6 +277,26 @@ export class QueuePanel
return this.queue.tracks.length; return this.queue.tracks.length;
} }
/**
* Repaint the rows when the selection changes.
*
* `<lit-virtualizer>` renders through the `virtualize` directive,
* which reacts to its *own* properties and not to the host having
* re-rendered, so host state like a selection reaches the rows only
* if it is pushed. `track-list` has always done this and both
* playlist views had to be taught it.
*
* **There is a second, accidental mechanism here and it must not be
* mistaken for this one**: `.keyFunction` below is a per-render
* arrow, so it is a changed property on every host update and
* repaints the rows by itself. Removing *either* alone changes
* nothing observable, which is why #43 could not be settled by
* reading the code. With both gone the highlight still arrives
* on whatever unrelated render happens next, measured at 134ms,
* 3,866ms and 5,816ms against 517ms healthy, which a user cannot
* tell from broken. `queue-selection.spec.ts` asserts the
* *promptness* rather than the eventual state for that reason.
*/
onSelectionChanged(): void { onSelectionChanged(): void {
this.virtualizer?.requestUpdate(); this.virtualizer?.requestUpdate();
} }
+38 -56
View File
@@ -4,19 +4,10 @@ import '@awesome.me/webawesome/dist/components/icon/icon.js';
import { designTokens } from '../../styles/tokens.css'; import { designTokens } from '../../styles/tokens.css';
import type { DragActiveDetail } from '@utils/drag-controller'; import type { DragActiveDetail } from '@utils/drag-controller';
import { import { ActiveViewController } from '@store/controllers/active-view-controller';
ICON_PLAYLIST, import { ViewVisibilityController } from '@store/controllers/view-visibility-controller';
ICON_AUTOTAG, import { VIEW_META } from '../../services/view-meta';
ICON_REQUESTED, import type { View } from '../../services/view-meta';
} from '@utils/icon-language';
type View = 'home' | 'playlists' | 'artists' | 'genres' | 'albums' | 'tracks' | 'explore' | 'downloads' | 'autotag' | 'jobs' | 'settings';
interface NavItem {
id: View;
label: string;
icon: string;
}
const MIN_WIDTH = 56; const MIN_WIDTH = 56;
const MAX_WIDTH = 400; const MAX_WIDTH = 400;
@@ -159,11 +150,20 @@ export class AppSidebar extends LitElement {
/** Delay in ms before a drag-hover triggers navigation. */ /** Delay in ms before a drag-hover triggers navigation. */
private static readonly HOVER_NAV_DELAY = 600; private static readonly HOVER_NAV_DELAY = 600;
/** Home, because that is where `index.ts` now navigates on startup /**
* (H-8). The sidebar does not hear a `navigate` it did not send, * Which item is lit, read from the shell rather than tracked here.
* so this default is what keeps `aria-current` honest on arrival. */ *
@state() * This used to be a `@state()` field defaulting to `home` -- the
private activeView: View = 'home'; * landing view -- because "the sidebar does not hear a `navigate`
* it did not send". That default was the only honest moment it
* ever had: a back-navigation dispatches no `navigate`, so the
* highlight stayed on the view the user had just left (#72), and
* the copy of this component that `bottom-nav` mounts inside its
* drawer opened on `home` from whatever page you were standing on.
* The shell publishes the active view now, so there is nothing to
* default and nothing to keep in step.
*/
private activeCtrl = new ActiveViewController(this);
@state() @state()
private isDragging = false; private isDragging = false;
@@ -200,19 +200,15 @@ export class AppSidebar extends LitElement {
typeof setTimeout typeof setTimeout
> | null = null; > | null = null;
private navItems: NavItem[] = [ /**
{ id: 'home', label: 'Home', icon: 'house' }, * Which destinations the user has kept (#25). The list below is
{ id: 'playlists', label: 'Playlists', icon: ICON_PLAYLIST }, * still the whole set and its order -- this only filters it, and
{ id: 'artists', label: 'Artists', icon: 'user-group' }, * only for drawing: a hidden view is still reachable by `navigate`,
{ id: 'genres', label: 'Genres', icon: 'masks-theater' }, * which is what detail views and the launch page depend on.
{ id: 'albums', label: 'Albums', icon: 'compact-disc' }, */
{ id: 'tracks', label: 'Tracks', icon: 'music' }, private visibilityCtrl = new ViewVisibilityController(this);
{ id: 'explore', label: 'Explore', icon: 'globe' },
{ id: 'downloads', label: 'Downloads', icon: ICON_REQUESTED }, private navItems = VIEW_META;
{ id: 'autotag', label: 'Autotag', icon: ICON_AUTOTAG },
{ id: 'jobs', label: 'Jobs', icon: 'list-check' },
{ id: 'settings', label: 'Settings', icon: 'gear' },
];
override connectedCallback() { override connectedCallback() {
super.connectedCallback(); super.connectedCallback();
@@ -237,10 +233,6 @@ export class AppSidebar extends LitElement {
'yj-drag-active', 'yj-drag-active',
this.onDragActive as EventListener, this.onDragActive as EventListener,
); );
document.addEventListener(
'navigate',
this.onGlobalNavigate as EventListener,
);
} }
override disconnectedCallback() { override disconnectedCallback() {
@@ -262,10 +254,6 @@ export class AppSidebar extends LitElement {
'yj-drag-active', 'yj-drag-active',
this.onDragActive as EventListener, this.onDragActive as EventListener,
); );
document.removeEventListener(
'navigate',
this.onGlobalNavigate as EventListener,
);
this.clearDragHoverTimer(); this.clearDragHoverTimer();
} }
@@ -281,9 +269,12 @@ export class AppSidebar extends LitElement {
></div> ></div>
<nav aria-label="Main"> <nav aria-label="Main">
<ul> <ul>
${this.navItems.map((item) => { ${this.navItems
.filter((item) => this.visibilityCtrl.visible(item.id))
.map((item) => {
const active = this.activeCtrl.isActive(item.id);
const classes = [ const classes = [
this.activeView === item.id active
? 'active' ? 'active'
: '', : '',
this.dragHoverView === item.id this.dragHoverView === item.id
@@ -299,7 +290,7 @@ export class AppSidebar extends LitElement {
type="button" type="button"
class=${classes} class=${classes}
data-testid="nav-${item.id}" data-testid="nav-${item.id}"
aria-current=${this.activeView === item.id aria-current=${active
? 'page' ? 'page'
: 'false'} : 'false'}
@click=${() => @click=${() =>
@@ -382,19 +373,6 @@ export class AppSidebar extends LitElement {
private static readonly DROP_VIEWS: Set<View> = private static readonly DROP_VIEWS: Set<View> =
new Set(['playlists']); new Set(['playlists']);
/** Keeps the highlighted nav item in sync with navigation that
* originates outside the sidebar itself (e.g. the launch-page
* dispatch in index.ts). */
private onGlobalNavigate = (
e: CustomEvent<{ view?: string }>,
) => {
const view = e.detail.view;
if (view && this.navItems.some((item) => item.id === view)) {
this.activeView = view as View;
}
};
private onDragActive = ( private onDragActive = (
e: CustomEvent<DragActiveDetail>, e: CustomEvent<DragActiveDetail>,
) => { ) => {
@@ -460,7 +438,11 @@ export class AppSidebar extends LitElement {
} }
private navigate(view: View) { private navigate(view: View) {
this.activeView = view; // No optimistic highlight: the shell answers, and it answers
// synchronously in `handleNavigate` before it awaits anything.
// Setting it here as well is the second opinion this fix
// removes -- it is what let a click's highlight survive a
// navigation the shell then handled differently.
this.dispatchEvent(new CustomEvent('navigate', { this.dispatchEvent(new CustomEvent('navigate', {
detail: { view }, detail: { view },
bubbles: true, bubbles: true,
@@ -64,6 +64,7 @@ import { list } from '@utils/binding';
import { import {
ICON_PLAYLIST, ICON_PLAYLIST,
ICON_QUEUE, ICON_QUEUE,
ICON_SMART_PLAYLIST,
} from '@utils/icon-language'; } from '@utils/icon-language';
@@ -1214,7 +1215,7 @@ export class SmartPlaylistDetails
<wa-icon name="arrow-left"></wa-icon> <wa-icon name="arrow-left"></wa-icon>
</button> </button>
<div class="playlist-avatar"> <div class="playlist-avatar">
<wa-icon name="filter"></wa-icon> <wa-icon name=${ICON_SMART_PLAYLIST}></wa-icon>
</div> </div>
<div class="playlist-info"> <div class="playlist-info">
<h1 <h1
@@ -11,6 +11,7 @@ import {
import { SelectionController } from '@utils/selection-controller'; import { SelectionController } from '@utils/selection-controller';
import type { SelectionHost } from '@utils/selection-controller'; import type { SelectionHost } from '@utils/selection-controller';
import { ViewLifecycleMixin } from '@utils/view-lifecycle'; import { ViewLifecycleMixin } from '@utils/view-lifecycle';
import { PHONE_QUERY } from '@utils/breakpoints';
import { import {
ContextMenuController, ContextMenuController,
contextMenuStyles, contextMenuStyles,
@@ -105,9 +106,6 @@ const ROW_CHROME_WIDTH =
const ROW_HEIGHT = 33; const ROW_HEIGHT = 33;
const PHONE_ROW_HEIGHT = 52; const PHONE_ROW_HEIGHT = 52;
/** The shell's phone breakpoint, as `index.css` and every component
* stylesheet spells it. */
const PHONE_QUERY = '(max-width: 599px)';
// Inline SVG paths for favorite icons — eliminates wa-icon shadow DOM // Inline SVG paths for favorite icons — eliminates wa-icon shadow DOM
// overhead (30-50 shadow roots during scroll). Font Awesome 6 paths. // overhead (30-50 shadow roots during scroll). Font Awesome 6 paths.
+2
View File
@@ -19,6 +19,7 @@ regular/heart
regular/star regular/star
solid/arrow-down-wide-short solid/arrow-down-wide-short
solid/arrow-left solid/arrow-left
solid/arrow-right
solid/arrow-rotate-right solid/arrow-rotate-right
solid/arrows-rotate solid/arrows-rotate
solid/arrow-up-short-wide solid/arrow-up-short-wide
@@ -41,6 +42,7 @@ solid/compact-disc
solid/copy solid/copy
solid/database solid/database
solid/download solid/download
solid/ellipsis
solid/file-import solid/file-import
solid/filter solid/filter
solid/floppy-disk solid/floppy-disk
@@ -400,6 +400,20 @@ async function dispatch(action: string): Promise<void> {
break; break;
} }
// The keyboard half of #6. It dispatches the same events the
// header's buttons and the detail views' own back buttons do,
// rather than calling `history.back()` here: the shell owns the
// guard that stops a press at the root leaving the app, and a
// second caller reaching for `history` directly is how the old
// `navStack` came to disagree with the platform.
case 'nav.back':
document.dispatchEvent(new CustomEvent('navigate-back'));
break;
case 'nav.forward':
document.dispatchEvent(new CustomEvent('navigate-forward'));
break;
case 'nav.queue': { case 'nav.queue': {
const queuePanel = document.getElementById( const queuePanel = document.getElementById(
'queue-panel', 'queue-panel',
+12
View File
@@ -103,6 +103,18 @@ export const SHORTCUT_META: Record<string, ShortcutMeta> = {
scope: 'global', scope: 'global',
defaultKey: 'Q', defaultKey: 'Q',
}, },
'nav.back': {
label: 'Back',
category: 'Navigation',
scope: 'global',
defaultKey: 'Alt+Left',
},
'nav.forward': {
label: 'Forward',
category: 'Navigation',
scope: 'global',
defaultKey: 'Alt+Right',
},
'app.shortcuts': { 'app.shortcuts': {
label: 'Keyboard Shortcuts', label: 'Keyboard Shortcuts',
category: 'App', category: 'App',
+199
View File
@@ -0,0 +1,199 @@
/**
* What the top bar drops when it runs out of room (#143).
*
* The bar holds five children the wordmark, back/forward, the library
* filter, the search box and the job indicator and at the bottom of
* the Compact band they do not all fit. Measured on `main` at 600×600:
* the bar is 611px inside a 600px viewport sitting still, and **862px
* while a scan with a long title is running**, because `job-indicator`
* is `hidden` when idle and up to 235px wide when it is not. `body` is
* `overflow-x: auto`, so what a user sees is a horizontal scrollbar on
* a shell that #24 promised would not need one.
*
* **The fit is measured, never breakpointed**, which is `page-header`'s
* rule (#69) and applies here for a reason specific to this bar: three
* of its five children are as wide as their *content*. The library
* filter is a `<select>` sized by the longest library name, the job
* indicator by the running job's title, and the search box by its
* view-scoped placeholder so any width you pick is right for exactly
* one library, one job and one view. The same sweep that produced the
* numbers above found the bar overflowing at every width from 600 to
* 899 *and* at 900, where `nav-history` reappears; a breakpoint fixing
* "600 to 610" would have fixed the case that happened to be idle.
*
* **What yields is chosen by #24's own sentence** *no action is ever
* unreachable at any supported size* which rules out the two cheapest
* candidates the issue lists. Hiding the library filter takes away an
* action: `library-filter` is the **only** control in the app that sets
* the selected library (nothing else calls `setSelectedLibrary`), so
* hiding it is trading this promise for the same promise. Collapsing
* the search box to an icon is what #57 wants on a phone, but #57 is
* blocked behind #62 and building its modal here would be building it
* without the thing that blocks it.
*
* So the two things that yield are the two that are **not** actions and
* whose content survives elsewhere:
*
* 1. **The wordmark**, which is a brand the window's own title bar
* says the same thing, and #48 wants it down to "YJ" at every width
* anyway. It yields its *width*, not its existence: the rule in
* `index.css` is visually-hidden rather than `display: none`, so the
* document keeps its top-level heading.
* 2. **The job indicator's label**, leaving the ring. This is not a new
* judgement the component already drops it below 600px for exactly
* this reason, and its `sr-only` live region is what announces the
* state either way, so nothing is lost to anyone. What a measurement
* adds is the band between 600 and 900, where whether the label fits
* depends on what else is in the bar rather than on the width alone.
*
* Measured against the running app with a long-titled scan staged, that
* order fits at every width from 320 to 1440 and collapses nothing at
* 320, 390, 599, 899 and 1100, which is the other half of the claim.
*
* Three things about the mechanism are load-bearing.
*
* **Every pass starts from all-visible**, so the collapsed set is a
* pure function of the current width rather than of how the window got
* there. `page-header` states the same rule and the same reasons: a
* pass that only ever added would never give the wordmark back, and one
* that adjusted by a step would need a hysteresis band to stop it
* oscillating on the pixel where it exactly fits.
*
* **"Fits" is the children against the content box, not `scrollWidth`
* against `clientWidth`** and that distinction is not pedantry, it
* is a measured false pass. `scrollWidth` counts a box's *left*
* padding and not its right, so with this bar's 2em gutters it
* under-reports by 32px: at 700px with a scan running it read
* `700/700`, a perfect fit, while `job-indicator` ended 32px past
* where the content may go and sat in the gutter. Same family as #69's
* title trap, one property over the measurement that is easiest to
* reach for is the one that cannot see the failure. So the predicate
* here is the same one `top-bar-fit.spec.ts` asserts: no in-flow child
* outside the content box.
*
* That is only truthful in turn because **nothing here absorbs pressure
* by truncating**. The collapsible children are `flex-shrink: 0` in
* `index.css`, so a deficit shows up as a child out of bounds instead
* of quietly eating the indicator's label, which is `text-overflow:
* ellipsis` and would have. The search box is the one child that may
* shrink, between its 320px basis and the 200px floor its own
* stylesheet sets, and a narrower input hides nothing it was showing.
*
* **The bar does not resize when a job starts**, which is the case the
* whole thing is for. A ResizeObserver on the header alone never fires:
* the indicator goes 0 235 inside a bar whose width has not changed.
* Every element child is observed too.
*/
/** One thing the bar can give up, cheapest first. */
interface FitStep {
/** For tests and for reading the DOM back. */
readonly id: string;
/** Applied to the bar; `on` collapses. */
readonly collapse: (bar: HTMLElement, on: boolean) => void;
}
/**
* The order things are given up in. Lowest priority first see the
* argument above for why these two and not the library filter.
*/
export const FIT_STEPS: readonly FitStep[] = [
{
id: 'wordmark',
collapse: (bar, on) =>
bar.querySelector('hgroup')?.classList.toggle('yj-collapsed', on),
},
{
id: 'job-label',
collapse: (bar, on) =>
bar.querySelector('job-indicator')?.toggleAttribute('compact', on),
},
];
/**
* Decide what the bar shows at its current width.
*
* Exported for the component tier, which can hand it a bar of known
* widths; the app installs the observer below and never calls this.
*
* @returns the ids collapsed, in the order they were given up.
*/
export function measureTopBarFit(bar: HTMLElement): string[] {
const fits = () => {
const style = getComputedStyle(bar);
const box = bar.getBoundingClientRect();
const left = box.left + parseFloat(style.paddingLeft);
const right = box.right - parseFloat(style.paddingRight);
for (const child of bar.children) {
const cs = getComputedStyle(child);
// Out of flow is out of the question: a collapsed wordmark
// is absolutely positioned and 1px wide precisely so that
// it costs the row nothing.
if (cs.display === 'none' || cs.position === 'absolute') continue;
const r = child.getBoundingClientRect();
// Sub-pixel slack: a flex row's widths are fractional and a
// rounding difference is not an overflow anyone can see.
if (r.width > 0 && (r.right > right + 0.5 || r.left < left - 0.5)) {
return false;
}
}
return true;
};
for (const step of FIT_STEPS) step.collapse(bar, false);
const collapsed: string[] = [];
if (!fits()) {
for (const step of FIT_STEPS) {
step.collapse(bar, true);
collapsed.push(step.id);
if (fits()) break;
}
}
return collapsed;
}
/**
* Watch the bar and its children, and keep it fitting.
*
* Returns the uninstaller, which the tests use; the app installs once
* for the life of the session.
*/
export function installTopBarFit(bar: HTMLElement): () => void {
let measuring = false;
const measure = () => {
// A pass resizes the children it collapses, which the observer
// would report back to us. It settles either way — the pass is
// idempotent at a given width — but re-entering it is work for
// no news, and it is what "ResizeObserver loop completed with
// undelivered notifications" is.
if (measuring) return;
measuring = true;
try {
measureTopBarFit(bar);
} finally {
measuring = false;
}
};
const observer = new ResizeObserver(measure);
observer.observe(bar);
for (const child of bar.children) observer.observe(child);
measure();
return () => observer.disconnect();
}
+64
View File
@@ -0,0 +1,64 @@
import {
ICON_PLAYLIST,
ICON_AUTOTAG,
ICON_REQUESTED,
} from '@utils/icon-language';
/** A primary destination. Mirrors `backend/config.View`. */
export type View =
| 'home'
| 'playlists'
| 'artists'
| 'genres'
| 'albums'
| 'tracks'
| 'explore'
| 'downloads'
| 'autotag'
| 'settings';
export interface ViewMeta {
id: View;
label: string;
icon: string;
/**
* Views that are never offered as a toggle. Settings alone, because
* a user who hides it cannot get back to unhide it.
*
* This is the *affordance*; the rule is `backend/config.ViewSpec`'s
* `Hideable`, which refuses at the setter and drops the key on load.
* `config.toml` is hand-editable, so the checkbox being absent is
* not what makes this safe it is only what stops the question
* being asked.
*/
alwaysShown?: boolean;
}
/**
* The app's primary destinations, in the order the navigation draws
* them and Settings lists them.
*
* It is here rather than inside `app-sidebar` because #25 gave it a
* second reader: Settings renders a toggle per view and needs the same
* labels in the same order. Same shape as `services/shortcut-meta.ts`,
* which moved out of `config-page` for the same reason -- a private
* static that two surfaces need is a private static that is about to be
* copied.
*
* The labels and icons deliberately do not exist in Go. Which views
* exist and what an unconfigured install shows is `backend/config.Views`
* and is asked for over the binding; how they are *drawn* is the
* frontend's, and lives beside the rest of the icon vocabulary.
*/
export const VIEW_META: ViewMeta[] = [
{ id: 'home', label: 'Home', icon: 'house' },
{ id: 'playlists', label: 'Playlists', icon: ICON_PLAYLIST },
{ id: 'artists', label: 'Artists', icon: 'user-group' },
{ id: 'genres', label: 'Genres', icon: 'masks-theater' },
{ id: 'albums', label: 'Albums', icon: 'compact-disc' },
{ id: 'tracks', label: 'Tracks', icon: 'music' },
{ id: 'explore', label: 'Explore', icon: 'globe' },
{ id: 'downloads', label: 'Downloads', icon: ICON_REQUESTED },
{ id: 'autotag', label: 'Autotag', icon: ICON_AUTOTAG },
{ id: 'settings', label: 'Settings', icon: 'gear', alwaysShown: true },
];
+90
View File
@@ -0,0 +1,90 @@
/**
* Which primary view the app is showing.
*
* The shell has always known this -- `handleNavigate()` sets
* `#main-content`'s `data-active-view` on every path, `_isBack`
* included -- and never told anyone. The nav components learned it
* from the `navigate` CustomEvent instead, which only the *outbound*
* path dispatches: the `popstate` listener calls `handleNavigate()`
* directly. So both navs kept highlighting the view you had just left
* (#72).
*
* The fix cannot be a re-dispatch of `navigate`. `index.ts` is itself a
* document listener for it, so emitting one from inside
* `handleNavigate` is an infinite loop -- and the two statements are
* different anyway: `navigate` means *please go to X*, and 28 call
* sites across 18 files say it. This says *the active view is now X*,
* which only the shell is in a position to say and only once per
* navigation.
*
* Three things about it are load-bearing.
*
* **It is a store rather than an event**, because a component that
* mounts *after* a navigation still has to know. `bottom-nav`'s "More"
* drawer creates its `<app-sidebar>` on open, and that copy had heard
* no `navigate` at all: standing on Albums, the drawer highlighted
* Home -- its `activeView` default, which existed to match the landing
* view and matched nothing else ever after. An event has no answer for
* a listener that was not there; a value does.
*
* **A detail view is not a view here.** Opening one leaves the primary
* view it was opened from lit, which is what #72 asks for and what
* `app-sidebar` used to do by accident -- it guarded on
* `navItems.some(...)`, so a name matching no item left its highlight
* alone. `bottom-nav` had no such guard and so lit nothing on a detail
* view. Neither was correct; the sidebar was stale-but-lucky, and
* stating the rule once is what makes the two agree.
*
* **Whether a view is primary is the shell's fact, not this store's.**
* `VIEW_TAGS` in `index.ts` is the list, and a copy of it here is a
* second list to forget -- so the caller passes the answer it already
* has rather than this file re-deriving it.
*/
type Subscriber = () => void;
class ActiveViewStore {
/** Empty until the shell's first navigation, which happens at
* startup from `GetDefaultPage()`. Nothing is highlighted for that
* moment, which is honest: the alternative is a written-down
* default that is right only when the default page agrees with it. */
private activeView = '';
private subscribers = new Set<Subscriber>();
/** The active primary view, e.g. `albums`. */
get(): string {
return this.activeView;
}
isActive(view: string): boolean {
return this.activeView !== '' && this.activeView === view;
}
/**
* Called by the shell on every navigation, `popstate` included.
*
* `isPrimary` is `view in VIEW_TAGS` at the call site: a detail
* view reports itself and deliberately changes nothing, so the view
* it was opened from stays lit until the user picks another one.
*/
setView(view: string, isPrimary: boolean): void {
if (!isPrimary) return;
if (view === this.activeView) return;
this.activeView = view;
this.notify();
}
subscribe(fn: Subscriber): () => void {
this.subscribers.add(fn);
return () => this.subscribers.delete(fn);
}
private notify(): void {
this.subscribers.forEach((fn) => fn());
}
}
export const activeViewStore = new ActiveViewStore();
@@ -0,0 +1,59 @@
import type {
ReactiveController,
ReactiveControllerHost,
} from 'lit';
import { activeViewStore } from '../active-view-store';
/**
* ActiveViewController connects a Lit component to the
* ActiveViewStore.
*
* Usage in a component:
*
* private activeCtrl = new ActiveViewController(this);
*
* render() {
* const lit = this.activeCtrl.isActive('albums');
* }
*
* It reads through to the store rather than copying the value into a
* `@state()` field, which is the point of #72: two components holding
* their own idea of the active view is what let them disagree with the
* shell and with each other.
*/
export class ActiveViewController implements ReactiveController {
private host: ReactiveControllerHost;
private unsubscribe?: () => void;
constructor(host: ReactiveControllerHost) {
this.host = host;
host.addController(this);
}
// ===============================================================
// LIFECYCLE HOOKS
// ===============================================================
hostConnected(): void {
this.unsubscribe = activeViewStore.subscribe(() => {
this.host.requestUpdate();
});
}
hostDisconnected(): void {
this.unsubscribe?.();
}
// ===============================================================
// DATA ACCESS
// ===============================================================
/** The active primary view, e.g. `albums`. */
get current(): string {
return activeViewStore.get();
}
isActive(view: string): boolean {
return activeViewStore.isActive(view);
}
}
@@ -0,0 +1,40 @@
import type {
ReactiveController,
ReactiveControllerHost,
} from 'lit';
import { historyStore, type HistoryDepth } from '../history-store';
/**
* HistoryController connects a Lit component to the HistoryStore.
*
* Usage in a component:
*
* private historyCtrl = new HistoryController(this);
*
* render() {
* const { canBack } = this.historyCtrl.depth;
* }
*/
export class HistoryController implements ReactiveController {
private host: ReactiveControllerHost;
private unsubscribe?: () => void;
constructor(host: ReactiveControllerHost) {
this.host = host;
host.addController(this);
}
hostConnected(): void {
this.unsubscribe = historyStore.subscribe(() => {
this.host.requestUpdate();
});
}
hostDisconnected(): void {
this.unsubscribe?.();
}
get depth(): HistoryDepth {
return historyStore.get();
}
}
@@ -0,0 +1,54 @@
import type {
ReactiveController,
ReactiveControllerHost,
} from 'lit';
import { viewVisibilityStore } from '../view-visibility-store';
/**
* ViewVisibilityController connects a Lit component to the
* ViewVisibilityStore.
*
* It reads through to the store rather than copying the map into a
* `@state()` field, for the reason `ActiveViewController` does: there
* are two live `<app-sidebar>` instances the moment `bottom-nav`'s
* "More" drawer opens, and two components holding their own idea of
* which destinations exist is how they come to disagree.
*/
export class ViewVisibilityController implements ReactiveController {
private host: ReactiveControllerHost;
private unsubscribe?: () => void;
constructor(host: ReactiveControllerHost) {
this.host = host;
host.addController(this);
}
hostConnected(): void {
this.unsubscribe = viewVisibilityStore.subscribe(() => {
this.host.requestUpdate();
});
void viewVisibilityStore.init();
}
hostDisconnected(): void {
this.unsubscribe?.();
}
/** Whether the navigation should offer this destination. */
visible(view: string): boolean {
return viewVisibilityStore.visible(view);
}
/**
* What the config says, ignoring the download-client gate the
* state Settings' own checkbox shows.
*/
enabled(view: string): boolean {
return viewVisibilityStore.enabled(view);
}
setVisible(view: string, visible: boolean): Promise<void> {
return viewVisibilityStore.setVisible(view, visible);
}
}
+21
View File
@@ -189,6 +189,8 @@ class DownloadStore {
private initialized = false; private initialized = false;
private providersLoaded = false;
constructor() { constructor() {
EventsOn(Events.DownloadProvidersChanged, () => { EventsOn(Events.DownloadProvidersChanged, () => {
void this.refreshProviders(); void this.refreshProviders();
@@ -285,6 +287,25 @@ class DownloadStore {
} }
} }
/**
* Loads the providers, and only those, once.
*
* `init()` additionally fetches the descriptors, the downloads and
* the request list, which is right for a page about downloading and
* wrong for the sidebar: it only needs `available`, to decide
* whether the Downloads destination exists at all (#25), and that
* is one query. `DownloadProvidersChanged` keeps it current
* afterwards, so configuring a client makes the tab appear without
* a restart.
*/
async ensureProviders(): Promise<void> {
if (this.providersLoaded) return;
this.providersLoaded = true;
await this.refreshProviders();
}
async refreshProviders(): Promise<void> { async refreshProviders(): Promise<void> {
try { try {
this.providersValue = (await ListProviders()) ?? []; this.providersValue = (await ListProviders()) ?? [];
+66
View File
@@ -0,0 +1,66 @@
/**
* How far the session can go back and forward.
*
* The History API exposes `length` and nothing useful: it counts
* entries the app did not push, does not say where in the list the
* current entry is, and `popstate` fires *identically* whether the
* user went back or forward. So a control that wants to grey itself
* out has to be told, and the shell is the only thing in a position to
* know (#6).
*
* Two rules follow from how the shell counts, and both are the reason
* this is a pair of booleans rather than one depth:
*
* **Forward is not "back, negated".** `pushedEntries` -- the counter
* this replaces -- decremented on every `popstate`, which made a
* forward navigation look like a second back. The shell keeps an index
* per entry and a high-water mark instead, and publishes the two
* answers rather than the arithmetic.
*
* **Back stops at the app's own floor.** The launch entry is
* *replaced*, not pushed, so that one back press from the root exits
* the app on Android; `canBack` is false there, which is what stops
* the header's own button being the thing that quits.
*/
type Subscriber = () => void;
export interface HistoryDepth {
canBack: boolean;
canForward: boolean;
}
class HistoryStore {
private depth: HistoryDepth = { canBack: false, canForward: false };
private subscribers = new Set<Subscriber>();
get(): HistoryDepth {
return this.depth;
}
/** Called by the shell whenever an entry is pushed or restored. */
setDepth(canBack: boolean, canForward: boolean): void {
if (
canBack === this.depth.canBack &&
canForward === this.depth.canForward
) {
return;
}
this.depth = { canBack, canForward };
this.notify();
}
subscribe(fn: Subscriber): () => void {
this.subscribers.add(fn);
return () => this.subscribers.delete(fn);
}
private notify(): void {
this.subscribers.forEach((fn) => fn());
}
}
export const historyStore = new HistoryStore();
+5
View File
@@ -9,6 +9,11 @@ export type { ThemeState, BackgroundShade } from './theme-store';
export { ThemeController } from './controllers/theme-controller'; export { ThemeController } from './controllers/theme-controller';
export { searchStore } from './search-store'; export { searchStore } from './search-store';
export { SearchController } from './controllers/search-controller'; export { SearchController } from './controllers/search-controller';
export { activeViewStore } from './active-view-store';
export { ActiveViewController } from './controllers/active-view-controller';
export { historyStore } from './history-store';
export type { HistoryDepth } from './history-store';
export { HistoryController } from './controllers/history-controller';
export { shortcutsStore } from './shortcuts-store'; export { shortcutsStore } from './shortcuts-store';
export type { ShortcutsState } from './shortcuts-store'; export type { ShortcutsState } from './shortcuts-store';
export { ShortcutsController } from './controllers/shortcuts-controller'; export { ShortcutsController } from './controllers/shortcuts-controller';
+1
View File
@@ -30,6 +30,7 @@ export type JobState =
export type JobKind = export type JobKind =
| 'library-scan' | 'library-scan'
| 'index-build' | 'index-build'
| 'download'
| 'autotag-apply' | 'autotag-apply'
| 'catalog-enrich'; | 'catalog-enrich';
+119
View File
@@ -0,0 +1,119 @@
import { EventsOn } from '@runtime/runtime';
import { GetViewVisibility, SetViewVisible } from '@go/config/config.js';
import { dictByName } from '@utils/binding';
import { downloadStore } from './download-store';
import { Events } from '../events';
type Subscriber = () => void;
/**
* Which primary destinations the navigation offers.
*
* Eleven sidebar entries is more than most libraries need, so #25 makes
* them individually toggleable. Three rules about this are load-bearing.
*
* **Hidden is not unreachable.** This decides what the *nav* draws and
* nothing else: `navigate` still resolves a hidden view, which is not a
* nicety detail views navigate into these, and the shell's launch
* page is one of them. Nothing here needs a special case for the
* highlight either, because #72 moved that onto `active-view-store`:
* `app-sidebar` asks `isActive(id)` per *rendered* item, so a hidden
* view lights nothing exactly as a detail view does.
*
* **The defaults live in Go**, in `backend/config.Views`, and this asks
* for the *resolved* answer rather than the stored map. A config that
* says nothing about a view means "that view's own default", so a copy
* of the defaults here would be a second thing to keep in step and
* the one that shipped in the artifact, not the one being edited.
*
* **Downloads is a second question**, answered by the download client
* rather than by the config: a destination for a feature that cannot
* work is worse than an absent one. It is gated at `visible()` and not
* in the config, so switching it on in Settings still means what it
* says once a client exists. `available` is false until the providers
* have loaded, which makes the tab *appear* on a fresh launch rather
* than appearing and then vanishing the less jarring half of a race
* that resolves in one query.
*/
class ViewVisibilityStore {
/** The backend's resolved answer, empty until the first load. */
private configured: Record<string, boolean> = {};
private loaded = false;
private subscribers = new Set<Subscriber>();
constructor() {
EventsOn(Events.GeneralConfigChanged, () => {
void this.refresh();
});
// A client configured later has to add the destination without a
// restart -- #37's rule, one surface over.
downloadStore.subscribe(() => this.notify());
}
/** Loads the visibility map once. Safe to call from every mount. */
async init(): Promise<void> {
if (this.loaded) return;
this.loaded = true;
await Promise.all([
this.refresh(),
downloadStore.ensureProviders(),
]);
}
/**
* Whether the navigation should offer this destination.
*
* An unknown id is visible: the caller is drawing it from its own
* list, and a view this store has not heard of (or has not loaded
* yet) is better shown than silently dropped.
*/
visible(view: string): boolean {
if (view === 'downloads' && !downloadStore.available) return false;
return this.configured[view] ?? true;
}
/**
* What the *config* says, ignoring the download-client gate which
* is what Settings' own checkbox has to show, or a user with no
* client would see Downloads switched off and be unable to switch
* it on.
*/
enabled(view: string): boolean {
return this.configured[view] ?? true;
}
async setVisible(view: string, visible: boolean): Promise<void> {
await SetViewVisible(view, visible);
// The backend emits GeneralConfigChanged, but the caller is
// owed the new state by the time this resolves.
await this.refresh();
}
subscribe(fn: Subscriber): () => void {
this.subscribers.add(fn);
return () => this.subscribers.delete(fn);
}
private async refresh(): Promise<void> {
try {
this.configured = await dictByName(GetViewVisibility());
this.notify();
} catch (err) {
console.error('Failed to load view visibility:', err);
}
}
private notify(): void {
this.subscribers.forEach((fn) => fn());
}
}
export const viewVisibilityStore = new ViewVisibilityStore();
+23
View File
@@ -0,0 +1,23 @@
/**
* The shell's breakpoints, where JavaScript has to agree with CSS.
*
* A media query inside a shadow root is answered by the viewport, so a
* component normally states what it drops at phone width in its own
* stylesheet and needs nothing from here. This exists for the cases
* where the decision is not a style: `track-list` computes its grid in
* JS from the host width, and `now-playing` renders *different content*
* on a phone a plain string instead of a link which no stylesheet
* can express.
*
* One breakpoint, several expressions of it. It was a private const in
* track-list.ts when there was one; a second reader is where a copy
* would start drifting from index.css.
*/
/**
* Phone width. 600px rather than the sidebar's 900px because 900 is a
* laptop: the answer there is a narrower sidebar, which is still a
* sidebar. Below this the shell drops the sidebar column entirely and
* bottom-nav takes over.
*/
export const PHONE_QUERY = '(max-width: 599px)';
+24
View File
@@ -54,6 +54,18 @@ export const ICON_PLAYLIST = 'list';
*/ */
export const ICON_NEW = 'plus'; export const ICON_NEW = 'plus';
/**
* A smart playlist the rule, and the thing the rule makes.
*
* Governed for the reason `ICON_AUTOTAG` states: it was already at
* three call sites (the Playlists header, the row marker beside a smart
* playlist's name, and `smart-playlist-details`'s avatar), and a name
* stops being a detail of one component the moment there are two. It is
* deliberately *not* `ICON_NEW`, even on the button that makes one:
* an icon names the noun it acts on, and the noun here is the rule.
*/
export const ICON_SMART_PLAYLIST = 'filter';
/** /**
* The request ("want") toggle, as an outline/solid pair. * The request ("want") toggle, as an outline/solid pair.
* *
@@ -100,6 +112,18 @@ export const ICON_AUTOTAG = 'tag';
*/ */
export const ICON_DOWNLOADING = 'download'; export const ICON_DOWNLOADING = 'download';
/**
* The rest of what this thing can do.
*
* `page-header` collapses the actions that do not fit into one menu
* behind this, so the glyph has to name *more of the same nouns* rather
* than any one of them which is what an ellipsis is and what `bars`
* (the navigation drawer, one component over in `bottom-nav`) is not.
* It is deliberately the only meaning it carries: an overflow menu that
* shared an icon with a destination would be the `list` problem again.
*/
export const ICON_MORE_ACTIONS = 'ellipsis';
/** /**
* Take this away. * Take this away.
* *
+54 -23
View File
@@ -3,15 +3,21 @@
* *
* Three of these are about the thing that makes a second nav dangerous: * Three of these are about the thing that makes a second nav dangerous:
* it has to agree with the first one. `bottom-nav` emits the same * it has to agree with the first one. `bottom-nav` emits the same
* bubbling, composed `navigate` event `app-sidebar` does and listens * bubbling, composed `navigate` event `app-sidebar` does, and reads
* for that event globally, so a navigation from anywhere a card, a * which tab is lit from `activeViewStore` the shell's one statement
* detail view, the drawer's own sidebar moves its highlight too. A * of where the user is so it follows a navigation from anywhere: a
* tab bar that only tracks its own clicks looks right until the moment * card, a detail view, the drawer's own sidebar, or the back gesture.
* the user arrives somewhere by another route. *
* That last one is why the source is the store and not the `navigate`
* event these tests used to dispatch. `popstate` dispatches no
* `navigate` (index.ts calls `handleNavigate` directly), so a tab bar
* listening for the event looked right until the user pressed back
* #72.
*/ */
import { describe, expect, it, beforeEach } from 'vitest'; import { describe, expect, it, beforeEach } from 'vitest';
import '@components/bottom-nav/bottom-nav'; import '@components/bottom-nav/bottom-nav';
import { activeViewStore } from '@store/active-view-store';
import type { BottomNav } from '@components/bottom-nav/bottom-nav'; import type { BottomNav } from '@components/bottom-nav/bottom-nav';
import { fixture, shadow, shadowAll, update } from '@test/support/render'; import { fixture, shadow, shadowAll, update } from '@test/support/render';
import { resetHarness } from '@test/support/harness'; import { resetHarness } from '@test/support/harness';
@@ -21,6 +27,12 @@ type Nav = BottomNav;
const tabs = (el: HTMLElement) => const tabs = (el: HTMLElement) =>
shadowAll<HTMLButtonElement>(el, 'nav button'); shadowAll<HTMLButtonElement>(el, 'nav button');
/** The testids of whatever the bar says is the current page. */
const current = (el: HTMLElement) =>
tabs(el)
.filter((b) => b.getAttribute('aria-current') === 'page')
.map((b) => b.dataset.testid);
/** Resolve on one occurrence of an event, or reject loudly on time. */ /** Resolve on one occurrence of an event, or reject loudly on time. */
const once = (el: Element, name: string, timeoutMs = 2000) => const once = (el: Element, name: string, timeoutMs = 2000) =>
new Promise<void>((resolve, reject) => { new Promise<void>((resolve, reject) => {
@@ -70,36 +82,55 @@ describe('bottom-nav', () => {
it('follows a navigation it did not send', async () => { it('follows a navigation it did not send', async () => {
const el = await fixture<Nav>('bottom-nav'); const el = await fixture<Nav>('bottom-nav');
document.dispatchEvent(new CustomEvent('navigate', { activeViewStore.setView('tracks', true);
detail: { view: 'tracks' },
bubbles: true,
composed: true,
}));
await update(el, {}); await update(el, {});
const current = tabs(el) expect(current(el)).toEqual(['tab-tracks']);
.filter((b) => b.getAttribute('aria-current') === 'page')
.map((b) => b.dataset.testid);
expect(current).toEqual(['tab-tracks']);
}); });
it('marks exactly one tab current, and none for a view it has no tab for', async () => { it('marks exactly one tab current, and none for a view it has no tab for', async () => {
const el = await fixture<Nav>('bottom-nav'); const el = await fixture<Nav>('bottom-nav');
document.dispatchEvent(new CustomEvent('navigate', { activeViewStore.setView('settings', true);
detail: { view: 'settings' },
bubbles: true,
composed: true,
}));
await update(el, {}); await update(el, {});
// Settings lives in the drawer, so nothing in the bar is current. // Settings lives in the drawer, so nothing in the bar is current.
// Leaving Home highlighted would be a tab bar lying about where // Leaving Home highlighted would be a tab bar lying about where
// the user is. // the user is.
expect( expect(current(el)).toEqual([]);
tabs(el).filter((b) => b.getAttribute('aria-current') === 'page'), });
).toHaveLength(0);
it('keeps the parent tab lit while a detail view is open', async () => {
const el = await fixture<Nav>('bottom-nav');
activeViewStore.setView('albums', true);
// A detail view reports itself and is not primary, so it changes
// nothing. This is the first half of #72: the bar used to take the
// name, match it against no tab, and light nothing at all — while
// `app-sidebar`, which guarded on its own item list, kept the
// highlight. Neither was deliberate and the two disagreed.
activeViewStore.setView('explore-album-details', false);
await update(el, {});
expect(current(el)).toEqual(['tab-albums']);
});
it('follows the back path, which dispatches no navigate event', async () => {
const el = await fixture<Nav>('bottom-nav');
activeViewStore.setView('albums', true);
activeViewStore.setView('tracks', true);
await update(el, {});
expect(current(el)).toEqual(['tab-tracks']);
// What `popstate` does: the shell replays the entry through
// `handleNavigate` without dispatching `navigate`. A bar listening
// for the event stayed on Tracks — the view just left, confidently
// wrong rather than merely blank.
activeViewStore.setView('albums', true);
await update(el, {});
expect(current(el)).toEqual(['tab-albums']);
}); });
it('closes the drawer when a navigation happens', async () => { it('closes the drawer when a navigation happens', async () => {
+55 -2
View File
@@ -10,6 +10,7 @@ import '@components/sidebar/app-sidebar';
import '@components/library-filter/library-filter'; import '@components/library-filter/library-filter';
import '@components/library-status-indicator/library-status-indicator'; import '@components/library-status-indicator/library-status-indicator';
import { Events } from '../../src/events'; import { Events } from '../../src/events';
import { activeViewStore } from '@store/active-view-store';
import { emit, stub, flush, calls, lastArgs } from '@test/support/harness'; import { emit, stub, flush, calls, lastArgs } from '@test/support/harness';
import { import {
fixture, fixture,
@@ -25,7 +26,26 @@ import {
ICON_REQUESTED, ICON_REQUESTED,
} from '@utils/icon-language'; } from '@utils/icon-language';
/** A configured, enabled download client. */
const PROVIDER = {
id: 1,
kind: 'slskd',
name: 'Sound',
enabled: true,
priority: 50,
};
describe('<app-sidebar>', () => { describe('<app-sidebar>', () => {
// Downloads is offered only where there is a client to download with
// (#25), so "all eleven destinations" is a statement about a
// configured install. `view-visibility.test.ts` owns the rule itself;
// this states the world these cases are describing.
beforeEach(async () => {
stub('download.Service.ListProviders', [PROVIDER]);
emit(Events.DownloadProvidersChanged);
await flush();
});
it('renders a testid per destination, which is how e2e navigates', async () => { it('renders a testid per destination, which is how e2e navigates', async () => {
const el = await fixture('app-sidebar'); const el = await fixture('app-sidebar');
@@ -43,12 +63,13 @@ describe('<app-sidebar>', () => {
'nav-explore', 'nav-explore',
'nav-downloads', 'nav-downloads',
'nav-autotag', 'nav-autotag',
'nav-jobs',
'nav-settings', 'nav-settings',
]); ]);
}); });
it('marks exactly one item as the current page', async () => { it('marks exactly one item as the current page', async () => {
activeViewStore.setView('home', true);
const el = await fixture('app-sidebar'); const el = await fixture('app-sidebar');
const current = shadowAll(el, 'li button').filter( const current = shadowAll(el, 'li button').filter(
@@ -72,17 +93,49 @@ describe('<app-sidebar>', () => {
expect(seen).toEqual(['artists']); expect(seen).toEqual(['artists']);
}); });
it('moves aria-current to the clicked destination', async () => { it('moves aria-current with the shell, not with the click', async () => {
activeViewStore.setView('home', true);
const el = await fixture('app-sidebar'); const el = await fixture('app-sidebar');
shadow<HTMLElement>(el, '[data-testid="nav-genres"]')?.click(); shadow<HTMLElement>(el, '[data-testid="nav-genres"]')?.click();
await el.updateComplete; await el.updateComplete;
// The click asks; it does not answer. The sidebar used to move its
// own highlight optimistically, which is the second opinion #72
// removed -- one component deciding where the user is, while the
// shell decided separately and `bottom-nav` decided a third way.
expect(
shadow(el, '[data-testid="nav-genres"]')?.getAttribute('aria-current'),
).toBe('false');
// What the shell does with that event, in one line.
activeViewStore.setView('genres', true);
await update(el, {});
expect( expect(
shadow(el, '[data-testid="nav-genres"]')?.getAttribute('aria-current'), shadow(el, '[data-testid="nav-genres"]')?.getAttribute('aria-current'),
).toBe('page'); ).toBe('page');
}); });
it('follows the back path, which dispatches no navigate event', async () => {
activeViewStore.setView('albums', true);
const el = await fixture('app-sidebar');
// `popstate` replays an entry through `handleNavigate` directly, so
// there is no `navigate` event to hear -- which is why the sidebar
// stayed on the view the user had just left (#72).
activeViewStore.setView('tracks', true);
await update(el, {});
expect(
shadowAll(el, 'li button')
.filter((item) => item.getAttribute('aria-current') === 'page')
.map((item) => item.getAttribute('data-testid')),
).toEqual(['nav-tracks']);
});
it('looks the way it did last time', async () => { it('looks the way it did last time', async () => {
const el = await fixture('app-sidebar'); const el = await fixture('app-sidebar');
+12 -1
View File
@@ -7,6 +7,7 @@
* opening it. * opening it.
*/ */
import { describe, expect, it, beforeEach } from 'vitest'; import { describe, expect, it, beforeEach } from 'vitest';
import type { LitElement } from 'lit';
import '@components/home-view/home-view'; import '@components/home-view/home-view';
import { stub, calls, lastArgs, stubFailure } from '@test/support/harness'; import { stub, calls, lastArgs, stubFailure } from '@test/support/harness';
@@ -166,7 +167,17 @@ describe('home view', () => {
const before = calls('home.Service.GetShelves').length; const before = calls('home.Service.GetShelves').length;
shadow<HTMLElement>(el, 'wa-button')!.click(); // The action is declared to `page-header` rather than slotted as
// markup (#69), so it is a button in *that* shadow root now.
const header = shadow<HTMLElement>(el, 'page-header')!;
await (header as LitElement).updateComplete;
header.shadowRoot!
.querySelector<HTMLButtonElement>(
'[data-testid="page-action-shuffle-suggestions"]',
)!
.click();
await el.updateComplete; await el.updateComplete;
expect(calls('home.Service.GetShelves').length).toBe(before + 1); expect(calls('home.Service.GetShelves').length).toBe(before + 1);
@@ -0,0 +1,85 @@
/**
* A hover affordance is gated on the device having hover.
*
* The home page's cover cards reveal a play button on :hover. A touch
* long-press synthesises a hover state in the WebView, so on a phone
* that button flashed into view during the 500ms hold that
* utils/long-press.ts is measuring for a context menu a control
* appearing because the user was reaching for a different one.
*
* This is asserted against the *parsed stylesheet* rather than by
* emulating a touch device, and that is a limitation worth stating
* rather than hiding. CDP's Emulation.setEmulatedMedia does not reach
* this tier's iframe matchMedia still answers `hover: hover` after it
* is set so there is no way here to render the component as a phone
* would and read the computed style. What can be checked is the shape
* the browser actually built from the css`` literal: that the reveal
* lives inside a hover media query and that the default is display:none.
*
* Which is the regression worth catching anyway. The failure mode is
* someone hoisting the rule back out of the query for a one-line tidy
* a change nothing renders differently on a desktop, so every other
* assertion in this repo passes and the phone silently regresses.
*/
import { describe, expect, it } from 'vitest';
import '@components/home-view/home-view';
import { fixture } from '@test/support/render';
/** Every rule in the element's own adopted stylesheets, flattened. */
function rulesOf(host: Element): { text: string; condition: string | null }[] {
const sheets = host.shadowRoot?.adoptedStyleSheets ?? [];
const out: { text: string; condition: string | null }[] = [];
for (const sheet of sheets) {
for (const rule of Array.from(sheet.cssRules)) {
if (rule instanceof CSSMediaRule) {
for (const inner of Array.from(rule.cssRules)) {
out.push({ text: inner.cssText, condition: rule.conditionText });
}
continue;
}
out.push({ text: rule.cssText, condition: null });
}
}
return out;
}
describe('the home card play button', () => {
it('reveals itself only where the device has hover', async () => {
const el = await fixture('home-view', {});
const rules = rulesOf(el);
// The sweep is worth nothing if it read no rules at all — the same
// first assertion icon-language.test.ts makes for the same reason.
expect(rules.length).toBeGreaterThan(0);
const reveals = rules.filter(
(r) => r.text.includes('.play') && /opacity:\s*1/.test(r.text),
);
expect(reveals.length).toBeGreaterThan(0);
for (const rule of reveals) {
expect(rule.condition).toMatch(/hover:\s*hover/);
expect(rule.condition).toMatch(/pointer:\s*fine/);
}
});
it('is display:none rather than transparent where it is absent', async () => {
const el = await fixture('home-view', {});
// opacity:0 alone would leave a button that still takes taps and is
// still in the accessibility tree, so a phone would keep the hit
// area for a control it can never see.
const unconditional = rulesOf(el).filter(
(r) => r.condition === null && r.text.startsWith('.play'),
);
expect(unconditional.length).toBeGreaterThan(0);
expect(unconditional.some((r) => /display:\s*none/.test(r.text))).toBe(true);
});
});
@@ -44,6 +44,8 @@ const GOVERNED = [
'regular/bookmark', 'regular/bookmark',
'bars-staggered', 'bars-staggered',
'tag', 'tag',
'filter',
'ellipsis',
]; ];
/** The one file allowed to say them, plus its own test. */ /** The one file allowed to say them, plus its own test. */
+160
View File
@@ -0,0 +1,160 @@
/**
* Background work, shown where the work is started (#27).
*
* The Jobs tab is gone; each kind's rows now live beside the thing that
* starts it scans in Settings Libraries, index work in Search
* Index, downloads under the download clients, the autotag apply in the
* Autotag view. What those four surfaces never had, and what this
* carries to them, is the *generic* affordances: pause, cancel,
* "Details", and a finished job you can dismiss.
*
* The assertions are about which rows a panel owns and what its buttons
* do, not about the store `job-store` already has the snapshot
* covered, and a panel that renders the right rows for the wrong reason
* would pass either way.
*/
import { describe, expect, it, beforeEach } from 'vitest';
import '@components/jobs/job-panel';
import { emit, flush, lastArgs, calls, resetHarness, stub } from '@test/support/harness';
import { Events } from '../../src/events';
import { fixture, shadow, shadowAll } from '@test/support/render';
import type { LitElement } from 'lit';
/** A job snapshot entry, with the fields `job-row` actually reads. */
const job = (over: Record<string, unknown> = {}) => ({
id: 'scan:1',
kind: 'library-scan',
title: 'Scanning Music',
state: 'running',
current: 3,
total: 10,
caps: { pausable: true, cancellable: true },
stages: null,
stats: null,
startedAt: Date.now(),
updatedAt: Date.now(),
logCount: 0,
warnCount: 0,
errorCount: 0,
...over,
});
/** Push a full snapshot, which is what the backend emits. */
async function snapshot(jobs: unknown[]): Promise<void> {
emit(Events.JobsChanged, jobs);
await flush();
}
const rows = (el: HTMLElement) => shadowAll(el, 'job-row');
const titles = (el: HTMLElement) =>
rows(el).map((row) => (row as HTMLElement & { job: { title: string } }).job.title);
describe('<job-panel>', () => {
beforeEach(async () => {
resetHarness();
stub('jobs.Service.GetJobs', []);
await snapshot([]);
});
it('renders only the kinds it was asked for', async () => {
const el = await fixture<LitElement>('job-panel', {
kinds: 'index-build,catalog-enrich',
});
await snapshot([
job({ id: 'scan:1', kind: 'library-scan', title: 'Scanning Music' }),
job({ id: 'idx', kind: 'index-build', title: 'Building the index' }),
job({ id: 'enrich', kind: 'catalog-enrich', title: 'Filling in artists' }),
]);
await el.updateComplete;
// The title is inside `job-row`'s own shadow root, so this asks
// the rows what they are drawing rather than reading the panel's
// text -- which would pass whether or not a row rendered.
expect(titles(el)).toEqual(['Building the index', 'Filling in artists']);
});
/**
* An idle panel in four places is four pieces of furniture describing
* an absence and `hidden` rather than an empty render, because the
* host's own margin would otherwise still be spent.
*/
it('takes up no room when it has nothing to say', async () => {
const el = await fixture<LitElement>('job-panel', { kinds: 'download' });
await snapshot([job({ id: 'scan:1', kind: 'library-scan' })]);
await el.updateComplete;
expect(el.hidden).toBe(true);
expect(rows(el)).toHaveLength(0);
await snapshot([
job({ id: 'dl:1', kind: 'download', title: 'Downloading Glass Harbour' }),
]);
await el.updateComplete;
expect(el.hidden).toBe(false);
expect(rows(el)).toHaveLength(1);
});
/**
* The controls go through `applyJobControl`, which is what carries
* the index build's "you will discard hours of downloading"
* confirmation across this move. A host drawing its own buttons would
* have dropped it silently.
*/
it('pauses through the shared handler', async () => {
const el = await fixture<LitElement>('job-panel', { kinds: 'library-scan' });
await snapshot([job()]);
await el.updateComplete;
const row = rows(el)[0]!;
shadow<HTMLButtonElement>(row, 'button[aria-label^="Pause"]')?.click();
await flush();
expect(lastArgs('jobs.Service.PauseJob')).toEqual(['scan:1']);
});
/**
* Cancelling an index build asks first; cancelling a scan does not,
* because a scan is cheap to re-run. Both answers live in
* `applyJobControl` and both had to survive the move.
*/
it('does not ask before cancelling a scan', async () => {
const el = await fixture<LitElement>('job-panel', { kinds: 'library-scan' });
await snapshot([job()]);
await el.updateComplete;
const row = rows(el)[0]!;
shadow<HTMLButtonElement>(row, 'button[aria-label^="Stop"]')?.click();
await flush();
expect(lastArgs('jobs.Service.CancelJob')).toEqual(['scan:1']);
});
/**
* A finished job is dismissed one at a time. There is deliberately no
* "Clear finished" here: `ClearFinishedJobs` is global, so a Clear in
* the Libraries panel would discard the index build's history too.
*/
it('keeps finished jobs, dismissible one by one', async () => {
const el = await fixture<LitElement>('job-panel', { kinds: 'library-scan' });
await snapshot([job({ state: 'complete' })]);
await el.updateComplete;
const row = rows(el)[0]!;
shadow<HTMLButtonElement>(row, 'button[aria-label^="Dismiss"]')?.click();
await flush();
expect(lastArgs('jobs.Service.DismissJob')).toEqual(['scan:1']);
expect(calls('jobs.Service.ClearFinishedJobs')).toHaveLength(0);
});
});
@@ -11,7 +11,8 @@ import { describe, expect, it, beforeEach } from 'vitest';
import '@components/sidebar/app-sidebar'; import '@components/sidebar/app-sidebar';
import '@components/queue-panel/queue-panel'; import '@components/queue-panel/queue-panel';
import '@components/track-list/track-list'; import '@components/track-list/track-list';
import { stub } from '@test/support/harness'; import { stub, emit, flush } from '@test/support/harness';
import { Events } from '../../src/events';
import { fixture, shadow, shadowAll, update } from '@test/support/render'; import { fixture, shadow, shadowAll, update } from '@test/support/render';
/** Two fixture tracks, enough to move a focus ring between. */ /** Two fixture tracks, enough to move a focus ring between. */
@@ -33,12 +34,22 @@ const TRACKS = [
] as never[]; ] as never[];
describe('<app-sidebar> is reachable', () => { describe('<app-sidebar> is reachable', () => {
// Eleven destinations assumes a configured download client, since
// Downloads is not offered without one (#25).
beforeEach(async () => {
stub('download.Service.ListProviders', [
{ id: 1, kind: 'slskd', name: 'Sound', enabled: true, priority: 50 },
]);
emit(Events.DownloadProvidersChanged);
await flush();
});
it('renders every destination as a button, not a bare list item', async () => { it('renders every destination as a button, not a bare list item', async () => {
const el = await fixture('app-sidebar'); const el = await fixture('app-sidebar');
const items = shadowAll(el, 'li button'); const items = shadowAll(el, 'li button');
expect(items).toHaveLength(11); expect(items).toHaveLength(10);
expect(items.every((item) => item.tagName === 'BUTTON')).toBe(true); expect(items.every((item) => item.tagName === 'BUTTON')).toBe(true);
}); });
@@ -0,0 +1,91 @@
/**
* The global back/forward control (#6).
*
* The interesting half of this component is what it does when it
* *cannot* act. The app's rule is that a control which cannot do
* anything should not be a button at all `library-status-indicator`
* spent a release as a `<button>` whose handler was a comment and
* this is the documented exception: back and forward are a pair whose
* positions the user learns, so the unavailable one greys out rather
* than disappearing and moving the other one under the cursor.
*/
import { describe, expect, it, beforeEach } from 'vitest';
import '@components/nav-history/nav-history';
import { fixture, shadow, update } from '@test/support/render';
import { historyStore } from '@store/history-store';
const back = (el: HTMLElement) =>
shadow<HTMLButtonElement>(el, '[data-testid="history-back"]');
const forward = (el: HTMLElement) =>
shadow<HTMLButtonElement>(el, '[data-testid="history-forward"]');
describe('nav-history', () => {
beforeEach(() => {
historyStore.setDepth(false, false);
});
it('offers both directions, named', async () => {
const el = await fixture('nav-history');
// The name is the whole control: two arrows side by side are
// indistinguishable to anything not looking at them.
expect(back(el)?.getAttribute('aria-label')).toBe('Back');
expect(forward(el)?.getAttribute('aria-label')).toBe('Forward');
});
it('disables what cannot be done, in both directions independently', async () => {
const el = await fixture('nav-history');
expect(back(el)?.disabled).toBe(true);
expect(forward(el)?.disabled).toBe(true);
historyStore.setDepth(true, false);
await update(el, {});
expect(back(el)?.disabled).toBe(false);
expect(forward(el)?.disabled).toBe(true);
// Standing in the middle of the list, which is what a back press
// followed by a look at the toolbar produces.
historyStore.setDepth(true, true);
await update(el, {});
expect(back(el)?.disabled).toBe(false);
expect(forward(el)?.disabled).toBe(false);
});
it('asks the shell rather than reaching for history itself', async () => {
const el = await fixture('nav-history');
const seen: string[] = [];
for (const name of ['navigate-back', 'navigate-forward']) {
document.addEventListener(name, () => seen.push(name));
}
historyStore.setDepth(true, true);
await update(el, {});
back(el)?.click();
forward(el)?.click();
// Composed and bubbling, or index.ts's document listener — which
// owns the guard that stops a press at the root leaving the app —
// never hears them. A second caller reaching for `history`
// directly is how the old `navStack` came to disagree with the
// platform.
expect(seen).toEqual(['navigate-back', 'navigate-forward']);
});
it('says nothing when it cannot act', async () => {
const el = await fixture('nav-history');
const seen: string[] = [];
document.addEventListener('navigate-back', () => seen.push('back'));
back(el)?.click();
expect(seen).toEqual([]);
});
});
@@ -0,0 +1,166 @@
/**
* The mini player's links are a desktop affordance.
*
* `utils/explore-link.ts` makes every track and artist name navigate,
* and `utils/queue-source-link.ts` makes "Playing from X" navigate in
* the bottom bar those are a few characters of text at a font size
* chosen for a bar, which is not a touch target. Worse, explore-link
* holds the navigation for one double-click interval and drops it if a
* second click arrives: a gesture that exists so double-clicking a row
* can play it, and which means nothing at all on touch.
*
* So below the shell's phone breakpoint the three render as plain text
* and the whole bar's cover art opens the full-screen Now Playing view,
* which is where the links live.
*
* The breakpoint is stubbed rather than emulated for the reason
* track-list-phone.test.ts states: this tier's viewport is fixed at
* 1280x800 by the runner, and the component reads matchMedia in
* connectedCallback precisely so a test can answer it first.
*/
import { describe, expect, it, beforeEach } from 'vitest';
import '@components/now-playing/now-playing';
import { Events } from '../../src/events';
import { emit, flush } from '@test/support/harness';
import { fixture, shadow, shadowAll, text } from '@test/support/render';
import type { TrackInfo } from '@store/player-store';
import type { QueueTrack } from '@store/queue-store';
const TRACK: TrackInfo = {
fileName: 'ashes.mp3',
filePath: '/music/ashes.mp3',
trackLength: 215,
seekPosition: 0,
state: 'playing',
title: 'Ashes to Ashes',
artist: 'David Bowie',
album: 'Scary Monsters',
coverArt: '',
coverArtSmall: '',
coverArtMedium: '',
coverArtLarge: '',
trackChangeId: 1,
artistMbid: '',
releaseGroupMbid: '',
recordingMbid: '',
};
function queueTrack(n: number, title: string): QueueTrack {
return {
id: n,
audioFileId: n,
filePath: `/music/${n}.mp3`,
position: n,
title,
artist: 'David Bowie',
album: 'Scary Monsters',
coverArtPath: '',
artistMbid: '',
releaseGroupMbid: '',
recordingMbid: '',
};
}
/** Mount the bar with the phone breakpoint answering `matches`. */
async function mountAt(phone: boolean) {
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;
try {
const el = await fixture('now-playing');
emit(Events.TrackChanged, { ...TRACK, trackChangeId: 20 });
emit(Events.QueueChanged, {
tracks: [queueTrack(1, 'Ashes to Ashes')],
currentIndex: 0,
source: { type: 'album', id: 7, label: 'Scary Monsters' },
});
await flush();
await el.updateComplete;
return el;
} finally {
window.matchMedia = real;
}
}
describe('the mini player on a phone', () => {
beforeEach(() => {
emit(Events.TrackChanged, { ...TRACK, trackChangeId: 1 });
});
it('renders the title and artist as plain text', async () => {
const el = await mountAt(true);
expect(shadowAll(el, '.explore-link').length).toBe(0);
// The words are unchanged — this is about what they are, not about
// hiding them. A fix that dropped the text would pass an assertion
// about links alone.
expect(text(el, '[data-testid="now-playing-title"]')).toContain(
'Ashes to Ashes',
);
expect(text(el, '[data-testid="now-playing-artist"]')).toContain(
'David Bowie',
);
});
it('does not navigate from the source line', async () => {
const el = await mountAt(true);
const source = shadow<HTMLElement>(el, '[data-testid="now-playing-source"]');
expect(source?.classList.contains('navigable')).toBe(false);
let navigated = false;
el.addEventListener('navigate', () => {
navigated = true;
});
source?.click();
expect(navigated).toBe(false);
});
it('still says where the queue came from', async () => {
const el = await mountAt(true);
// Dropping the *link* is the change; dropping the information would
// be a different and worse one.
expect(text(el, '[data-testid="now-playing-source"]')).toBe(
'Playing from Scary Monsters',
);
});
it('leaves the desktop bar exactly as it was', async () => {
const el = await mountAt(false);
expect(shadowAll(el, '.explore-link').length).toBeGreaterThan(0);
const source = shadow<HTMLElement>(el, '[data-testid="now-playing-source"]');
expect(source?.classList.contains('navigable')).toBe(true);
let detail: unknown;
el.addEventListener('navigate', (e) => {
detail = (e as CustomEvent).detail;
});
source?.click();
expect(detail).toEqual({
view: 'explore-album-details',
localAlbumId: 7,
albumName: 'Scary Monsters',
});
});
});
+320 -1
View File
@@ -9,7 +9,7 @@
* the thing no assertion can the header looking wrong. * the thing no assertion can the header looking wrong.
*/ */
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import type { PageHeader } from '@components/page-header/page-header'; import type { PageAction, PageHeader } from '@components/page-header/page-header';
import '@components/page-header/page-header'; import '@components/page-header/page-header';
import { fixture, shadow, shadowAll, update, visual } from '@test/support/render'; import { fixture, shadow, shadowAll, update, visual } from '@test/support/render';
@@ -19,6 +19,67 @@ const SORTS = [
{ id: 'tracks', label: 'Tracks' }, { id: 'tracks', label: 'Tracks' },
]; ];
/**
* Three actions of the shape that broke: Playlists' own, whose widths
* (91 + 122 + 162 = 390px) are what a 700px header could not hold.
*/
function playlistActions(seen: string[]): PageAction[] {
return [
{
id: 'import',
label: 'Import',
icon: 'file-import',
priority: 0,
onSelect: () => seen.push('import'),
},
{
id: 'new-playlist',
label: 'New Playlist',
icon: 'plus',
priority: 2,
onSelect: () => seen.push('new-playlist'),
},
{
id: 'new-smart-playlist',
label: 'New Smart Playlist',
icon: 'filter',
priority: 1,
onSelect: () => seen.push('new-smart-playlist'),
},
];
}
/**
* Resize and let the fit settle.
*
* The rule is driven by a ResizeObserver, which delivers before paint
* and therefore after the microtask queue an `updateComplete` drains
* so this waits on frames rather than on promises, and then on the
* render the measurement asks for.
*/
async function widthOf(el: PageHeader, px: number): Promise<void> {
el.style.width = `${px}px`;
for (let frame = 0; frame < 3; frame += 1) {
await new Promise((r) => requestAnimationFrame(r));
await el.updateComplete;
}
}
/** The labels currently rendered as buttons, in order. */
function buttons(el: PageHeader): string[] {
return shadowAll<HTMLButtonElement>(el, '.action')
.filter((b) => !b.hidden)
.map((b) => b.textContent?.trim() ?? '');
}
/** The labels currently in the overflow menu, in order. */
function menu(el: PageHeader): string[] {
return shadowAll(el, '#page-header-overflow wa-dropdown-item').map(
(i) => i.textContent?.trim() ?? '',
);
}
describe('<page-header>', () => { describe('<page-header>', () => {
it('renders the heading as the page\u2019s only h1', async () => { it('renders the heading as the page\u2019s only h1', async () => {
const el = await fixture<PageHeader>('page-header', { const el = await fixture<PageHeader>('page-header', {
@@ -153,6 +214,264 @@ describe('<page-header>', () => {
}); });
}); });
/**
* #69: Playlists slotted three buttons totalling 390px into a header
* that gets 700px at 900×600, and "New Smart Playlist" rendered 114 of
* its 162. It survived a spec named `layout-overflow` because that one
* asserts the *shell* needs no sideways scrolling clipping inside a
* component is invisible to it.
*
* The header can only fix that for actions it renders itself, which is
* why they are data now. These are the assertions about the rule; the
* e2e spec is what checks it against the real widths.
*/
describe('<page-header> actions', () => {
it('renders a declared action, and asks the host to perform it', async () => {
// Same division the sort control already lives by: the header
// decides what fits, the host decides what happens.
const seen: string[] = [];
const el = await fixture<PageHeader>('page-header', {
heading: 'Playlists',
actions: playlistActions(seen),
});
await widthOf(el, 1200);
expect(buttons(el)).toEqual([
'Import',
'New Playlist',
'New Smart Playlist',
]);
shadow<HTMLButtonElement>(el, '[data-testid="page-action-import"]')!.click();
expect(seen).toEqual(['import']);
});
it('hides the overflow trigger while everything fits', async () => {
const el = await fixture<PageHeader>('page-header', {
heading: 'Playlists',
actions: playlistActions([]),
});
await widthOf(el, 1200);
expect(shadow<HTMLButtonElement>(el, '.more-button')!.hidden).toBe(true);
expect(menu(el)).toEqual([]);
});
it('collapses the lowest priority first', async () => {
// Import is lowest because it is rarest; New Playlist is highest
// because it is the drop target, and a closed menu cannot be one.
const el = await fixture<PageHeader>('page-header', {
heading: 'Playlists',
count: 4,
countNoun: 'playlist',
sortOptions: SORTS,
sortField: 'name',
actions: playlistActions([]),
});
// Asserted as the *order* rather than at two chosen widths: which
// pixel drops which button depends on the font and on the shell
// this tier does not have, and pinning those numbers here would be
// a test of the fixture. What the host declares is a sequence.
const states: string[][] = [];
for (let width = 1200; width >= 300; width -= 40) {
await widthOf(el, width);
const now = menu(el);
const last = states[states.length - 1];
if (last === undefined || last.join() !== now.join()) states.push(now);
}
expect(states).toEqual([
[],
['Import'],
['Import', 'New Smart Playlist'],
['Import', 'New Playlist', 'New Smart Playlist'],
]);
// The menu lists them in the host's declared order, not in the
// order they happened to collapse — a menu that reshuffles itself
// as the window narrows is a menu nobody can learn.
expect(buttons(el)).toEqual([]);
});
it('gives an action back when the width returns', async () => {
// Every pass starts from all-visible, so the collapsed set is a
// function of the current width and not of how it got there. A rule
// that only ever added to the set would never widen again.
const el = await fixture<PageHeader>('page-header', {
heading: 'Playlists',
count: 4,
countNoun: 'playlist',
sortOptions: SORTS,
sortField: 'name',
actions: playlistActions([]),
});
await widthOf(el, 420);
expect(buttons(el)).toEqual([]);
await widthOf(el, 1200);
expect(menu(el)).toEqual([]);
expect(buttons(el)).toEqual([
'Import',
'New Playlist',
'New Smart Playlist',
]);
});
it('collapses an action before it truncates the title', async () => {
// The title can ellipsis, which means `scrollWidth` reports a
// header that fits perfectly while the heading reads "Playlis…" —
// this issue's failure mode moved from the button to the title, and
// invisible to the same measurement that missed it the first time.
const el = await fixture<PageHeader>('page-header', {
heading: 'Playlists',
count: 4,
countNoun: 'playlist',
sortOptions: SORTS,
sortField: 'name',
actions: playlistActions([]),
});
await widthOf(el, 700);
const h1 = shadow<HTMLElement>(el, 'h1')!;
expect(h1.scrollWidth).toBeLessThanOrEqual(h1.clientWidth + 1);
expect(menu(el).length).toBeGreaterThan(0);
});
it('names the overflow trigger and says what it controls', async () => {
// An overflow menu is exactly the shape that grows a nameless
// control, and `aria-controls` cannot name an element that is not
// in the DOM — which is why the panel renders unconditionally and
// `wa-popup` hides it, the same rule `config-section` follows.
const el = await fixture<PageHeader>('page-header', {
heading: 'Playlists',
count: 4,
countNoun: 'playlist',
sortOptions: SORTS,
sortField: 'name',
actions: playlistActions([]),
});
await widthOf(el, 480);
const more = shadow<HTMLButtonElement>(el, '.more-button')!;
expect(more.hidden).toBe(false);
expect(more.getAttribute('aria-label')).toBe('More actions');
expect(more.getAttribute('aria-expanded')).toBe('false');
expect(more.getAttribute('aria-haspopup')).toBe('menu');
const panel = shadow<HTMLElement>(el, '#page-header-overflow')!;
expect(more.getAttribute('aria-controls')).toBe(panel.id);
expect(panel.getAttribute('role')).toBe('menu');
more.click();
await el.updateComplete;
expect(
shadow<HTMLButtonElement>(el, '.more-button')!.getAttribute(
'aria-expanded',
),
).toBe('true');
});
it('runs a collapsed action from the menu, and closes it', async () => {
const seen: string[] = [];
const el = await fixture<PageHeader>('page-header', {
heading: 'Playlists',
count: 4,
countNoun: 'playlist',
sortOptions: SORTS,
sortField: 'name',
actions: playlistActions(seen),
});
await widthOf(el, 700);
shadow<HTMLButtonElement>(el, '.more-button')!.click();
await el.updateComplete;
shadowAll<HTMLElement>(el, '#page-header-overflow wa-dropdown-item')[0]!.click();
await el.updateComplete;
expect(seen).toEqual(['import']);
expect(
shadow<HTMLButtonElement>(el, '.more-button')!.getAttribute(
'aria-expanded',
),
).toBe('false');
});
it('keeps a drop target a drop target, and does not fake one in the menu', async () => {
// You cannot drag a track onto a closed menu, so the affordance is
// absent from the overflow rather than approximated there. The
// header wires the handlers onto the button and owns none of them.
const dropped: string[] = [];
const actions: PageAction[] = [
{
id: 'new-playlist',
label: 'New Playlist',
icon: 'plus',
onSelect: () => undefined,
drop: {
active: true,
onDragOver: () => dropped.push('over'),
onDragLeave: () => dropped.push('leave'),
onDrop: () => dropped.push('drop'),
},
},
];
const el = await fixture<PageHeader>('page-header', {
heading: 'Playlists',
actions,
});
await widthOf(el, 1200);
const button = shadow<HTMLElement>(
el,
'[data-testid="page-action-new-playlist"]',
)!;
expect(button.classList.contains('drag-over')).toBe(true);
button.dispatchEvent(new DragEvent('dragover', { bubbles: true }));
button.dispatchEvent(new DragEvent('drop', { bubbles: true }));
expect(dropped).toEqual(['over', 'drop']);
// …and collapsed, it is a menu item with no drop wiring at all.
await widthOf(el, 120);
expect(menu(el)).toEqual(['New Playlist']);
expect(
shadow<HTMLElement>(el, '[data-testid="page-action-new-playlist"]')
?.hidden,
).toBe(true);
});
it('renders nothing at all for a view with no actions', async () => {
// Two of the three hosts have one action and one has none while its
// other tab is up; an empty actions row is not a mode.
const el = await fixture<PageHeader>('page-header', { heading: 'Albums' });
await widthOf(el, 900);
expect(shadow(el, '.actions')).toBeNull();
});
});
describe('<page-header> as each view wears it', () => { describe('<page-header> as each view wears it', () => {
// One baseline per arrangement rather than per view: the point is // One baseline per arrangement rather than per view: the point is
// that eight views produce four shapes, not eight. // that eight views produce four shapes, not eight.
+2 -2
View File
@@ -40,7 +40,7 @@ import '@components/jobs/job-details-drawer';
import '@components/jobs/job-indicator'; import '@components/jobs/job-indicator';
import '@components/jobs/job-log-view'; import '@components/jobs/job-log-view';
import '@components/jobs/job-row'; import '@components/jobs/job-row';
import '@components/jobs/jobs-view'; import '@components/jobs/job-panel';
import '@components/library-filter/library-filter'; import '@components/library-filter/library-filter';
import '@components/library-status-indicator/library-status-indicator'; import '@components/library-status-indicator/library-status-indicator';
import '@components/now-playing/now-playing'; import '@components/now-playing/now-playing';
@@ -87,8 +87,8 @@ const TAGS = [
'job-details-drawer', 'job-details-drawer',
'job-indicator', 'job-indicator',
'job-log-view', 'job-log-view',
'job-panel',
'job-row', 'job-row',
'jobs-view',
'library-filter', 'library-filter',
'library-status-indicator', 'library-status-indicator',
'now-playing', 'now-playing',
@@ -27,7 +27,6 @@ import '@components/explore-view/explore-view';
import '@components/home-view/home-view'; import '@components/home-view/home-view';
import '@components/downloads-view/downloads-view'; import '@components/downloads-view/downloads-view';
import '@components/jobs/jobs-view';
import '@components/playlist-view/playlist-view'; import '@components/playlist-view/playlist-view';
import { fixture } from '@test/support/render'; import { fixture } from '@test/support/render';
import { stub, flush } from '@test/support/harness'; import { stub, flush } from '@test/support/harness';
@@ -223,7 +222,6 @@ const CACHED_VIEWS = [
'artists-view', 'artists-view',
'genres-view', 'genres-view',
'downloads-view', 'downloads-view',
'jobs-view',
'playlist-view', 'playlist-view',
'explore-view', 'explore-view',
'home-view', 'home-view',
@@ -0,0 +1,189 @@
/**
* Which destinations the navigation offers (#25).
*
* Eleven sidebar entries is more than most libraries need, so they are
* individually toggleable. The assertions here are about the **nav**
* and not about the setting being saved: "the config was written" is
* the plumbing, and a spec that measures the plumbing is how #69 and
* #72 both shipped green on a broken build.
*
* Two singletons make ordering matter, and both are driven the way the
* app drives them rather than reset: `GeneralConfigChanged` is what the
* backend emits when a toggle is saved, and `DownloadProvidersChanged`
* is what it emits when a client is configured. So each case states the
* world it wants and is independent of which one ran first.
*/
import { describe, expect, it, beforeEach } from 'vitest';
import '@components/sidebar/app-sidebar';
import '@components/bottom-nav/bottom-nav';
import { activeViewStore } from '@store/active-view-store';
import { stub, emit, flush, resetHarness } from '@test/support/harness';
import { Events } from '../../src/events';
import { fixture, shadowAll } from '@test/support/render';
import type { LitElement } from 'lit';
const PROVIDER = {
id: 1,
kind: 'slskd',
name: 'Sound',
enabled: true,
priority: 50,
};
/** Every view id the sidebar is currently drawing, in order. */
const navIDs = (el: HTMLElement) =>
shadowAll<HTMLButtonElement>(el, 'nav button')
.map((b) => b.dataset.testid?.replace(/^nav-/, ''))
.filter((id): id is string => id !== undefined);
const tabIDs = (el: HTMLElement) =>
shadowAll<HTMLButtonElement>(el, 'nav button')
.map((b) => b.dataset.testid?.replace(/^tab-/, ''))
.filter((id): id is string => id !== undefined);
/**
* State the backend's resolved answer and push the event that says it
* changed. The map is *resolved* every known view, defaults already
* applied because that is what the binding returns and the whole
* reason the frontend holds no copy of the defaults.
*/
async function setViews(views: Record<string, boolean>): Promise<void> {
stub('config.Config.GetViewVisibility', views);
emit(Events.GeneralConfigChanged, {});
await flush();
await flush();
}
async function setClientConfigured(configured: boolean): Promise<void> {
stub('download.Service.ListProviders', configured ? [PROVIDER] : []);
emit(Events.DownloadProvidersChanged);
await flush();
await flush();
}
const ALL_VISIBLE = {
home: true,
playlists: true,
artists: true,
genres: true,
albums: true,
tracks: true,
explore: true,
downloads: true,
autotag: true,
settings: true,
};
describe('view visibility', () => {
beforeEach(async () => {
resetHarness();
await setViews(ALL_VISIBLE);
await setClientConfigured(true);
});
it('draws every destination the config keeps', async () => {
const el = await fixture<LitElement>('app-sidebar');
expect(navIDs(el)).toEqual([
'home',
'playlists',
'artists',
'genres',
'albums',
'tracks',
'explore',
'downloads',
'autotag',
'settings',
]);
});
it('drops the ones the user switched off', async () => {
const el = await fixture<LitElement>('app-sidebar');
await setViews({ ...ALL_VISIBLE, autotag: false, explore: false });
await el.updateComplete;
expect(navIDs(el)).not.toContain('autotag');
expect(navIDs(el)).not.toContain('explore');
expect(navIDs(el)).toContain('settings');
});
/**
* Hiding is about the nav item, not about the view. Detail views
* navigate into these and the launch page is one of them, so the
* shell's own statement of where the user is has to survive a
* destination that draws no item and it does so with no special
* case here, because #72 moved the highlight onto `active-view-store`
* and this only filters what is rendered.
*/
it('lights nothing when the active view is a hidden one', async () => {
const el = await fixture<LitElement>('app-sidebar');
await setViews({ ...ALL_VISIBLE, autotag: false });
activeViewStore.setView('autotag', true);
await el.updateComplete;
const lit = shadowAll<HTMLButtonElement>(el, 'nav button')
.filter((b) => b.getAttribute('aria-current') === 'page');
expect(lit).toHaveLength(0);
expect(navIDs(el)).not.toContain('autotag');
activeViewStore.setView('albums', true);
});
/**
* A destination for a feature that cannot work is worse than an
* absent one, so Downloads asks the download client rather than the
* config and it appears when one is configured, without a restart
* (#37's rule, one surface over).
*/
it('hides Downloads until a client is configured', async () => {
const el = await fixture<LitElement>('app-sidebar');
await setClientConfigured(false);
await el.updateComplete;
expect(navIDs(el)).not.toContain('downloads');
await setClientConfigured(true);
await el.updateComplete;
expect(navIDs(el)).toContain('downloads');
});
/**
* The tab bar honours the toggles too, and the reason is local: its
* "More" drawer opens the same `<app-sidebar>`, which filters. An
* unfiltered bar would contradict its own drawer one tap away.
*/
it('drops a hidden destination from the phone tab bar', async () => {
const el = await fixture<LitElement>('bottom-nav');
expect(tabIDs(el)).toEqual(['home', 'albums', 'tracks', 'playlists', 'more']);
await setViews({ ...ALL_VISIBLE, albums: false });
await el.updateComplete;
expect(tabIDs(el)).toEqual(['home', 'tracks', 'playlists', 'more']);
});
/** "More" is not a destination and is never filtered away: it is how
* everything else is still reachable. */
it('keeps More when every tab is hidden', async () => {
const el = await fixture<LitElement>('bottom-nav');
await setViews({
...ALL_VISIBLE,
home: false,
albums: false,
tracks: false,
playlists: false,
});
await el.updateComplete;
expect(tabIDs(el)).toEqual(['more']);
});
});
+97 -3
View File
@@ -1,11 +1,14 @@
/** /**
* The three small stores behind view chrome: the global search term, * The small stores behind view chrome: the global search term, the
* the track list's column set, and the explore cache that keeps detail * active view both navs highlight, the track list's column set, and
* pages from re-fetching what a search already returned. * the explore cache that keeps detail pages from re-fetching what a
* search already returned.
*/ */
import { describe, expect, it, beforeEach } from 'vitest'; import { describe, expect, it, beforeEach } from 'vitest';
import { searchStore } from '@store/search-store'; import { searchStore } from '@store/search-store';
import { activeViewStore } from '@store/active-view-store';
import { historyStore } from '@store/history-store';
import { trackListStore } from '@store/tracklist-store'; import { trackListStore } from '@store/tracklist-store';
import { exploreCache, ARTIST_IMAGE_CACHE_LIMIT } from '@store/explore-cache'; import { exploreCache, ARTIST_IMAGE_CACHE_LIMIT } from '@store/explore-cache';
import { Events } from '../../src/events'; import { Events } from '../../src/events';
@@ -80,6 +83,97 @@ describe('search store', () => {
}); });
}); });
describe('active view store', () => {
beforeEach(() => {
activeViewStore.setView('home', true);
});
it('holds the primary view the shell navigated to', () => {
activeViewStore.setView('albums', true);
expect(activeViewStore.get()).toBe('albums');
expect(activeViewStore.isActive('albums')).toBe(true);
expect(activeViewStore.isActive('tracks')).toBe(false);
});
it('leaves the primary view lit while a detail view is open', () => {
activeViewStore.setView('albums', true);
activeViewStore.setView('explore-album-details', false);
// #72's third finding, made deliberate: a detail view is not a
// destination in either nav, and the tab it was opened from is
// where the user still is. `app-sidebar` did this by accident (it
// guarded on its own item list) and `bottom-nav` did not do it at
// all, which is why one looked right and the other looked broken.
expect(activeViewStore.get()).toBe('albums');
});
it('does not notify when the view is unchanged', () => {
let notifications = 0;
const off = activeViewStore.subscribe(() => {
notifications += 1;
});
activeViewStore.setView('albums', true);
activeViewStore.setView('albums', true);
activeViewStore.setView('explore-album-details', false);
off();
expect(notifications).toBe(1);
});
it('lights nothing for a view with no name', () => {
// The store starts empty rather than defaulting to a view, because
// a written-down default is right only while `GetDefaultPage()`
// agrees with it. That is only safe if the empty value matches
// nothing: `isActive` compares strings, and a component asking
// about an id it does not have must not light up.
activeViewStore.setView('', true);
expect(activeViewStore.isActive('')).toBe(false);
});
});
describe('history store', () => {
beforeEach(() => {
historyStore.setDepth(false, false);
});
it('holds both answers, because forward is not back negated', () => {
historyStore.setDepth(true, false);
expect(historyStore.get()).toEqual({ canBack: true, canForward: false });
// The middle of the list: both directions available at once, which
// a single depth counter cannot express and which is the state the
// old `pushedEntries` got wrong.
historyStore.setDepth(true, true);
expect(historyStore.get()).toEqual({ canBack: true, canForward: true });
});
it('does not notify when neither answer changed', () => {
let notifications = 0;
const off = historyStore.subscribe(() => {
notifications += 1;
});
historyStore.setDepth(true, true);
historyStore.setDepth(true, true);
off();
expect(notifications).toBe(1);
});
it('starts with both unavailable, which is the truth at launch', () => {
// A fresh session is one entry deep and that entry is *replaced*,
// not pushed, so there is nothing of ours behind it. A control
// that assumed otherwise would offer a press that does nothing --
// and on Android, one the OS would have used to exit the app.
expect(historyStore.get()).toEqual({ canBack: false, canForward: false });
});
});
describe('track list store', () => { describe('track list store', () => {
it('starts from the default column set', () => { it('starts from the default column set', () => {
expect(trackListStore.getState().columnIds.length).toBeGreaterThan(0); expect(trackListStore.getState().columnIds.length).toBeGreaterThan(0);
+6 -11
View File
@@ -20,19 +20,14 @@ pre-commit:
glob: "*.go" glob: "*.go"
run: go tool golangci-lint run --timeout 5m ./... run: go tool golangci-lint run --timeout 5m ./...
# Snapshots the tree either side of the generators and reports only
# what moved across them. This used to be `go generate` plus a bare
# `git diff --name-only`, which is the *whole unstaged worktree* — so
# any unrelated edit sitting there was reported as stale generated
# code, and `make generate` then fixed nothing. See the script.
codegen-check: codegen-check:
glob: "*.{go,sql,templ}" glob: "*.{go,sql,templ}"
run: | run: ./scripts/codegen-check.sh
go generate ./...
if [ -n "$(git diff --name-only)" ]; then
echo "Generated code is out of date. Run 'make generate' and stage the changes."
# --no-pager, or this blocks forever on `less` waiting for a
# keypress that a hook run without a tty will never get: the
# commit hangs at exactly the moment it is trying to tell you
# why it failed.
git --no-pager diff --stat
exit 1
fi
# frontend/bindings is generated by `wails3`, not `go generate`, so # frontend/bindings is generated by `wails3`, not `go generate`, so
# the check above does not cover it. ~3.5s warm, ~20s on a cold # the check above does not cover it. ~3.5s warm, ~20s on a cold
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env bash
#
# Fails when `go generate ./...` would change something that is not staged.
#
# The obvious spelling of this is `go generate && git diff --name-only`,
# which is what the hook used to be, and it answers the wrong question:
# that diff is the *whole unstaged worktree*, so any unrelated edit — a
# note, a plan document, the next commit's files sitting there while this
# one lands — was reported as
#
# Generated code is out of date. Run 'make generate' and stage the changes.
#
# Running `make generate` then does nothing, because nothing generated is
# stale, and the message sends you looking for a codegen problem that does
# not exist. Splitting one piece of work into several commits is exactly
# the shape that triggers it, so the workaround was a constraint on commit
# order for no real reason.
#
# So the tree is snapshotted either side of the generators and only what
# *moved across them* is reported. That is deliberately not a list of
# generated paths: sqlcgen, `*_templ.go` and `frontend/src/events.ts` are
# today's answer, a fourth generator is one `//go:generate` line away, and
# a path list is a second place to remember it — the same reasoning that
# keeps staleshape.go parsing sql/schemas/ rather than restating it.
#
# Content, not names: a generated file that is *already* dirty and is then
# rewritten further keeps its name in both snapshots and would otherwise
# slip through.
set -euo pipefail
cd "$(dirname "$0")/.."
# name + worktree blob hash for every file that differs from the index.
# A file listed but absent (a deletion) hashes as "gone" rather than
# aborting the pipeline.
snapshot() {
git diff --name-only | while IFS= read -r f; do
if [ -f "$f" ]; then
printf '%s %s\n' "$f" "$(git hash-object -- "$f")"
else
printf '%s gone\n' "$f"
fi
done
}
# A brand-new generated file is not in either diff, because it is not
# tracked at all — the same blind spot bindings-check.sh names. Both
# snapshots are taken before the generators run.
before="$(snapshot)"
before_untracked="$(git ls-files --others --exclude-standard)"
go generate ./...
after="$(snapshot)"
after_untracked="$(git ls-files --others --exclude-standard)"
# Symmetric difference, and the symmetry is the whole point. Generation
# can push a file *into* the unstaged set (it was current, now it is not)
# or *out* of it (someone hand-edited generated output and the generator
# put it back) — and the second is stale generated code just as much as
# the first. Comparing one direction only reports "current" for it,
# which is the failure this script was written to stop.
moved="$(comm -3 <(printf '%s\n' "$before" | sort) <(printf '%s\n' "$after" | sort) |
cut -d' ' -f1 | tr -d '\t' | sort -u | grep -v '^$' || true)"
if [ -n "$moved" ]; then
echo "codegen-check: generated code is out of date." >&2
echo "Run 'make generate' and stage:" >&2
printf ' %s\n' $moved >&2
exit 1
fi
if [ "$after_untracked" != "$before_untracked" ]; then
echo "codegen-check: generation produced new files. Stage them:" >&2
comm -13 <(printf '%s\n' "$before_untracked" | sort) \
<(printf '%s\n' "$after_untracked" | sort) >&2
exit 1
fi
echo "codegen-check: generated code is current"
+63
View File
@@ -97,6 +97,55 @@ if [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
fi fi
rm -f "$PID_FILE" rm -f "$PID_FILE"
# ── Refuse to inherit somebody else's port ───────────────────────────
# The PID check above only knows about *this* worktree: `make dev-stop`
# kills the pid in this .dev/app.pid and nothing else. Several worktrees
# of this repo share the default port, so an app orphaned by a deleted
# worktree goes on listening with nothing left to stop it.
#
# Without this check the new app starts, fails to bind, exits — and every
# curl and playwright-cli call afterwards goes to the *other* process, so
# the harness reports facts about an app nobody asked for. That is not a
# quiet wrongness either: it presented as
# "no such table: libraries" against a freshly created YJ_HOME, which
# reads exactly like applySchema or staleshape.go having gone wrong and
# is a frightening place to start looking.
#
# The startup wait below cannot catch it, because the health check is
# satisfied by *any* app on the port — which is precisely the failure.
# So it is refused here, before anything is launched, rather than warned
# about. --port already exists for the legitimate second-app case.
port_holder() {
command -v ss >/dev/null || return 0
ss -lptn "sport = :$PORT" 2>/dev/null | grep -oP 'pid=\K[0-9]+' | head -n 1
}
if curl -sf -o /dev/null --max-time 2 "http://localhost:$PORT/" ||
[ -n "$(port_holder)" ]; then
holder="$(port_holder)"
echo "dev-headless: :$PORT is already in use; refusing to start" >&2
if [ -n "$holder" ]; then
# /proc/<pid>/cwd names the checkout it belongs to, and says
# "(deleted)" for the orphaned-worktree case that is the whole
# reason this is worth a check.
cwd="$(readlink "/proc/$holder/cwd" 2>/dev/null || echo unknown)"
cmd="$(tr '\0' ' ' <"/proc/$holder/cmdline" 2>/dev/null || echo unknown)"
echo " pid $holder ($cmd)" >&2
echo " cwd $cwd" >&2
# The PID-file check above has already passed, so whatever this
# is, `make dev-stop` does not know about it — saying otherwise
# sends you to a command that will report success and change
# nothing. Never `pkill -f` here either: the pattern would
# match this script's own command line.
echo " 'make dev-stop' will not touch it (it is not in" >&2
echo " ${PID_FILE#"$REPO_ROOT"/}): kill $holder, or pass --port." >&2
else
echo " The holder could not be identified (no ss, or it belongs" >&2
echo " to another user). Try: ss -lptn 'sport = :$PORT'" >&2
fi
exit 1
fi
# ── Choose the YJ_HOME ─────────────────────────────────────────────── # ── Choose the YJ_HOME ───────────────────────────────────────────────
# A seed is a YJ_HOME that a previous run of the app produced, tarred # A seed is a YJ_HOME that a previous run of the app produced, tarred
# up (see scripts/seed-sandbox.sh). Restoring it means starting *in* # up (see scripts/seed-sandbox.sh). Restoring it means starting *in*
@@ -200,6 +249,20 @@ until curl -sf -o /dev/null "http://localhost:$PORT/"; do
sleep 0.25 sleep 0.25
done done
# The loop above exits on the first answer from the port, and "something
# answered" is not "the app we started answered". The pre-launch guard
# makes that unlikely rather than impossible — a race, or a listener
# started in between — and the check is one signal, so it is worth making
# here too. An empty log beside a dead pid is the "it exited immediately
# and nothing said so" case that the original report spent its time on.
if ! kill -0 "$APP_PID" 2>/dev/null; then
echo "dev-headless: :$PORT answered, but the app we started (pid" >&2
echo " $APP_PID) is gone — something else holds the port." >&2
tail -n 30 "$LOG_FILE" >&2
rm -f "$PID_FILE"
exit 1
fi
cat <<EOF cat <<EOF
dev-headless: up dev-headless: up
url http://localhost:$PORT url http://localhost:$PORT
+27 -3
View File
@@ -38,8 +38,10 @@
# Where a body is taken and no --body-file is given, it is read from stdin. # Where a body is taken and no --body-file is given, it is read from stdin.
# #
# Environment: # Environment:
# GITEA_TOKEN a PAT with write:issue (plus write:repository and read:user, # GITEA_TOKEN a PAT with write:issue. `claim` and `mine` additionally
# which the rest of this repo's tooling reaches for) # need to know your username: set GITEA_USER, or give the
# token read:user and it is looked up.
# GITEA_USER your Gitea login. Optional; see above.
# GITEA_URL defaults to https://git.ljones.me # GITEA_URL defaults to https://git.ljones.me
# GITEA_REPO defaults to yonlu/yellowjacket # GITEA_REPO defaults to yonlu/yellowjacket
set -euo pipefail set -euo pipefail
@@ -81,7 +83,29 @@ read_body() {
if [ "$file" = "-" ]; then cat; else cat "$file"; fi if [ "$file" = "-" ]; then cat; else cat "$file"; fi
} }
me() { curl -sS -H "Authorization: token $GITEA_TOKEN" "$server/api/v1/user" | python3 "$py" login; } # The one lookup in this script that needs a scope beyond write:issue.
# `GET /user` requires read:user, and it is reached for exactly two reasons:
# to name the assignee in `claim`, and to filter in `mine`. A token scoped to
# the work this script does — write:issue — therefore failed at `claim`, which
# is the one step the workflow requires before the first edit, so the whole
# documented process was blocked by its own tooling.
#
# GITEA_USER short-circuits it, which is what lets a least-privilege token do
# the job. The lookup stays as the fallback because it is right when the
# scope is there and needs no setup at all.
me() {
if [ -n "${GITEA_USER:-}" ]; then
printf '%s' "$GITEA_USER"
return
fi
curl -sS -H "Authorization: token $GITEA_TOKEN" "$server/api/v1/user" |
python3 "$py" login ||
{
echo "issue.sh: could not resolve your username. Set GITEA_USER, or" >&2
echo "issue.sh: re-issue GITEA_TOKEN with read:user." >&2
exit 1
}
}
label_id() { call GET "/labels?limit=100" | python3 "$py" label-id "$1"; } label_id() { call GET "/labels?limit=100" | python3 "$py" label-id "$1"; }