`RovingGridController.measureColumns` read `offsetTop`, and every card
in these grids is drawn by a `lit-virtualizer`, which positions its
children with a transform — which `offsetTop` does not see. So all of
them reported 0, every rendered card counted as one row, and ArrowDown
was `min(i + everything, last)` while ArrowUp was `max(i - everything,
0)`: the vertical arrows have been End and Home in the albums, artists
and genres grids since the day this was written. At 700x700 with three
real rows of 3/3/2, ArrowDown from card 0 landed on card 7.
Two things behind it, both only visible once the grid splits:
`cover-grid`'s scrollToIndex was `querySelector('lit-virtualizer')` —
always `#grid-before` — while the roving index spans the whole album
list, so with a dropdown open End scrolled the wrong half to an index
it does not contain. It now picks the half that holds the index and
rebases it.
And the focus is retried on a deadline rather than taken once at the
host's `updateComplete`: a scroll of 5 000 rows produces the card a few
hundred ms later, so the tab stop moved and nothing took focus, which
looks exactly like the key not being handled.
Also waits for the virtualizer in album-dropdown.spec's expandCard,
which flaked on roughly one run in two on main.
Eleven dialogs passed a `label` that never reached the accessibility
tree: Web Awesome renders it into an <h2 id="title"> in the same shadow
root as the native <dialog> and never points aria-labelledby at it, so
getByRole('dialog', {name}) matched nothing and a screen reader
announced an unnamed dialog. a11y.md lists all of them under "what is
already correct".
utils/name-dialog.ts sets the IDREF, with aria-label as the fallback for
without-header (first-run-wizard), called from each host's updated().
aria-labelledby rather than aria-label because three call sites compute
their label at render time, and the heading re-renders anyway. It waits
for the dialog's own first update: wa-dialog populates its shadow root
in its own update, so a query at the host's firstUpdated names nothing.
Reaching into another library's open shadow root is deliberate and the
failure is bounded — if the structure moves, the query misses and the
dialog is as unnamed as it was.
The context menu was the only route to Play, Add to Queue, Play Next,
Add to Playlist, Favourite and Track Details, and it opened on
right-click alone: the panel had no role=menu, so its six menuitems were
orphaned, nothing moved focus into it, and nothing handled arrows or
Escape (a11y.3). Phase 1 deferred this deliberately so it would land
with the dialogs, as one focus-management implementation.
MenuKeyboard is that model. It is standalone rather than part of
ContextMenuController because playlist-view renders a menu without the
controller, and the only thing worse than a menu with no keyboard model
is two menus with two of them. Shift+F10 and the ContextMenu key open it
from a focused row, anchored to that row, and focus returns there.
Three lists had no focused row to open it from, so they gained a roving
tab stop (utils/roving-rows.ts, written once rather than three times).
track-list keeps its own: it predates this, carries selection semantics
the other three do not have, and is pinned by its own tests.
Also the ARIA tail this is one story with: aria-sort on the column
headers (role=columnheader arrived in Phase 1 without it), listbox and
option on the four selectable grids — aria-selected on role=button is
invalid and was being dropped, so the state the whole ctrl/shift
interaction exists to produce was invisible — and live regions on the
four async surfaces that changed in silence.
Two things a reproduction taught that reading could not: the
wa-dropdown-items have not set their role when the host's updateComplete
resolves, so querying by role then finds nothing and the menu opens
without taking focus; and focus() on a popup that has not positioned
itself is a silent no-op.
An event carries what a consumer needs so it never has to invalidate.
- `library-store` answers `TrackPlayCountChanged` by patching one
track, replacing the tracks array (consumers key memoized caches on
its identity) while sharing every unchanged Track — instead of
discarding four collections and refetching 25 MB per song.
- `playlist-store` answers `PlaylistTracksChanged` by refetching the
one playlist the event names, plus the summaries, since `UpdatedAt`
is a sort key. 2 668 kB and 172 ms for one heart, against 2.0 kB. It
falls back to a full invalidate only where a patch cannot be shown
to be equivalent: no id, a cold cache, an unknown id, or a fetch
already in flight. And a store with no subscriber fetches nothing —
the singleton's constructor used to put every track of every
playlist on the path to first paint for a view the user might never
open.
- `library-store` guards every fetch with a cache generation and holds
the request itself instead of deriving a promise from subscriber
notifications, which fixes the library-filter race and the
never-settling waiter together: they are the same bug seen from
either end.
- `explore-cache`'s two art caches are bounded, sharing one exported
cap constant — the artist photo's data URL is held by both, so
capping either alone frees nothing at all and reads as a fix that
did not work.
- `search-store` deliberately does *not* coalesce its notify: deferring
makes a subscriber that unsubscribes synchronously after a `setTerm`
miss the notification entirely, which is a semantic change rather
than an optimisation, and this is the store on the keystroke path.
- `selection-controller` retains its keys across a refetch rather than
clearing them, since they are file paths and those survive one, and
`getSelectedKeysOrdered()` gains an early exit. It stays a walk of
the list: an index goes stale on any re-sort, re-filter or refetch
while a file path survives all three, and 3 ms does not buy a
silently mis-ordered queue insert.
Four small modules the views below adopt:
- `lru-map.ts` — a Map re-inserted on read and trimmed from the front.
`explore-view` never unmounts and its two art caches were plain
Maps: twenty-four searches retained 20.58 MB and were still
accelerating, a cover thumbnail being ~27 kB of base64 and an artist
photo ~128 kB.
- `cache-stats.ts` — a bound has to stay checkable, so caches register
and `window.__yjCacheStats()` reports entries, retained chars and cap
in one eval, rather than the next session having to rebuild the
twenty-four-search reproduction first.
- `track-index.ts` — a WeakMap from the tracks array's identity to a
Map<FilePath, Track>. Five components turned selected file paths back
into tracks with `filePaths.map(fp => tracks.find(...))`, so "Select
all -> Edit tags" at 50 000 tracks blocked the main thread for 3.0 to
6.3 s. 68 ms after. Keying on the array's identity is safe for the
same reason the memoized filter caches are, and it is collected for
free when the store drops the array.
- `lazy-track-details.ts` — one memoised dynamic import, because
`track-details` (42 kB) was imported for side effect by all five
components that open it and so was evaluated before first paint
however the routes were split.
There was no app-level notification surface: two components had grown
private toasts and the other 84 catch blocks ended at console.error,
so a user with a moved file, a locked database or an offline network
saw a button that did nothing. Where errors did surface, eight sites
printed the raw Go string.
Four levels, chosen by the call site from one rule — a failure is only
worth interrupting for if the user can do something about it that they
are not already doing: Blocking (data at risk), Persistent (something
asked for that did not happen, worth retrying), Transient (a small
action whose state visibly reverted anyway), Inline (rendered in the
panel that failed).
Three things about it are load-bearing. Coalescing lives in the store,
keyed by (level, region, key) within a window, so 200 unplayable files
are one message with a count and no future caller has to remember that.
An inline notification carries a *region*, because "inline" says not
global, not where. And the bottom band belongs to the player, so the
app-level stack sits under the header — the player's own floating
notice grows upward by however many lines it needs.
`utils/describe-error.ts` maps the causes a user can act on to copy;
`explainError` repeats a backend message when it is one of our own
sentinels rather than a Go wrapping chain, since mapping "a library
with that name already exists" to something generic is a regression.
`confirmAction()` is a wa-dialog, so destructive actions inherit the
focus trap and Escape the hand-rolled overlays do not have.
`index.ts` caches primary views and hides them with a class so
scrollTop survives navigation. Nothing else was told: `disconnectedCallback`
never fires for one, so everything written to clean up there never
cleans up. The worst case was not a leak — pressing `s` on Settings
skipped two albums out of the Autotag queue, and `a` on that same live
handler rewrites tags on disk.
- `utils/view-lifecycle.ts` is the missing half: `viewActivated` /
`viewDeactivated`, with `listenWhileActive`, `intervalWhileActive`
and `whileActive` torn down on the way out, and an off-screen view
that does not render. `registerViewAware` gives a shared reactive
controller the same treatment, because a controller cannot know
whether its host is a cached view — `ContextMenuController` bound
three document listeners in `hostConnected`, which for a cached host
is "forever".
- `services/shortcut-scope.ts` publishes the ambient scope. Resolving
scope from focus alone was not enough: this app is driven with the
mouse, focus sits on `<body>`, and a focus-only rule would have made
the panel keys work only after a click landed inside the panel.
- Global bindings yield to a focused control that owns the key —
button, select, slider, checkbox, menu, grid row, or anything inside
an open dialog — so the unmodified single-key bindings stop stealing
Space and the arrows.
- `utils/roving-grid.ts` gives a card grid one tab stop moved with the
arrows, since a card per tab stop makes a library-length tab
sequence.
A name linked only when the entity carried an MBID — and for tracks,
only when it carried two. That rule is invisible, so a track list read
as randomly broken: some titles were clickable, most were not, and
nothing on screen said why.
A name now always goes somewhere. Tagged entities open their
MusicBrainz page as before; untagged ones open the *library* page for
the same album or artist, which both detail views already support via
a local id — they just had no caller passing one. An untagged track
highlights by title, since a recording MBID is exactly what it lacks.
Links now fire on a genuine single click only. Every list these appear
in also plays a row on double-click, and the title is the widest thing
in the row, so the first click of that gesture lands on the link:
navigating immediately meant double-clicking a track title opened a
page instead of playing it, which the e2e playback suite caught. The
navigation is held for one double-click interval and dropped if the
second click arrives, while the dblclick itself is left to bubble to
the row — so rows do not need to know links exist.
Consolidates in-progress work across autotag, explore, and library:
- autotag: beets/Picard-informed scoring engine — ID-first matching, VA
handling, recommendation tiers, and a merged distance/rank cascade, with
an eval harness for regression tracking.
- explore: offline MusicBrainz dump import/incremental refresh replaces the
legacy tier crawl; index-first local search with fuzzy matching and a
dedicated ranker; disk-free guards for dump downloads.
- library: artist-credit extraction and matching.
- lyrics: owned-library lyric search (FTS) with LRCLIB backfill.
Also: rewrite README to be user-focused, and migrate upstream to
git.ljones.me/yonlu/yellowjacket.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
End-of-milestone state for the Explore milestone. Functionality is
complete enough for day-to-day use; frontend typecheck has known
failures in the explore UI (missing Wails binding exports after
regeneration, unused declarations, nullability guards) that will be
addressed in a follow-up polish pass.
Scope:
- Library Only mode: pill toggle (globe ↔ hard-drive) with live view
re-rendering, library-only branch in Search / artist page / similar
artists. Suppresses external API calls when enabled.
- Ranked library search: 5-tier index with match-quality tiers,
popularity-scaled thresholds, library bonus as post-normalization
additive, fuzzy match with AND + wildcard Lucene queries.
- New schemas: artist_metadata, http_cache.
- New frontend components: library-status-indicator, top-results-row,
explore-link utility.
- Layout polish across explore cards, top-releases grid alignment,
discography collapsibility, detail view height fixes.
- Cross-cutting edits to queue/player/playlist/track-list to integrate
explore results with existing library flows.
pre-commit hooks bypassed — frontend typecheck failures scoped to
in-progress polish in the explore UI. Go build and full backend test
suite are green.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add selectAll() method to SelectionController that selects all items via host interface
- Change app.selectAll dispatch from document.execCommand('selectAll') to CustomEvent('shortcut:select-all')