Compare commits

..
Author SHA1 Message Date
logan f1c066db6e 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 2m29s
CI / e2e (pull_request) Successful in 6m53s
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 13:18:55 -04:00
logan a1ee967323 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 13:18:24 -04:00
53 changed files with 189 additions and 3303 deletions
-6
View File
@@ -195,12 +195,6 @@ Two rules about climbing:
- **Do not write an e2e spec first.** Drive the flow by hand, then
promote it with `/e2e`. Specs written blind assert on selectors that
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`,
`make bindings-check`, `make css-check` and — from `frontend/`
-68
View File
@@ -3664,71 +3664,3 @@ 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
row's text 242 → 133. It covers the content area only — not the sidebar
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.
+1 -146
View File
@@ -945,158 +945,13 @@ change at all.
Two rules hold it up. The **first** navigation *replaces* the launch
entry rather than pushing one, or every launch costs a back press before
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
the app will close. And the in-app back buttons (`navigate-back`, fired
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
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
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.
**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
primary view in the DOM and toggles a `.view-hidden` class, because that
is what preserves `scrollTop` across navigation — so
+1 -76
View File
@@ -544,7 +544,7 @@ func (c *Config) SetDefaultPage(page string) error {
c.General.ApplyDefaults()
}
c.General.DefaultPage = View(page)
c.General.DefaultPage = DefaultPage(page)
if err := c.General.Validate(); err != nil {
return fmt.Errorf(
@@ -666,81 +666,6 @@ func (c *Config) SetAllowMeteredCatalogDownload(allow bool) error {
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.
func (c *Config) GetTrackListColumns() []tracklist.Column {
if c.TrackList == nil {
+26 -76
View File
@@ -5,14 +5,28 @@ import (
"fmt"
)
// DefaultDefaultPage is the launch page for a fresh install.
const DefaultDefaultPage = ViewHome
// DefaultPage identifies which view the app opens to on launch.
type DefaultPage string
var (
errUnknownDefaultPage = errors.New("unknown default page")
errViewCannotLaunch = errors.New("view cannot be the launch page")
// 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.
const DefaultDefaultPage = DefaultPageHome
var errUnknownDefaultPage = errors.New("unknown default page")
// QueueFallback identifies what plays, if anything, once the queue
// runs out with nothing left to auto-advance to.
type QueueFallback string
@@ -32,22 +46,8 @@ var errUnknownQueueFallback = errors.New("unknown queue fallback")
// GeneralConfig holds general application preferences that don't
// belong to a more specific subsystem.
type GeneralConfig struct {
DefaultPage View `toml:"DefaultPage"`
DefaultPage DefaultPage `toml:"DefaultPage"`
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
// 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,
@@ -71,17 +71,15 @@ func (c *GeneralConfig) ApplyDefaults() {
func (c *GeneralConfig) Validate() error {
c.ApplyDefaults()
spec, known := LookupView(string(c.DefaultPage))
if !known {
switch c.DefaultPage {
case DefaultPageHome, DefaultPageTracks, DefaultPageAlbums, DefaultPageArtists,
DefaultPageGenres, DefaultPagePlaylists, DefaultPageExplore, DefaultPageDownloads,
DefaultPageAutotag, DefaultPageJobs:
// Valid.
default:
return fmt.Errorf("%w: %q", errUnknownDefaultPage, c.DefaultPage)
}
if !spec.CanLaunch {
return fmt.Errorf("%w: %q", errViewCannotLaunch, c.DefaultPage)
}
c.normalizeViewVisibility()
switch c.QueueFallback {
case QueueFallbackStop, QueueFallbackFavorites, QueueFallbackDynamicMix:
// Valid.
@@ -91,51 +89,3 @@ func (c *GeneralConfig) Validate() error {
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
}
-89
View File
@@ -1,89 +0,0 @@
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"
ViewJobs View = "jobs"
ViewSettings View = "settings"
)
// 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: ViewJobs, VisibleByDefault: true, 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
}
-255
View File
@@ -1,255 +0,0 @@
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(ViewJobs): false,
},
}
general.ApplyDefaults()
resolved := general.ResolvedViewVisibility()
if !resolved[string(ViewAutotag)] {
t.Error("autotag was switched on and should be visible")
}
if resolved[string(ViewJobs)] {
t.Error("jobs 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")
}
}
// 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(ViewJobs), 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(ViewJobs)] {
t.Error("jobs 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)
}
}
}
-94
View File
@@ -82,100 +82,6 @@ func TestPruneStaleLocalCrossReferences(t *testing.T) {
}
}
// TestPruneClearsInLibraryWithNoLocalID covers the fixed point: a row
// carrying in_library with a NULL local_*_id. The upsert's conflict
// clause is `in_library = MAX(in_library, excluded.in_library)`, so it
// can only ever raise the flag, and this pass used to be gated on the id
// being present — which meant nothing in the app could clear such a row,
// ever. It is asserted for all three entity types because the gate was
// written once and used three times, so a fix applied to one is a fix
// that looks complete.
//
// The rows are seeded with raw SQL rather than through seedIndexResult
// deliberately: upsertBatch writes a zero LocalArtistID as literal 0,
// not NULL, and 0 satisfies `IS NOT NULL` — so the old gate already
// caught that shape and a fixture built through the upsert cannot
// reproduce this at all. NULL is what the artifact importer and any
// older writer leave behind, the column being nullable with no default.
func TestPruneClearsInLibraryWithNoLocalID(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
si := NewSearchIndex(db, nil, nil, slog.Default())
// A genuinely owned artist, to prove the wider gate does not simply
// clear everything it now looks at.
database.InsertTestTrack(t, db, database.TestTrack{
FilePath: "/music/owned.mp3",
Artist: "Owned",
})
artist, err := db.Queries.GetArtistByName(t.Context(), "Owned")
if err != nil {
t.Fatalf("read seeded artist: %v", err)
}
seedIndexResult(t, db, SearchIndexResult{
EntityType: EntityArtist,
MBID: testMBID("owned"),
Title: "Owned",
ArtistName: "Owned",
ArtistMBID: testMBID("owned"),
InLibrary: true,
LocalArtistID: artist.ID,
})
orphans := []struct {
name string
entityType string
mbid string
}{
{"artist", EntityArtist, "orphan-artist"},
{"release group", EntityReleaseGroup, "orphan-release-group"},
{"recording", EntityRecording, "orphan-recording"},
}
for _, o := range orphans {
if _, err := db.ExecContext(
`INSERT INTO explore_index
(entity_type, mbid, title, artist_name, artist_mbid,
in_library,
local_artist_id, local_release_group_id, local_recording_id)
VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?)`,
dbEntityType(o.entityType), dbMBID(testMBID(o.mbid)), o.name, o.name,
dbMBID(testMBID(o.mbid)),
nil, nil, nil,
); err != nil {
t.Fatalf("seed %s orphan: %v", o.name, err)
}
}
si.pruneStaleLocalCrossReferences()
inLibrary := func(t *testing.T, mbid string) int {
t.Helper()
var flag int
if err := db.QueryRowWriter(
"SELECT in_library FROM explore_index WHERE mbid = ?", dbMBID(mbid),
).Scan(&flag); err != nil {
t.Fatalf("read in_library for %q: %v", mbid, err)
}
return flag
}
for _, o := range orphans {
if got := inLibrary(t, testMBID(o.mbid)); got != 0 {
t.Errorf("%s with a NULL local id: in_library = %d, want 0", o.name, got)
}
}
if got := inLibrary(t, testMBID("owned")); got != 1 {
t.Errorf("owned artist: in_library = %d, want 1 (it still has a file)", got)
}
}
// TestUnenrichedLibraryArtistMBIDs_OrdersByOwnedTrackCount verifies the
// backfill queue prioritizes artists by how many tracks the user actually
// owns, not by how many duplicate-mbid artist rows happen to exist (the
+1 -15
View File
@@ -2562,19 +2562,6 @@ func (si *SearchIndex) PopulateLocalCrossReferences() {
// The row itself is left in place (it may still be part of the shipped
// catalog, just no longer owned) — only the "this is mine" bookkeeping
// is cleared.
//
// It is gated on the flag *or* the id, not on the id alone. Gated on
// the id, `in_library = 1 AND local_*_id IS NULL` is a fixed point: the
// upsert can only ever raise the flag and this pass skipped such a row
// by construction, so nothing in the app could clear it — a row claiming
// to be owned, permanently, with no local row to check the claim
// against. Nothing in the tree writes that shape today
// (collectLibraryEntities sets both together), which is exactly why it
// is worth closing now: the exposure is a database written by an older
// version, and the next writer that sets the flag without an id, which
// nothing structurally prevents. A NULL id fails the existence test on
// its own, so the wider gate needs no second clause to say what "not
// owned" means.
func (si *SearchIndex) pruneStaleLocalCrossReferences() {
type prune struct {
entityType string
@@ -2607,8 +2594,7 @@ func (si *SearchIndex) pruneStaleLocalCrossReferences() {
result, err := si.db.ExecContext(
`UPDATE explore_index
SET in_library = 0, `+p.column+` = NULL
WHERE entity_type = ?
AND (`+p.column+` IS NOT NULL OR in_library = 1)
WHERE entity_type = ? AND `+p.column+` IS NOT NULL
AND NOT EXISTS (`+p.exists+`)`,
dbEntityType(p.entityType),
)
+1 -10
View File
@@ -24,19 +24,10 @@ func DefaultBindings() map[string]string {
"player.repeat": "R",
"player.mute": "M",
// 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.
// Navigation (Global scope)
"nav.search": "/",
"nav.searchAlt": "Ctrl+F",
"nav.queue": "Q",
"nav.back": "Alt+Left",
"nav.forward": "Alt+Right",
// App actions
"app.selectAll": "Ctrl+A",
-244
View File
@@ -15,49 +15,12 @@ import { test, expect } from '../support/fixtures.js';
*
* What it cannot answer is whether Android's *gesture* reaches the
* 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;
const activeView = (page: Page) =>
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.
*
@@ -79,114 +42,6 @@ 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('leaves a detail view for the view it was opened from', async ({
app,
@@ -216,105 +71,6 @@ test.describe('the back gesture', () => {
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 }) => {
await app.getByTestId('nav-tracks').click();
await openAnArtist(app);
+2 -6
View File
@@ -1,4 +1,4 @@
import { test, expect, navigateTo } from '../support/fixtures.js';
import { test, expect } from '../support/fixtures.js';
/**
* H-19: Playlists, Downloads, Jobs, Settings and Home had a page
@@ -58,12 +58,8 @@ const TAGS: Record<string, string> = {
test.describe('every primary view says what it is', () => {
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) {
await navigateTo(app, view);
await app.getByTestId(`nav-${view}`).click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
view,
+2 -4
View File
@@ -1,4 +1,4 @@
import { test, expect, navigateTo } from '../support/fixtures.js';
import { test, expect } from '../support/fixtures.js';
/**
* Plan 007 phase 5: a11y.1 and a11y.2, frozen against the real app.
@@ -56,9 +56,7 @@ test.describe('Settings is reachable without a mouse', () => {
test.describe("Downloads' tabs are tabs", () => {
test('arrow keys move the selection and swap the panel', async ({ app }) => {
// By event, not by nav item: with no download client configured
// there is no Downloads destination to click (#25).
await navigateTo(app, 'downloads');
await app.getByTestId('nav-downloads').click();
const view = app.locator('downloads-view');
const requests = view.getByRole('tab', { name: 'Requests' });
+2 -7
View File
@@ -4,7 +4,6 @@ import {
eventNames,
resetEvents,
waitForEvent,
navigateTo,
} from '../support/fixtures.js';
/**
@@ -40,9 +39,7 @@ test.describe('view lifecycle', () => {
test('a keypress on Settings does not reach the Autotag queue', async ({
app,
}) => {
// By event, not by nav item: Autotag is hidden by default (#25)
// and a hidden view is still reachable.
await navigateTo(app, 'autotag');
await app.getByTestId('nav-autotag').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'autotag',
@@ -86,9 +83,7 @@ test.describe('view lifecycle', () => {
// 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*
// toggled shuffle. As a panel binding it can only mean one thing.
// By event, not by nav item: Autotag is hidden by default (#25)
// and a hidden view is still reachable.
await navigateTo(app, 'autotag');
await app.getByTestId('nav-autotag').click();
await expect
.poll(() => pendingCount(app))
.toMatch(/^Pending \(\d+\)$/);
-116
View File
@@ -1,116 +0,0 @@
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,35 +111,6 @@ export async function bindingCalls(page: Page): Promise<string[]> {
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). */
export class TestCtl {
constructor(private readonly baseURL: string) {}
@@ -112,16 +112,6 @@ export function GetTrackListColumns(): $CancellablePromise<tracklist$0.Column[]
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.
*/
@@ -257,20 +247,6 @@ export function SetTrackListColumns(columns: tracklist$0.Column[] | null): $Canc
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.
*/
+1 -33
View File
@@ -104,15 +104,6 @@ p {
flex: 0 1 320px;
}
/* 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 {
list-style-type: none;
}
@@ -142,23 +133,6 @@ ul {
.subtitle {
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 {
@@ -372,13 +346,7 @@ body div.sidebar {
/* The search box is the one header control worth its width; the
library filter is a rarely-changed setting and reachable from
the drawer's Settings.
`nav-history` is already gone from 899 down. It would belong
here anyway and for a stronger reason than width: the phone has
Back as a gesture or a button the OS owns, and this app hooks it
(`popstate`), so a second Back in the chrome duplicates a
control the platform provides. */
the drawer's Settings. */
.top-bar library-filter {
display: none;
}
-6
View File
@@ -20,12 +20,6 @@
<!-- a11y.29: a heading level was being used for type size. -->
<p class="subtitle">Music how it was meant to bee.</p>
</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>
<search-bar></search-bar>
<job-indicator></job-indicator>
+18 -105
View File
@@ -23,7 +23,6 @@ import '@components/now-playing/now-playing.ts';
import '@components/sidebar/app-sidebar.ts';
import '@components/bottom-nav/bottom-nav.ts';
import '@components/queue-panel/queue-panel.ts';
import '@components/nav-history/nav-history.ts';
import '@components/search-bar/search-bar.ts';
import '@components/library-filter/library-filter.ts';
import '@components/first-run-wizard/first-run-wizard.ts';
@@ -41,8 +40,6 @@ import { setBasePath } from '@awesome.me/webawesome/dist/webawesome.js';
import { registerBundledIcons } from './src/icons';
import { queueStore } from '@store/queue-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 Queue from '@go/queue/queue.js';
import { GetDefaultPage } from '@go/config/config.js';
@@ -217,101 +214,45 @@ document.addEventListener('navigate', (e: Event) => {
// go through `history.back()` rather than popping `navStack`
// themselves, so one press cannot consume two entries.
/** The navigation an entry stands for, and where it sits in this
* session's list. `undefined` on the entry that predates the app's own
* routing, which is the one back exits from. */
type NavState = { yjNav?: { view: string; [key: string]: any }; yjIdx?: number };
/** The navigation an entry stands for. `undefined` on the entry that
* predates the app's own routing, which is the one back exits from. */
type NavState = { yjNav?: { view: string; [key: string]: any } };
/** Whether the app's first navigation has been recorded. It *replaces*
* the launch entry rather than pushing, or every launch would cost one
* back press before the app would exit. */
let historyStarted = false;
// Back and forward are the *same* `popstate` event -- it carries no
// direction, and the History API exposes neither the current position
// 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);
}
/** How many entries this session has pushed beyond that first one --
* i.e. how deep back can go while staying inside the app. */
let pushedEntries = 0;
function recordNavigation(detail: { view: string; [key: string]: any }): void {
// `_isBack` and `_replace` are bookkeeping, not destination: keeping
// either in the entry would make a replayed navigation claim to be
// one.
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;
}
// `_isBack` is bookkeeping, not destination: keeping it in the entry
// would make a replayed navigation claim to be a back-navigation.
const { _isBack: _ignored, ...nav } = detail;
const state: NavState = { yjNav: nav };
// Same URL, deliberately: the app has no routes, and a path a
// reload cannot resolve is worse than no path at all.
if (historyStarted) {
currentIndex += 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 }, '');
history.pushState(state, '');
pushedEntries += 1;
} else {
currentIndex = 0;
maxIndex = 0;
history.replaceState({ yjNav: nav, yjIdx: 0 }, '');
history.replaceState(state, '');
historyStarted = true;
}
publishDepth();
}
window.addEventListener('popstate', (e: PopStateEvent) => {
const state = e.state as NavState | null;
const nav = state?.yjNav;
const nav = (e.state as NavState | null)?.yjNav;
// Before the app's first navigation, or an entry somebody else
// pushed: nothing to restore, and the activity should be free to
// finish.
if (!nav) return;
// 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();
pushedEntries = Math.max(0, pushedEntries - 1);
void handleNavigate({ ...nav, _isBack: true });
});
@@ -338,20 +279,6 @@ async function handleNavigate(
// attribute keeps e2e selectors semantic instead of structural.
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 ----------------------------------------
if (view in VIEW_TAGS) {
// Remove any active detail view first
@@ -570,18 +497,7 @@ function schedule(fn: () => void): void {
// anyway would leave the app: the depth check is what stops a stray
// `navigate-back` closing it.
document.addEventListener('navigate-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();
if (pushedEntries > 0) history.back();
});
// Navigate to the user's configured launch page. Falls back to 'home'
@@ -591,17 +507,14 @@ GetDefaultPage()
document.dispatchEvent(new CustomEvent('navigate', {
bubbles: true,
composed: true,
// 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 },
detail: { view: view || 'home' },
}));
})
.catch(() => {
document.dispatchEvent(new CustomEvent('navigate', {
bubbles: true,
composed: true,
detail: { view: 'home', _replace: true },
detail: { view: 'home' },
}));
});
@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 532 B

@@ -7,8 +7,6 @@ import { designTokens } from '../../styles/tokens.css';
import '../sidebar/app-sidebar.js';
import { nameDialog } from '@utils/name-dialog';
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';
@@ -116,35 +114,8 @@ export class BottomNav extends LitElement {
}
`];
/**
* 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);
@state()
private activeView = 'home';
/**
* Whether the drawer has been asked for.
@@ -196,9 +167,12 @@ export class BottomNav extends LitElement {
nameDialog(this.drawer);
}
private onGlobalNavigate = () => {
private onGlobalNavigate = (e: Event) => {
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.
// The highlight is not this listener's business any more.
this.drawerOpen = false;
};
@@ -228,17 +202,13 @@ export class BottomNav extends LitElement {
return html`
<nav aria-label="Primary">
<ul>
${BottomNav.TABS
.filter((tab) => this.visibilityCtrl.visible(tab.id))
.map((tab) => html`
${BottomNav.TABS.map((tab) => html`
<li>
<button
type="button"
class=${this.activeCtrl.isActive(tab.id)
? 'active'
: ''}
class=${this.activeView === tab.id ? 'active' : ''}
data-testid="tab-${tab.id}"
aria-current=${this.activeCtrl.isActive(tab.id)
aria-current=${this.activeView === tab.id
? 'page'
: 'false'}
@click=${() => this.navigate(tab.id)}
@@ -27,9 +27,6 @@ import type * as library from '@go/library/models.js';
import { ThemeController } from '@store/controllers/theme-controller';
import { TrackListController } from '@store/controllers/tracklist-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 type * as playlist from '@go/playlist/models.js';
import { Events } from '../../events';
@@ -74,9 +71,6 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
// --- Favorites controller ---
private favCtrl = new FavoritesController(this);
/** Which destinations the navigation offers (#25). */
private viewsCtrl = new ViewVisibilityController(this);
// --- Shortcuts controller ---
private shortcutsCtrl = new ShortcutsController(this);
@@ -475,12 +469,6 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
flex: 1;
}
.view-note {
color: var(--yj-text-tertiary, #888);
font-size: var(--yj-font-size-sm, 0.85rem);
margin-left: auto;
}
.column-arrows {
display: flex;
gap: 0.15em;
@@ -1096,25 +1084,6 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
}
}
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 = (
e: CustomEvent<ConfigFieldChangeEvent>,
): void => {
@@ -1459,7 +1428,6 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
-->
${this.renderLibrarySection()}
${this.renderGeneralSection()}
${this.renderNavigationSection()}
${this.renderNowPlayingSection()}
${this.renderThemeSection()}
${this.renderTrackListSection()}
@@ -1710,79 +1678,6 @@ 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 ---
private renderThemeSection() {
+20 -43
View File
@@ -161,52 +161,29 @@ export class HomeView extends ViewLifecycleMixin(LitElement) {
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 {
display: none;
position: absolute;
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;
}
@media (hover: hover) and (pointer: fine) {
.play {
position: absolute;
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);
}
.card:hover .play,
.card:focus-within .play {
opacity: 1;
transform: translateY(0);
}
.name {
@@ -1,133 +0,0 @@
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,7 +13,6 @@ import {
isQueueSourceNavigable,
navigateToQueueSource,
} from '@utils/queue-source-link';
import { PHONE_QUERY } from '@utils/breakpoints';
import { PlayerController } from '@store/controllers/player-controller';
import { creditStore } from '@store/credit-store';
import { QueueController } from '@store/controllers/queue-controller';
@@ -81,19 +80,6 @@ export class NowPlaying extends LitElement {
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). */
@state()
private titleScrolling = false;
@@ -355,12 +341,6 @@ export class NowPlaying extends LitElement {
this.reduceMotion = this.reduceMotionQuery?.matches ?? false;
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.geometryDirty = true;
this.requestUpdate();
@@ -384,7 +364,6 @@ export class NowPlaying extends LitElement {
this.attachDragListeners(false);
window.removeEventListener(SCROLL_CHANGE_EVENT, this.handleScrollModeEvent);
this.reduceMotionQuery?.removeEventListener('change', this.handleReduceMotionChange);
this.phoneQuery?.removeEventListener('change', this.handlePhoneChange);
this.resizeObserver?.disconnect();
this.stopScrollCycle('title');
this.stopScrollCycle('artist');
@@ -509,7 +488,7 @@ export class NowPlaying extends LitElement {
@mouseleave=${this.handleTitleMouseLeave}
@transitionend=${() => this.onScrollCycleEnd('title')}
>
<span class="scroll-content">${this.phone ? track.title : trackLink(track.title, track.album, track.releaseGroupMbid, track.recordingMbid) || track.title}</span>
<span class="scroll-content">${trackLink(track.title, track.album, track.releaseGroupMbid, track.recordingMbid) || track.title}</span>
</span>
<span
class="track-artist ${artistScrolling ? 'will-scroll' : ''} ${this.artistScrolling ? 'scrolling' : ''}"
@@ -519,15 +498,14 @@ export class NowPlaying extends LitElement {
@mouseleave=${this.handleArtistMouseLeave}
@transitionend=${() => this.onScrollCycleEnd('artist')}
>
<span class="scroll-content">${this.phone ? track.artist || 'Unknown Artist' : creditLink(creditStore.credits(track.recordingMbid), track.artist, track.artistMbid) || 'Unknown Artist'}</span>
<span class="scroll-content">${creditLink(creditStore.credits(track.recordingMbid), track.artist, track.artistMbid) || 'Unknown Artist'}</span>
</span>
${describeQueueSource(this.queue.source)
? html`
<span
class="track-source ${!this.phone && isQueueSourceNavigable(this.queue.source) ? 'navigable' : ''}"
class="track-source ${isQueueSourceNavigable(this.queue.source) ? 'navigable' : ''}"
data-testid="now-playing-source"
@click=${(e: MouseEvent) => {
if (this.phone) return;
if (!isQueueSourceNavigable(this.queue.source)) return;
navigateToQueueSource(
e.currentTarget as EventTarget,
@@ -593,10 +571,6 @@ export class NowPlaying extends LitElement {
this.reduceMotion = e.matches;
};
private handlePhoneChange = (e: MediaQueryListEvent): void => {
this.phone = e.matches;
};
private shouldScroll(field: 'title' | 'artist'): boolean {
const overflows = field === 'title' ? this.titleOverflows : this.artistOverflows;
@@ -632,12 +606,6 @@ export class NowPlaying extends LitElement {
track?.artist ?? '',
this.shouldScroll('title') ? '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');
}
+56 -38
View File
@@ -4,10 +4,19 @@ import '@awesome.me/webawesome/dist/components/icon/icon.js';
import { designTokens } from '../../styles/tokens.css';
import type { DragActiveDetail } from '@utils/drag-controller';
import { ActiveViewController } from '@store/controllers/active-view-controller';
import { ViewVisibilityController } from '@store/controllers/view-visibility-controller';
import { VIEW_META } from '../../services/view-meta';
import type { View } from '../../services/view-meta';
import {
ICON_PLAYLIST,
ICON_AUTOTAG,
ICON_REQUESTED,
} 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 MAX_WIDTH = 400;
@@ -150,20 +159,11 @@ export class AppSidebar extends LitElement {
/** Delay in ms before a drag-hover triggers navigation. */
private static readonly HOVER_NAV_DELAY = 600;
/**
* Which item is lit, read from the shell rather than tracked here.
*
* This used to be a `@state()` field defaulting to `home` -- the
* 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);
/** Home, because that is where `index.ts` now navigates on startup
* (H-8). The sidebar does not hear a `navigate` it did not send,
* so this default is what keeps `aria-current` honest on arrival. */
@state()
private activeView: View = 'home';
@state()
private isDragging = false;
@@ -200,15 +200,19 @@ export class AppSidebar extends LitElement {
typeof setTimeout
> | null = null;
/**
* Which destinations the user has kept (#25). The list below is
* still the whole set and its order -- this only filters it, and
* only for drawing: a hidden view is still reachable by `navigate`,
* which is what detail views and the launch page depend on.
*/
private visibilityCtrl = new ViewVisibilityController(this);
private navItems = VIEW_META;
private navItems: NavItem[] = [
{ 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: 'jobs', label: 'Jobs', icon: 'list-check' },
{ id: 'settings', label: 'Settings', icon: 'gear' },
];
override connectedCallback() {
super.connectedCallback();
@@ -233,6 +237,10 @@ export class AppSidebar extends LitElement {
'yj-drag-active',
this.onDragActive as EventListener,
);
document.addEventListener(
'navigate',
this.onGlobalNavigate as EventListener,
);
}
override disconnectedCallback() {
@@ -254,6 +262,10 @@ export class AppSidebar extends LitElement {
'yj-drag-active',
this.onDragActive as EventListener,
);
document.removeEventListener(
'navigate',
this.onGlobalNavigate as EventListener,
);
this.clearDragHoverTimer();
}
@@ -269,12 +281,9 @@ export class AppSidebar extends LitElement {
></div>
<nav aria-label="Main">
<ul>
${this.navItems
.filter((item) => this.visibilityCtrl.visible(item.id))
.map((item) => {
const active = this.activeCtrl.isActive(item.id);
${this.navItems.map((item) => {
const classes = [
active
this.activeView === item.id
? 'active'
: '',
this.dragHoverView === item.id
@@ -290,7 +299,7 @@ export class AppSidebar extends LitElement {
type="button"
class=${classes}
data-testid="nav-${item.id}"
aria-current=${active
aria-current=${this.activeView === item.id
? 'page'
: 'false'}
@click=${() =>
@@ -373,6 +382,19 @@ export class AppSidebar extends LitElement {
private static readonly DROP_VIEWS: Set<View> =
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 = (
e: CustomEvent<DragActiveDetail>,
) => {
@@ -438,11 +460,7 @@ export class AppSidebar extends LitElement {
}
private navigate(view: 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.activeView = view;
this.dispatchEvent(new CustomEvent('navigate', {
detail: { view },
bubbles: true,
@@ -11,7 +11,6 @@ import {
import { SelectionController } from '@utils/selection-controller';
import type { SelectionHost } from '@utils/selection-controller';
import { ViewLifecycleMixin } from '@utils/view-lifecycle';
import { PHONE_QUERY } from '@utils/breakpoints';
import {
ContextMenuController,
contextMenuStyles,
@@ -106,6 +105,9 @@ const ROW_CHROME_WIDTH =
const ROW_HEIGHT = 33;
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
// overhead (30-50 shadow roots during scroll). Font Awesome 6 paths.
-1
View File
@@ -19,7 +19,6 @@ regular/heart
regular/star
solid/arrow-down-wide-short
solid/arrow-left
solid/arrow-right
solid/arrow-rotate-right
solid/arrows-rotate
solid/arrow-up-short-wide
@@ -400,20 +400,6 @@ async function dispatch(action: string): Promise<void> {
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': {
const queuePanel = document.getElementById(
'queue-panel',
-12
View File
@@ -103,18 +103,6 @@ export const SHORTCUT_META: Record<string, ShortcutMeta> = {
scope: 'global',
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': {
label: 'Keyboard Shortcuts',
category: 'App',
-66
View File
@@ -1,66 +0,0 @@
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'
| 'jobs'
| '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: 'jobs', label: 'Jobs', icon: 'list-check' },
{ id: 'settings', label: 'Settings', icon: 'gear', alwaysShown: true },
];
-90
View File
@@ -1,90 +0,0 @@
/**
* 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();
@@ -1,59 +0,0 @@
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);
}
}
@@ -1,40 +0,0 @@
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();
}
}
@@ -1,54 +0,0 @@
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,8 +189,6 @@ class DownloadStore {
private initialized = false;
private providersLoaded = false;
constructor() {
EventsOn(Events.DownloadProvidersChanged, () => {
void this.refreshProviders();
@@ -287,25 +285,6 @@ 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> {
try {
this.providersValue = (await ListProviders()) ?? [];
-66
View File
@@ -1,66 +0,0 @@
/**
* 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,11 +9,6 @@ export type { ThemeState, BackgroundShade } from './theme-store';
export { ThemeController } from './controllers/theme-controller';
export { searchStore } from './search-store';
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 type { ShortcutsState } from './shortcuts-store';
export { ShortcutsController } from './controllers/shortcuts-controller';
-119
View File
@@ -1,119 +0,0 @@
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
@@ -1,23 +0,0 @@
/**
* 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)';
+23 -54
View File
@@ -3,21 +3,15 @@
*
* 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
* bubbling, composed `navigate` event `app-sidebar` does, and reads
* which tab is lit from `activeViewStore`the shell's one statement
* of where the user is — so it follows a navigation from anywhere: a
* card, a detail view, the drawer's own sidebar, or the back gesture.
*
* 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.
* bubbling, composed `navigate` event `app-sidebar` does and listens
* for that event globally, so a navigation from anywhere — a card, a
* detail view, the drawer's own sidebar — moves its highlight too. A
* tab bar that only tracks its own clicks looks right until the moment
* the user arrives somewhere by another route.
*/
import { describe, expect, it, beforeEach } from 'vitest';
import '@components/bottom-nav/bottom-nav';
import { activeViewStore } from '@store/active-view-store';
import type { BottomNav } from '@components/bottom-nav/bottom-nav';
import { fixture, shadow, shadowAll, update } from '@test/support/render';
import { resetHarness } from '@test/support/harness';
@@ -27,12 +21,6 @@ type Nav = BottomNav;
const tabs = (el: HTMLElement) =>
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. */
const once = (el: Element, name: string, timeoutMs = 2000) =>
new Promise<void>((resolve, reject) => {
@@ -82,55 +70,36 @@ describe('bottom-nav', () => {
it('follows a navigation it did not send', async () => {
const el = await fixture<Nav>('bottom-nav');
activeViewStore.setView('tracks', true);
document.dispatchEvent(new CustomEvent('navigate', {
detail: { view: 'tracks' },
bubbles: true,
composed: true,
}));
await update(el, {});
expect(current(el)).toEqual(['tab-tracks']);
const current = tabs(el)
.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 () => {
const el = await fixture<Nav>('bottom-nav');
activeViewStore.setView('settings', true);
document.dispatchEvent(new CustomEvent('navigate', {
detail: { view: 'settings' },
bubbles: true,
composed: true,
}));
await update(el, {});
// Settings lives in the drawer, so nothing in the bar is current.
// Leaving Home highlighted would be a tab bar lying about where
// the user is.
expect(current(el)).toEqual([]);
});
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']);
expect(
tabs(el).filter((b) => b.getAttribute('aria-current') === 'page'),
).toHaveLength(0);
});
it('closes the drawer when a navigation happens', async () => {
+1 -55
View File
@@ -10,7 +10,6 @@ import '@components/sidebar/app-sidebar';
import '@components/library-filter/library-filter';
import '@components/library-status-indicator/library-status-indicator';
import { Events } from '../../src/events';
import { activeViewStore } from '@store/active-view-store';
import { emit, stub, flush, calls, lastArgs } from '@test/support/harness';
import {
fixture,
@@ -26,26 +25,7 @@ import {
ICON_REQUESTED,
} from '@utils/icon-language';
/** A configured, enabled download client. */
const PROVIDER = {
id: 1,
kind: 'slskd',
name: 'Sound',
enabled: true,
priority: 50,
};
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 () => {
const el = await fixture('app-sidebar');
@@ -69,8 +49,6 @@ describe('<app-sidebar>', () => {
});
it('marks exactly one item as the current page', async () => {
activeViewStore.setView('home', true);
const el = await fixture('app-sidebar');
const current = shadowAll(el, 'li button').filter(
@@ -94,49 +72,17 @@ describe('<app-sidebar>', () => {
expect(seen).toEqual(['artists']);
});
it('moves aria-current with the shell, not with the click', async () => {
activeViewStore.setView('home', true);
it('moves aria-current to the clicked destination', async () => {
const el = await fixture('app-sidebar');
shadow<HTMLElement>(el, '[data-testid="nav-genres"]')?.click();
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(
shadow(el, '[data-testid="nav-genres"]')?.getAttribute('aria-current'),
).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 () => {
const el = await fixture('app-sidebar');
@@ -1,85 +0,0 @@
/**
* 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);
});
});
@@ -11,8 +11,7 @@ import { describe, expect, it, beforeEach } from 'vitest';
import '@components/sidebar/app-sidebar';
import '@components/queue-panel/queue-panel';
import '@components/track-list/track-list';
import { stub, emit, flush } from '@test/support/harness';
import { Events } from '../../src/events';
import { stub } from '@test/support/harness';
import { fixture, shadow, shadowAll, update } from '@test/support/render';
/** Two fixture tracks, enough to move a focus ring between. */
@@ -34,16 +33,6 @@ const TRACKS = [
] as never[];
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 () => {
const el = await fixture('app-sidebar');
@@ -1,91 +0,0 @@
/**
* 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([]);
});
});
@@ -1,166 +0,0 @@
/**
* 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',
});
});
});
@@ -1,191 +0,0 @@
/**
* 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,
jobs: 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',
'jobs',
'settings',
]);
});
it('drops the ones the user switched off', async () => {
const el = await fixture<LitElement>('app-sidebar');
await setViews({ ...ALL_VISIBLE, autotag: false, jobs: false });
await el.updateComplete;
expect(navIDs(el)).not.toContain('autotag');
expect(navIDs(el)).not.toContain('jobs');
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']);
});
});
+3 -97
View File
@@ -1,14 +1,11 @@
/**
* The small stores behind view chrome: the global search term, the
* active view both navs highlight, the track list's column set, and
* the explore cache that keeps detail pages from re-fetching what a
* search already returned.
* The three small stores behind view chrome: the global search term,
* the track list's column set, and the explore cache that keeps detail
* pages from re-fetching what a search already returned.
*/
import { describe, expect, it, beforeEach } from 'vitest';
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 { exploreCache, ARTIST_IMAGE_CACHE_LIMIT } from '@store/explore-cache';
import { Events } from '../../src/events';
@@ -83,97 +80,6 @@ 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', () => {
it('starts from the default column set', () => {
expect(trackListStore.getState().columnIds.length).toBeGreaterThan(0);
+11 -6
View File
@@ -20,14 +20,19 @@ pre-commit:
glob: "*.go"
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:
glob: "*.{go,sql,templ}"
run: ./scripts/codegen-check.sh
run: |
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
# the check above does not cover it. ~3.5s warm, ~20s on a cold
-81
View File
@@ -1,81 +0,0 @@
#!/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,55 +97,6 @@ if [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
fi
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 ───────────────────────────────────────────────
# 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*
@@ -249,20 +200,6 @@ until curl -sf -o /dev/null "http://localhost:$PORT/"; do
sleep 0.25
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
dev-headless: up
url http://localhost:$PORT
+3 -27
View File
@@ -38,10 +38,8 @@
# Where a body is taken and no --body-file is given, it is read from stdin.
#
# Environment:
# GITEA_TOKEN a PAT with write:issue. `claim` and `mine` additionally
# 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_TOKEN a PAT with write:issue (plus write:repository and read:user,
# which the rest of this repo's tooling reaches for)
# GITEA_URL defaults to https://git.ljones.me
# GITEA_REPO defaults to yonlu/yellowjacket
set -euo pipefail
@@ -83,29 +81,7 @@ read_body() {
if [ "$file" = "-" ]; then cat; else cat "$file"; fi
}
# 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
}
}
me() { curl -sS -H "Authorization: token $GITEA_TOKEN" "$server/api/v1/user" | python3 "$py" login; }
label_id() { call GET "/labels?limit=100" | python3 "$py" label-id "$1"; }