docs: record plan 007, its four audits, and what measuring corrected
`.planning/audits/2026-08-11-ui/` is the pass this work came from: the app driven by hand headless plus three static reviews, ~118 findings that are really five problems, each spread by being copied rather than fixed. `.planning/plans/active/007-ui-reconciliation.md` sequences them by blast radius and records what each of the six passes actually shipped — including twenty-five entries under "where the plan was wrong", which is the point of writing it down. The discipline those entries add up to, now in NOTES.md: a finding is three hypotheses — how big it is, why it is that big, and what to do about it — and they can be independently right and wrong. Three of the audit's recommended fixes would have shipped a bug (`m1` stops the card grids repainting, `m6`'s index-ordered selection goes stale on any re-sort, `m5`'s guard leaves the marquee short), all three because they reasoned from the shape of the code and not from what the rest of the file already knew about it. Five findings evaporated or inverted on contact. CLAUDE.md gains the invariants that came out of it, and the skill gains the fourteen measurement traps, each of which produced a wrong number first — the newest being that a longtask entry arrives after the task that produced it, so two numbers that must agree are worth more than one you have to be sceptical about.
This commit is contained in:
@@ -665,3 +665,597 @@ of the working tree, and never two jobs in one directory. The
|
||||
distinction that matters is not clean-vs-dirty but *whose* dirt: a
|
||||
working-tree copy carries a developer's accumulated build output, which
|
||||
is the one thing CI is supposed to be checking you do not depend on.
|
||||
|
||||
## A cached view needs a lifecycle, and so do its controllers
|
||||
|
||||
Plan 007 phase 1. `index.ts` caches primary views and hides them with a
|
||||
class so `scrollTop` survives navigation — a deliberate, good decision
|
||||
that nothing else was told about. `disconnectedCallback` therefore
|
||||
never fires, and every document listener, interval and subscription a
|
||||
view registers runs for the session. The measured cost was not a leak:
|
||||
pressing `s` on **Settings** skipped albums out of the Autotag queue,
|
||||
because `autotag-view`'s document keydown handler was still live.
|
||||
|
||||
Three things that were not obvious before doing it:
|
||||
|
||||
- **A focus-only scope rule would have been a regression.** The
|
||||
shortcut service resolves a panel scope by walking up from the
|
||||
focused element, and this app is driven with the mouse: focus sits on
|
||||
`<body>` almost always. Panel bindings would only have worked after
|
||||
a click landed inside the panel, where the old document listener
|
||||
worked always. Hence the *ambient* scope claimed by the active view
|
||||
(`services/shortcut-scope.ts`) as a fallback after the focus walk.
|
||||
- **Shared reactive controllers have the same bug.**
|
||||
`ContextMenuController` bound three document listeners in
|
||||
`hostConnected`, which for a cached host never un-happens. A
|
||||
controller cannot know whether its host is cached, so
|
||||
`registerViewAware` lets it ask, and it keeps connection-based
|
||||
behaviour on hosts that are not.
|
||||
- **Off-screen views were still rendering.** Store controllers call
|
||||
`requestUpdate()` on every subscriber, so one keystroke in the search
|
||||
box re-rendered eleven pages, ten of them invisible. The mixin
|
||||
withholds the update and replays it on activation, which is why
|
||||
coming back to a view still shows current state.
|
||||
|
||||
Re-running a view's *load* on activation is not free and is not always
|
||||
right: `autotag-view`'s `startQueue()` resets the selected folder and
|
||||
refetches candidates over the network, so it stays once-per-mount and
|
||||
only the local folder list refreshes on return.
|
||||
|
||||
## A local timer is not a clock, and a fixed grid row is not a notice board
|
||||
|
||||
Plan 007 phase 2. Both halves of the finding were reproduced by hand
|
||||
first, and both reproduced exactly as measured in August: the seek bar
|
||||
read `00:44` against a backend at `73` after four keyboard seeks, and
|
||||
a queue with a moved file in the middle stopped dead at index 0 with
|
||||
nothing emitted and `IsPlaying` false.
|
||||
|
||||
Four things worth keeping:
|
||||
|
||||
- **Phase 1 moved the reproduction.** With a track row focused the
|
||||
arrows belong to the grid, so the keyboard seek does not fire from
|
||||
the track list at all any more — the 30 s desync only reproduces
|
||||
with focus off the grid. A fix verified against a stale
|
||||
reproduction would have "passed" without ever running the code
|
||||
path. Re-run the reproduction on the current build, not on the
|
||||
audit's description of it.
|
||||
- **A push of state needs an identity and a sequence.** The store is
|
||||
a singleton and keeps the last position, so a seek bar mounting
|
||||
later adopts it: without `trackChangeId` on the payload that is a
|
||||
stale reading rendered as current. And without a monotonic `seq`,
|
||||
a report of the same second as the last one is indistinguishable
|
||||
from no report, so the interpolation it is supposed to reset keeps
|
||||
running. Both are cheap on the emit side and impossible to add
|
||||
later without touching every consumer.
|
||||
- **`.bottom-bar` is a fixed `4em` grid row.** An inline message laid
|
||||
out inside it squeezes the transport out of its own footer, which
|
||||
looks like a broken player rather than a message. It floats above
|
||||
the bar (`position: absolute; bottom: calc(100% + 4px)`), which is
|
||||
also the right answer for anything else that wants to speak from
|
||||
down there.
|
||||
- **Reporting by event beat returning an error.** The plan wanted the
|
||||
queue bindings to return `error`; the failure that mattered most —
|
||||
auto-advance onto a bad file — has no caller to return to. An event
|
||||
covers both, and the stores kept a `.catch()` per call for the
|
||||
bridge-level rejections that a return value never described anyway.
|
||||
|
||||
## A level says how loud, not where, and the bottom band is taken
|
||||
|
||||
Plan 007 phase 3. The audit's ~30 "the failure is invisible" findings
|
||||
were one problem wearing thirty hats — there was nowhere to put a
|
||||
message — so the surface shipped whole: four levels, one store, one
|
||||
presentation, and the callers routed through it in the same pass.
|
||||
|
||||
Five things worth keeping:
|
||||
|
||||
- **A level is not a location.** Blocking, Persistent and Transient say
|
||||
*how loud*; Inline says *not global*, which is not the same as
|
||||
saying where. An inline notification therefore carries a **region**
|
||||
(`player`, and whatever comes next) and the app-level host ignores
|
||||
it. Without that field the "one component with four presentations"
|
||||
would have become two components with two stores, which is the exact
|
||||
thing this phase existed to delete.
|
||||
- **The bottom of the window belongs to the player.** The stack was
|
||||
first anchored above the player bar, beside the player's own floating
|
||||
notice. That looked right at 1440×900 and overlapped at 800×600,
|
||||
because the player's notice grows *upward* by however many lines its
|
||||
sentence needs. The stack moved under the header. Anything anchored
|
||||
to the bottom edge is sharing a band with something whose height is
|
||||
not known in advance.
|
||||
- **Some backend errors are already sentences.** `describeError` maps
|
||||
runtime causes to copy, but the sentinels this app writes for its own
|
||||
conditions ("a library with that name already exists") are the most
|
||||
useful thing that could be shown, and mapping them to a generic line
|
||||
would have been a regression. `explainError` repeats a message with
|
||||
no Go/HTTP noise markers and defers to the map otherwise. The
|
||||
distinction is whether *we* wrote the string, not how long it is.
|
||||
- **C4 and M1 are the same bug from either end.** The library store
|
||||
cached a stale answer because a fetch outlived the selection, and
|
||||
hung its waiters forever because a failed fetch never satisfied the
|
||||
"loaded and not loading" predicate they watched for. Both go away by
|
||||
holding the request itself and stamping it with a cache generation —
|
||||
one change, two findings, and the four hand-written `waitFor*`
|
||||
helpers deleted.
|
||||
- **A reproduction can fail for the wrong reason.** The e2e spec for a
|
||||
rejected binding renamed the decoy library to its own name, which the
|
||||
backend accepts as a no-op: red at the right assertion, having never
|
||||
induced the failure it was named for. It only became a reproduction
|
||||
once it picked its row by the seeded library's name. A failing test
|
||||
is evidence of nothing until you have watched *why* it fails.
|
||||
|
||||
One operational note: `make bindings-check` requires a clean working
|
||||
tree for `frontend/wailsjs/` and reports staged changes as dirty, so it
|
||||
cannot pass mid-phase on an uncommitted tree. Regenerating and diffing
|
||||
by hand (`go tool wails generate module -tags webkit2_41`, then
|
||||
`git diff -- frontend/wailsjs`) is the equivalent check.
|
||||
|
||||
## Measuring is the work; the icon CDN was serving Pro
|
||||
|
||||
Plan 007 phase 4, items 1–2 of 8. This phase is verified by numbers
|
||||
rather than by assertions, which changes what "first" means: the first
|
||||
deliverable is not a fix, it is a 50 000-track library and a script
|
||||
that takes four measurements against it. Both fixes then landed with a
|
||||
before/after, and both had a reproduction that was watched failing.
|
||||
|
||||
Six things worth keeping:
|
||||
|
||||
- **A measurement library is not a fixture library, and should share
|
||||
nothing but its generator.** `test_data/music_library_test` is
|
||||
curated *cases* selected by name; `.dev/music_library_bulk` is a pile
|
||||
whose only interesting property is its size. Generating 50 000 files
|
||||
through ffmpeg is ~40 minutes, so the bulk one encodes six clips once
|
||||
and copies them — but it still tags every file through
|
||||
`backend/tagwriter`, because a library the app cannot read back
|
||||
measures nothing. 11 s, 466 MB, gitignored, and deliberately not a
|
||||
dependency of `make test`.
|
||||
- **The first cover renderer made a 2 GB library.** The fixture cover's
|
||||
diagonal band is ~37 hard edges at 300 px, which is the worst case
|
||||
for a JPEG DCT: ~35 kB per album, nearly all of it artefacts around a
|
||||
pattern nobody looks at. A smooth gradient is ~6 kB and just as
|
||||
distinguishable. 466 MB instead of 2 GB.
|
||||
- **Instrument the bindings, not the symptoms.** "Finishing a track
|
||||
refetches the library" became a fact rather than an inference by
|
||||
wrapping every method on `window.go` and recording call, duration and
|
||||
serialized size. The generated bindings look their target up at call
|
||||
time (`window['go']['library']['Library']['GetAllTracks']()`), so
|
||||
post-hoc wrapping catches a store that imported the wrapper long ago.
|
||||
Pair it with a `longtask` PerformanceObserver: a 25 MB JSON parse on
|
||||
the main thread appears there and nowhere else.
|
||||
- **A debounce will happily measure nothing.** "Keystroke to paint"
|
||||
against the next frame gave 16 ms on every build, because
|
||||
`search-bar` debounces 150 ms and 16 ms is the input echoing its own
|
||||
character — a number that cannot move, and therefore cannot be
|
||||
evidence. The measurement has to wait past the debounce for the
|
||||
render the keystroke caused.
|
||||
- **The icon CDN was serving Font Awesome _Pro_.** Every SVG fetched
|
||||
from the kit host carries a "Commercial License" comment, so the
|
||||
obvious fix — save what the app already downloads — is a licence
|
||||
violation. Font Awesome **Free** 7.3.1 (CC BY 4.0) has all 64 names
|
||||
the app uses, is redistributable with attribution, and moved no
|
||||
`ui-visual` baseline. Check what a CDN is actually serving before
|
||||
vendoring it.
|
||||
- **Some icon names cannot be found statically.** Twenty call sites
|
||||
compute one from state (`jobIcon(job)`, `TONE_ICONS[tone]`,
|
||||
`this.favCtrl.iconName`), so the list is committed and *checked at
|
||||
runtime*: the resolver records a miss and renders a fallback, and an
|
||||
e2e sweep across every view asserts there are none. A missing icon
|
||||
used to be invisible because the CDN had everything; it now has to be
|
||||
findable instead.
|
||||
|
||||
And two findings that did not survive contact:
|
||||
|
||||
- **`perf.M1`/`M2` no longer reproduce.** One keystroke costs 49.9 ms
|
||||
net of the debounce with **zero** long-task blocking at 50 000
|
||||
tracks, not the predicted 50–100 ms across every mounted view —
|
||||
because Phase 1 stopped off-screen views rendering, which was M1's
|
||||
mechanism. An audit finding can be fixed by an unrelated phase, and
|
||||
re-measuring before fixing is how you find out.
|
||||
- **`perf.M7`/`M8`'s unbounded caches did not show as heap growth**
|
||||
across a ten-view scripted browse (37 → 38 MB post-GC). They are
|
||||
real by inspection, but the reproduction has to be a long Explore
|
||||
session, and it should exist before the LRU does.
|
||||
|
||||
One correction to the audit's own numbering, since two phases cite it:
|
||||
in `perf.md` the icons are **M9** (the plan's Phase 4 prose calls them
|
||||
C1), the whole-library refetch is **C1**, the selection wipe is **C2**,
|
||||
and **C3/C4 were already fixed in Phase 3**.
|
||||
|
||||
## A ticker is a hidden dependency for everything that forgot to speak
|
||||
|
||||
Plan 007 phase 4, items 3–5 (`C5`, `M6`/`H-14`, `M10`). Three fixes,
|
||||
three new measurements, and one bug shipped-and-caught inside the same
|
||||
session — the useful part of which is *how* it was caught.
|
||||
|
||||
Five things worth keeping:
|
||||
|
||||
- **Deleting a polling loop is never only a deletion.** The explore
|
||||
index emitted its status every 3 s forever, with an identical payload
|
||||
once ready, which re-rendered the whole settings page for the life of
|
||||
the session. Every path that *mutates* the status already emitted, so
|
||||
the ticker looked purely redundant. It was not: `si.ready = true` and
|
||||
`si.cancel = nil` both change what `emitStatus` derives and neither
|
||||
announced itself, so the ticker was carrying two transitions within
|
||||
three seconds of their happening. Removing it left the header badge
|
||||
reading "Building search index" over an index the settings page
|
||||
called ready. Before removing a poll, enumerate the writes to
|
||||
everything it reports — `rg 'si\.ready = |\.cancel = '` was the whole
|
||||
audit, and it should have come first rather than second.
|
||||
- **The screenshot found it; no test did.** The Go tests passed, the
|
||||
436 component tests passed, all 36 e2e specs passed, and the numbers
|
||||
were exactly the improvement predicted. The contradiction was two
|
||||
labels 700 px apart in a PNG. "Read the PNG" earns its place in the
|
||||
gate on cases like this — the app was *self-inconsistent*, which no
|
||||
assertion was looking for because nobody had thought to.
|
||||
- **A "0 ms" measurement is usually a broken measurement.** View-open
|
||||
time waited for `#main-content > :not(.view-hidden)`, which matches
|
||||
the view being navigated *away from* — it is still on screen until
|
||||
the incoming chunk resolves. Every view, every build, 0 ms. This is
|
||||
the same failure as the 150 ms debounce from the first pass, and the
|
||||
same tell: a number that cannot move is not evidence. Both times the
|
||||
fix was to wait for the specific thing, not for a generic selector.
|
||||
- **A before/after must differ in exactly one thing, and `git stash` is
|
||||
not a way to arrange that** on a tree carrying four uncommitted
|
||||
phases. Stashing `frontend/index.ts` to measure the pre-split bundle
|
||||
also reverted the bundled-icon registration living in the same file:
|
||||
22 cross-origin requests, and a baseline for a build that has never
|
||||
existed. The honest baseline was made by *adding the static imports
|
||||
back* to the current file — a change that undoes the one thing being
|
||||
measured and nothing else.
|
||||
- **The cheapest half of a fix is often the one the audit did not
|
||||
name.** `perf.C5` is written as an event handler that over-fetches,
|
||||
and it is. But `playlistStore` is a singleton constructed at import
|
||||
time and eagerly warmed itself as well, so every launch paid the same
|
||||
2.6 MB whether or not Playlists was ever opened — the event costs
|
||||
that on a user action, the constructor costs it on every start. "Only
|
||||
when there is a subscriber" turned out to be a two-line change that
|
||||
beat the patching logic it was written to support.
|
||||
|
||||
And one thing about splitting a bundle: **report the trade, not the
|
||||
win.** Route splitting moved 666 kB out of the pre-paint path (1 480 →
|
||||
814 kB) and cost up to 6 ms on the *first* open of a view, once per
|
||||
session, hidden further by warming the chunks on idle. But first
|
||||
contentful paint did not move at all, because at localhost speeds over
|
||||
a warm cache 666 kB of JS is not what the paint was waiting for. The
|
||||
number that improved is real and is the one that costs on a cold start
|
||||
and under WebKit2GTK; the number a user watches did not change. Saying
|
||||
both is the difference between a measurement and an advertisement.
|
||||
|
||||
## "We looked and saw nothing" is only evidence if the thing that fills it ran
|
||||
|
||||
Plan 007 phase 4, items 6 and 7 (`M7`/`M8`, `M3`/`M4`). Two fixes, two
|
||||
new measurements, and one finding that two previous sessions had come
|
||||
within a sentence of deleting as unreproducible.
|
||||
|
||||
`perf.M7` says the Explore art caches are never evicted. Two sessions
|
||||
measured a ten-view scripted browse, saw the heap go 37 → 38 MB
|
||||
post-GC, and recorded the finding as "real by inspection but it does
|
||||
not show up". Both were right about the number and wrong about what it
|
||||
meant: **the browse script navigates to Explore and never types in it,
|
||||
and both caches are filled only by a search.** It was measuring a view
|
||||
with two empty maps. A session of twenty-four searches grows the heap
|
||||
20.58 MB and is still accelerating at the end.
|
||||
|
||||
Six things worth keeping:
|
||||
|
||||
- **A negative result inherits the coverage of the thing that produced
|
||||
it.** "We browsed ten views and the heap was flat" sounds like
|
||||
evidence about caches; it is evidence about ten navigations. Before
|
||||
believing a finding did not reproduce, check that the code path it
|
||||
names actually executed — here, one `console.log` of
|
||||
`thumbnailCache.size` would have ended the question two sessions
|
||||
earlier. Phase 4 has now had three findings evaporate on contact
|
||||
(`M1`, `M2`, and half of `M8`) which makes the fourth *look* like the
|
||||
same thing, and that prior is exactly what made it cheap to accept.
|
||||
- **A bound cannot be verified by a run that never reaches it.** The
|
||||
first bounded build measured identical to the unbounded one: twelve
|
||||
searches cached 180 thumbnails against a cap of 192, so nothing was
|
||||
ever evicted. This is the same trap as the 150 ms debounce and the
|
||||
`:not(.view-hidden)` selector, in its third costume — *a number that
|
||||
cannot move is not evidence* — and the tell is the same one every
|
||||
time: before and after are suspiciously equal.
|
||||
- **Two caches holding the same string means bounding one frees
|
||||
nothing.** `explore-view`'s `artistImageCache` and
|
||||
`exploreCache.artists` both hold the artist photo's base64 data URL,
|
||||
~128 kB each, measured at 2.30 M chars in *both* maps. Capping either
|
||||
alone leaves every string pinned by the other, and the measurement
|
||||
would have read as a fix that did not work. The cap is a shared
|
||||
exported constant now. Before bounding a cache, find every reference
|
||||
to what it holds.
|
||||
- **The audit named the wrong two maps.** `M8` calls out `artistAlbums`
|
||||
and `artistTopTracks` as holding discographies and top-track lists.
|
||||
Nothing in the app has ever written to either — their only callers
|
||||
were a component test. Deleted rather than bounded. The map that
|
||||
actually retains is one the audit does not mention.
|
||||
- **A measurement library optimised for size can remove the property a
|
||||
finding is about.** `M3` is "the Art column renders a 1500×1500
|
||||
original into a 24 px box". The bulk library's covers are 300×300 and
|
||||
3.7 kB, because generating 50 000 realistic covers made a 2 GB
|
||||
library and a smooth gradient made a 466 MB one. So the bytes saved
|
||||
here are 3.7 kB → 1.1 kB and prove nothing. The number that is not
|
||||
hostage to the fixture is **which tier was requested** — 26 of 26
|
||||
originals before, 0 after, true on any library. When the rig cannot
|
||||
show the magnitude, measure the mechanism.
|
||||
- **An audit's arithmetic is a hypothesis too.** `M4` predicts 250 000
|
||||
comparisons per scroll frame from 5 000 albums × ~50 visible cards.
|
||||
Measured: 24 visible cards, and the scan breaks on its first match,
|
||||
so it costs **1.46 ms per frame** — real, 146× improvable, and far
|
||||
below the long-task threshold, so it moves no user-visible number
|
||||
today. Worth fixing because it stops scaling with the library, not
|
||||
because anything was stuttering. Say which of those two it is.
|
||||
|
||||
One operational trap that cost a cycle and is now in the skill:
|
||||
**`make e2e` needs `SEED=default`.** Run against the bulk seed left
|
||||
over from a measurement session, 13 of 36 specs fail on fixture
|
||||
content — unicode tracks, fixture artists, the seeded playback file —
|
||||
and the failure list reads exactly like a regression in the change you
|
||||
are holding.
|
||||
|
||||
## A virtualizer repaints on its own properties, and the sloppy thing doing that may be load-bearing
|
||||
|
||||
Plan 007 phase 4, item 8 (`M5`) and part of the tail (`p3`, `m1`, `m7`,
|
||||
`p4`). One large fix, three tail items settled, one audit
|
||||
recommendation rejected as a bug, and a broken feature that no audit
|
||||
had noticed.
|
||||
|
||||
The mechanism under most of it is one sentence: **`<lit-virtualizer>`'s
|
||||
rows are rendered by the `virtualize` directive, and that directive
|
||||
runs when one of the *virtualizer's own* properties changes — not when
|
||||
its parent re-renders.** Everything below follows from that.
|
||||
|
||||
Seven things worth keeping:
|
||||
|
||||
- **Memoising `items` and hoisting `renderItem` together is how you
|
||||
build a list that never repaints.** Virtualizing the playlist views
|
||||
needed both (that is the point), and selection went silently dead:
|
||||
the controller held exactly the right keys and no row ever showed
|
||||
one. Nothing failed — 447 component tests, 36 e2e specs and every
|
||||
Go test stayed green. A click in the real app found it in ten
|
||||
seconds. The fix is what `track-list` has always done and nobody had
|
||||
written down: push `virtualizer.requestUpdate()` on a selection
|
||||
change and on a playing-track change.
|
||||
- **The same fact makes `perf.m1` a regression.** It asks for
|
||||
`artists-view` and `genres-view` to hoist their per-render arrow
|
||||
functions to stable fields "as `cover-grid` already does". That fresh
|
||||
closure is the only thing changing a virtualizer property on a host
|
||||
update, i.e. the only thing repainting the cards. Measured in the
|
||||
running app: 1 highlighted card before the change, 0 after, both
|
||||
views. There is no compensating win — the host mostly re-renders
|
||||
*because* card state changed — so the closures stay, and
|
||||
`card-grid-repaint.test.ts` fails on the change and exists for no
|
||||
other reason. **An audit's suggested fix is a hypothesis too**, and
|
||||
this is the first one in this phase that was actively harmful rather
|
||||
than merely wrong about magnitude.
|
||||
- **Two of `M5`'s four stated mechanisms did not survive
|
||||
measurement.** "lit removes and re-adds 10 000 listeners per pass" is
|
||||
false on any build: instrumenting `EventTarget.prototype` recorded
|
||||
**zero** add/remove calls per pass, because lit-html's `EventPart` is
|
||||
itself the listener (`handleEvent`) and a changed listener value
|
||||
updates a field rather than the DOM. And one update pass cost 5.3 ms,
|
||||
not a stall. What was real, and worse than predicted, was elements
|
||||
retained: **22 090** for a 2 000-track playlist against the audit's
|
||||
16 000, and 2 000 eager cover requests. Fixing the two real halves
|
||||
gives 487 elements and 0.
|
||||
- **The suggested fix would have cost two features.** "Render these
|
||||
through `<track-list>` the way `genre-details` does" holds for
|
||||
`genre-details` because a genre list is just tracks. Both playlist
|
||||
views render phantom rows for missing files, and `playlist-details`
|
||||
is a drag source and a drop target; `track-list` has never had
|
||||
either. Virtualizing in place got the same 45× on elements with none
|
||||
of the risk, and left `track-list` alone for its four other callers.
|
||||
Check what the reference implementation *does not* do before adopting
|
||||
it.
|
||||
- **A row inside a virtualizer needs `width: 100%`.** The virtualizer
|
||||
positions children absolutely, so a grid row shrinks to fit its
|
||||
content: the columns silently stopped lining up with the header above
|
||||
them. Caught by reading the screenshot, not by any assertion — the
|
||||
second time in this phase that a PNG found what the suite could not.
|
||||
- **A write with a `RETURNING` clause is still a write.**
|
||||
`CreateSmartPlaylist` issued its `INSERT ... RETURNING` through
|
||||
`DB.QueryContext`, which routes to the query-only read pool, and
|
||||
failed with "attempt to write a readonly database (8)" — so **no
|
||||
smart playlist could be created at all**, in any real build. It was
|
||||
invisible because `NewTestDB` shares one in-memory connection and
|
||||
leaves `readDB` nil, so `reader()` hands back the *writer* under test:
|
||||
every unit test of that path exercised a handle production does not
|
||||
have. `TestNoWritesOnTheReadPool` now walks the tree for the class,
|
||||
watched failing on the bug first. A test double that collapses two
|
||||
handles into one cannot see a bug about which handle you used.
|
||||
- **`p3` is right about one store and wrong about the other.**
|
||||
Coalescing `search-store`'s notify to a microtask makes a subscriber
|
||||
that unsubscribes synchronously after a `setTerm` miss the
|
||||
notification entirely — a semantic change, and one an existing test
|
||||
had already pinned deliberately. `playlist-store` took the fix; the
|
||||
keystroke store did not. "Make these five consistent" is a fine
|
||||
instinct and a bad rule when one of them is on a different path.
|
||||
|
||||
And two operational notes, both now in the skill:
|
||||
|
||||
- **A frontend edit is not live until the app restarts.** Vite HMR
|
||||
updates the module, but an already-registered custom element class
|
||||
cannot be re-registered, so the running page keeps the old one — the
|
||||
edit reads as having done nothing. Worse, a *build error* leaves the
|
||||
dev server serving the last good bundle, silently: a stray backtick
|
||||
inside a comment in a `css` tagged template literal ended the literal,
|
||||
esbuild failed, and the page kept rendering the previous CSS while
|
||||
`make dev-headless` printed nothing about it.
|
||||
- **`tsc --noEmit` is in CI and was not in the documented gate.** The
|
||||
previous pass left the tree failing it, under a fully green
|
||||
`make lint && make test && make ui-test && make e2e` — none of which
|
||||
typechecks `frontend/test/`.
|
||||
|
||||
## An audit's magnitude and its mechanism are two claims, and the fix is a third
|
||||
|
||||
Plan 007 phase 4, fifth pass: the `track-details` chunk split and `m6`.
|
||||
Two items, both landed, and the pass's one useful generalisation is
|
||||
that a finding is really *three* hypotheses — how big it is, why it is
|
||||
that big, and what to do about it — which can be independently right
|
||||
and wrong.
|
||||
|
||||
`perf.m6` got the first right, the second wrong, and the third half
|
||||
wrong:
|
||||
|
||||
- **Right about size.** "Select all → Edit tags at 50 000 tracks will
|
||||
hang the renderer." Measured through the real opener: **3.0–6.3 s**
|
||||
of blocked main thread, varying that much run to run on one build.
|
||||
It is the largest single stall this phase has found, and it was in
|
||||
the *minor* tier of the audit.
|
||||
- **Wrong about why.** The audit calls it O(selection × total) —
|
||||
2.5 × 10⁹ comparisons. It is not: select-all hands the opener its
|
||||
keys *in list order*, so each `find` matches at index *i* and the
|
||||
real cost is N²/2, quadratic in the **selection**. That matters for
|
||||
what it predicts about everything else: the audit's formula says a
|
||||
ten-track selection costs 500 000 comparisons (it costs about 50),
|
||||
and says nothing about the genuine worst case, which is a selection
|
||||
built from the *bottom* of the list.
|
||||
- **Half wrong about the fix.** "Keep an index-ordered selection, and
|
||||
build a `Map<FilePath, Track>` for the batch lookup." The map is the
|
||||
entire 50× (**3 051–6 298 ms → 68 ms**), and it is now
|
||||
`utils/track-index.ts`, a `WeakMap` keyed on the array's identity —
|
||||
the invalidation signal this app already relies on everywhere else.
|
||||
The index-ordered selection is the unsafe half: an index goes stale
|
||||
on any re-sort, re-filter or refetch while a file path survives all
|
||||
three, which is exactly why `retain()` drops `lastSelectedIndex` and
|
||||
keeps the keys. The helper it would have replaced measures **3 ms**.
|
||||
Three milliseconds does not buy a silently mis-ordered queue insert.
|
||||
|
||||
That is the second audit recommendation in two passes that would have
|
||||
shipped a bug, after `m1`. Both times the reason was the same: the
|
||||
audit reasoned from the shape of the code and not from what the rest of
|
||||
the file already knew about it.
|
||||
|
||||
Five more things worth keeping:
|
||||
|
||||
- **A `longtask` entry arrives after the task that produced it.** The
|
||||
new measurement's first run reported `blocking: 0 ms` next to a
|
||||
six-second wall time, because it read the buffer synchronously after
|
||||
the await. Sixth variant of this phase's most-repeated trap, and the
|
||||
first one caught by *another number in the same row* contradicting
|
||||
it rather than by suspicion. Two numbers that must agree are worth
|
||||
more than one number you have to be sceptical about.
|
||||
- **The first load after a rebuild is not a measurement of first
|
||||
load.** FCP read 96–112 ms on every run taken immediately after
|
||||
`make dev-headless`, and 28–32 ms on the very next run of the same
|
||||
build. A cold Vite module graph, not variance. The plan had been
|
||||
describing this as "±100 ms run to run" for three passes without
|
||||
naming it.
|
||||
- **Measurement labels are a flat namespace; audit IDs are case
|
||||
sensitive.** `before-m6`/`after-m6` already existed — the *second*
|
||||
pass's capital `M6`, an unrelated finding about a 3 s ticker. Naming
|
||||
a baseline after a finding would have overwritten two of them.
|
||||
- **An unreachable code path still costs bundle size, and “dead code”
|
||||
can mean “missing feature”.** `cover-grid` is one of the five
|
||||
components that opened `track-details`, and it cannot: its album
|
||||
dropdown is rendered by `renderSplitGrid`, which `connectedCallback`
|
||||
references only to satisfy `noUnusedLocals` and which is, by its own
|
||||
comment, never invoked. Expanding an album fetches its ten tracks and
|
||||
draws nothing. The audit files this as `perf.p2`, "an unreferenced
|
||||
`renderSplitGrid`", under housekeeping. It is a whole interaction
|
||||
that does not exist, and it was only visible from trying to use it.
|
||||
- **What keeps a chunk out of a bundle is the absence of an import,
|
||||
which nothing notices.** Five static imports were what put
|
||||
`track-details`'s 42 kB before first paint; adding one back costs
|
||||
nothing anybody would see, because the chunk is also warmed on idle
|
||||
and the dialog carries on working. `lazy-track-details.test.ts` reads
|
||||
the five sources and fails on a returning import — the same shape as
|
||||
`TestNoDirectRuntimeEmits`, and for the same reason: the invariant is
|
||||
about what the code *does not* say.
|
||||
|
||||
## A finding's magnitude is measured where the work runs, not where it is written
|
||||
|
||||
Plan 007 phase 4, sixth pass: `m5`, `m4`, `m2` — the end of the tail,
|
||||
and the phase. Three items, one of which was measured and then
|
||||
*dropped*, which is the outcome the discipline exists to allow.
|
||||
|
||||
The generalisation the pass added to the previous one's "an audit's
|
||||
magnitude and its mechanism are two claims": **a mechanism can be
|
||||
exactly as described and still cost nothing, because the cost depends
|
||||
on state the reading cannot see.** `perf.m5` is right that
|
||||
`now-playing.updated()` interleaves layout reads with style writes on
|
||||
every pass, and right that the component updates while playing. It is
|
||||
wrong by two orders of magnitude, because a 1 Hz position report
|
||||
changes nothing that component renders — so the layout is clean when
|
||||
the reads happen and they cost 3 µs. The interleave only flushes when
|
||||
the DOM actually changed, measured at 0.103 ms, 34× more. The fix is
|
||||
still right (52 forced layouts over six seconds of playback became 2),
|
||||
but the number that justifies it had to be found by making the DOM
|
||||
dirty on purpose.
|
||||
|
||||
Seven things worth keeping:
|
||||
|
||||
- **A guard is only correct if it lists everything the measurement
|
||||
depends on, including things a CSS rule adds.**
|
||||
`.will-scroll .scroll-content` has `padding-right: 2em`, so applying
|
||||
the scroll class changes the distance the marquee has to travel:
|
||||
−128 px before the class, −158 px after it. The audit's "guard on the
|
||||
value/flag they already track" reads as "guard on the text", and a
|
||||
text-only guard would have left every first hover scrolling 30 px
|
||||
short — silently, with no test in any tier able to see it. That is
|
||||
the **third** audit recommendation in three passes that would have
|
||||
shipped a bug, after `m1` and `m6`, and all three failed the same
|
||||
way: reasoning from the shape of a function instead of from what the
|
||||
rest of the file already knows about it.
|
||||
- **Measuring is also how you decline to fix something.** The same
|
||||
finding names `artists-view` and `genres-view`, which do one
|
||||
`querySelector` and two `style.setProperty` per pass and **no layout
|
||||
read at all** — 0.0033 ms, one percent of their own update pass. They
|
||||
are the two files `perf.m1` was rejected in, where a guard risks
|
||||
stopping the virtualizer seeing a changed property. Three
|
||||
microseconds does not buy that risk, and "measured, declined" is a
|
||||
better record than a silent omission.
|
||||
- **A finding can be half-fixed by a phase that was not about it.**
|
||||
`m4` describes two components registering document `mousemove` in
|
||||
`connectedCallback` "for the process lifetime". Phase 1 had already
|
||||
moved `track-list`'s onto `listenWhileActive`, so half the finding
|
||||
described a build a year of work had passed. Check the line the audit
|
||||
cites still says what it said.
|
||||
- **An N+1 finding is usually also an N-bytes finding, and the audit's
|
||||
fix may only address the N.** All three `m2` sites want `FilePath`
|
||||
and ask for whole track rows to get it: five genres cost **6 MB over
|
||||
the IPC**, which the suggested `GetTracksByGenres([]string)` would
|
||||
have preserved exactly while removing four round trips. Returning
|
||||
paths made it 1.29 MB. Ask what the caller does with the answer
|
||||
before batching the question.
|
||||
- **Return grouped, not flattened, when the caller owns the order.**
|
||||
An album list is sorted by name and a genre selection by click order;
|
||||
a flattened result would have reordered a queue silently. The new
|
||||
bindings return `map[int64][]string` / `map[string][]string`, which
|
||||
also serves the drag cache — a fourth N+1 site the audit does not
|
||||
name, and the one that fires most, since it warms on every selection
|
||||
change rather than on a menu action.
|
||||
- **`make generate` was emitting TypeScript that does not parse.**
|
||||
`genevents` prefixed only the *first* line of a const block's doc
|
||||
comment with `//`; Phase 4's first pass gave `events.go` two
|
||||
multi-paragraph comments; so regenerating `frontend/src/events.ts`
|
||||
wrote bare prose into an object literal. It is a pre-commit hook, so
|
||||
the failure was waiting for whoever next touched a `.sql`, a `.templ`
|
||||
or an event constant. Nothing caught it because nobody had run the
|
||||
generator since the comments were written. **A generator is only
|
||||
verified by running it**, and a hook that regenerates is a hook that
|
||||
can break a clean tree.
|
||||
- **The `wailsjs` delta is 13 lines across *two* files**, both
|
||||
`autotagservice/Service.*`, not five as three sessions of notes have
|
||||
said. It is 25 across four now, the extra 12 being this pass's two
|
||||
library bindings.
|
||||
|
||||
And three on measuring, all of which produced a wrong number first:
|
||||
|
||||
- **"First run cold, second warm" is not a rule.** First contentful
|
||||
paint read 100 then 96 on one build this pass, and 28 then 76 on
|
||||
another — the second run warmer in neither. FCP varies ±50 ms here
|
||||
for reasons the harness does not control. The honest response is to
|
||||
report it as unattributable, not to take a third run until it agrees.
|
||||
- **A confirming run against the wrong seed looks like a result.** A
|
||||
re-run taken straight after `make e2e` measured the *default*
|
||||
library, because `make e2e` needs `SEED=default` and the app was
|
||||
still on it: "Play 20 albums" went from a number to a dash and the
|
||||
artist's bytes fell 40×. Plausible in shape, meaningless. The tell
|
||||
was a row that stopped having a value at all.
|
||||
- **Selection highlighting read from an inactive view measures Phase 1,
|
||||
not a repaint bug.** Driving `artists-view` after navigating with a
|
||||
raw `navigate` event showed the controller holding one selected
|
||||
artist and zero highlighted cards — the exact signature of the
|
||||
virtualizer hazard, and entirely an artifact: `viewActive` was
|
||||
`false` and an off-screen view does not render. Through a real
|
||||
sidebar click: one highlighted card, `aria-selected="true"` on the
|
||||
right one, in both card grids. Check `viewActive` before believing a
|
||||
view did not repaint.
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
# Frontend accessibility & interaction-model audit — YellowJacket
|
||||
|
||||
Scope: `frontend/src/components/**`, `frontend/src/services/keyboard-shortcut-service.ts`,
|
||||
`frontend/index.html`, `frontend/index.ts`, `frontend/index.css`, `frontend/src/styles/tokens.css.ts`.
|
||||
Read-only; nothing was changed.
|
||||
|
||||
Already confirmed by hand and **not** re-reported: track rows / sidebar `<li>` not focusable,
|
||||
14 tab stops app-wide, closed queue panel still focusable, global Space/arrow/S/N/P hijack,
|
||||
`data-shortcut-scope` never set. Adjacent consequences of those are marked *(adjacent)*.
|
||||
|
||||
---
|
||||
|
||||
## Critical
|
||||
|
||||
**1. `frontend/src/components/config-page/config-section.ts:98-104` — the entire Settings page is unreachable by keyboard**
|
||||
The disclosure header is a bare `<div class="header" @click=${this.toggle}>` with no `<button>`,
|
||||
no `tabindex`, no `role`, no `aria-expanded`, no `aria-controls`. Sections default to
|
||||
`expanded = false` (line 84/88), so every setting in the app is behind a control that cannot be
|
||||
tabbed to or activated.
|
||||
*Symptom:* a keyboard or screen-reader user can open Settings and see nothing but collapsed
|
||||
headings they can never expand.
|
||||
*Fix:* make the header a `<button type="button" aria-expanded=${this.expanded} aria-controls="body">`
|
||||
and give the body an `id`.
|
||||
|
||||
**2. `frontend/src/components/downloads-view/downloads-view.ts:258-271` — tab switching is mouse-only and has no tab semantics**
|
||||
`<div class="tabs">` containing two `<div class="tab" @click>`; no `role="tablist"`/`role="tab"`,
|
||||
no `aria-selected`, no `tabindex`, no arrow-key handling, no `aria-controls` on the panel.
|
||||
*Symptom:* the Downloads tab of the Downloads view can never be reached without a mouse; AT
|
||||
announces two unlabelled generic containers.
|
||||
*Fix:* `role="tablist"` on the wrapper, `<button role="tab" aria-selected=... aria-controls=...>`
|
||||
per tab with roving tabindex.
|
||||
|
||||
**3. `frontend/src/components/track-list/track-list.ts:1967`, `frontend/src/components/queue-panel/queue-panel.ts:1543`, `frontend/src/components/cover-grid/cover-grid.ts` — context menus have no menu semantics, no focus, no keyboard**
|
||||
`<div class="context-menu-panel">` holds `wa-dropdown-item`s inside a raw `<wa-popup>`. The items do
|
||||
carry `role="menuitem"` (Web Awesome sets it — verified in
|
||||
`node_modules/@awesome.me/webawesome/dist/chunks/chunk.MCDD6PFW.js`), but the container has no
|
||||
`role="menu"`, so the menuitems are orphaned. Because they are in a bare `wa-popup` rather than a
|
||||
`wa-dropdown`, nothing moves focus into the menu, nothing handles Up/Down/Escape, and nothing
|
||||
restores focus on close. The menu only opens on `contextmenu` (mouse right-click); there is no
|
||||
Shift+F10 / Menu-key path.
|
||||
*Symptom:* Play, Add to Queue, Play Next, Add to Playlist, Favourite and Track Details are
|
||||
completely unavailable without a mouse — this is the only path to most of those actions.
|
||||
*Fix:* wrap in `role="menu"`, open on `keydown` Shift+F10/ContextMenu, focus the first item, handle
|
||||
Arrow/Escape/Tab, restore focus to the originating row on close.
|
||||
|
||||
**4. `frontend/src/components/autotag-view/autotag-view.ts:2824-2950` — four hand-rolled modal dialogs with no dialog semantics, no focus trap, no focus restore**
|
||||
`renderPasteDialog` (2824), `renderWarningDialog` (2856), `renderLeaveDialog` (2891),
|
||||
`renderSearchDialog` (2922) each render `<div class="dialog-overlay"><div class="dialog">` with no
|
||||
`role="dialog"`, no `aria-modal="true"`, no `aria-labelledby` pointing at the `<h3>`, and no focus
|
||||
management. Only the paste and search dialogs set `autofocus`; the Warning and Leave dialogs — the
|
||||
two that gate an **irreversible on-disk metadata rewrite** — leave focus wherever it was.
|
||||
*Symptom:* a screen-reader user is never told a dialog opened, can Tab straight out of it into the
|
||||
page behind, and can confirm "this rewrites audio files" without ever hearing the warning.
|
||||
*Fix:* use `<wa-dialog>` (which already does `showModal()` + activeElement restore — see
|
||||
`chunk.ZUIYLL2X.js`), or add role/aria-modal/labelledby + a Tab trap + focus save/restore.
|
||||
|
||||
**5. `frontend/src/components/autotag-view/autotag-view.ts:1706-1746` — bare single-letter shortcuts on `document`, including a destructive one, with an incomplete guard**
|
||||
`A` = Apply (rewrites tags on every track on disk, explicitly "not automatically reversible" per the
|
||||
warning copy at 2866-2872), `S` = Skip, `L` = Leave as-is, `U`/`F` = dialogs. The suppression check
|
||||
at 1707-1712 only tests `tagName === 'INPUT' | 'TEXTAREA' | isContentEditable`. Events originating
|
||||
inside a Web Awesome control's shadow DOM are retargeted to the host (`WA-SELECT`, `WA-INPUT`,
|
||||
`YJ-COMBOBOX`), so the guard passes and `A` fires while the user is typing. Buttons, checkboxes and
|
||||
`<select>` are likewise unguarded — pressing `S` on a focused `<select>` triggers Skip *and* jumps
|
||||
the option list.
|
||||
*Symptom:* typing an artist name into a Web Awesome field, or type-ahead on a select, silently
|
||||
rewrites metadata on an entire album.
|
||||
*Fix:* reuse `isTextInputFocused` from `keyboard-shortcut-service.ts` (which resolves through shadow
|
||||
roots via `getDeepActiveElement`) and require a confirm/modifier for `A`.
|
||||
|
||||
**6. `frontend/src/components/search-bar/search-bar.ts:166-174` and `frontend/src/components/explore-view/explore-view.ts:1317-1323` — clear buttons have no accessible name at all**
|
||||
Both are `<button class="clear-button">` containing only `<wa-icon name="xmark">`. No `aria-label`,
|
||||
no `title`, no text. (A systematic scan of every `<button>` in `components/**` found these two as the
|
||||
only truly unnamed controls; the rest have text or at least a `title` fallback.)
|
||||
*Symptom:* announced as "button" with no name; unusable via voice control.
|
||||
*Fix:* `aria-label="Clear search"`.
|
||||
|
||||
**7. `frontend/src/components/top-results-row/top-results-row.ts:267` — result cards are click-only divs**
|
||||
`<div class="card" @click=${() => this.handleClick(r)}>` — the only `role`/`tabindex`/`keydown`-free
|
||||
card renderer in the codebase (every other card view added at least `role="button" tabindex="0"`).
|
||||
*Symptom:* the top-results row on the Explore page cannot be activated by keyboard.
|
||||
*Fix:* `role="button" tabindex="0"` + Enter/Space handler, matching `home-view.ts:305-309`.
|
||||
|
||||
**8. `frontend/index.html:34` + `frontend/index.ts:263-275` — queue toggle has no state, and the closed panel is not inert** *(adjacent)*
|
||||
The button carries `aria-label="Toggle queue"` but never `aria-expanded` or `aria-controls`. The
|
||||
toggle just adds/removes the `open` attribute; the closed state is purely
|
||||
`:host { width: 0; overflow: hidden }` (`queue-panel.ts:214-217`), which hides nothing from the
|
||||
accessibility tree.
|
||||
*Symptom:* the button never reports open/closed, and a screen-reader's virtual cursor walks the
|
||||
entire queue (title, artist, remove button for every track) while the panel is visually closed.
|
||||
This is the same root cause as the already-confirmed "closed queue panel is still focusable".
|
||||
*Fix:* set `aria-expanded`/`aria-controls` on the button and `inert` (or `aria-hidden="true"` plus
|
||||
`visibility: hidden`) on the panel when closed.
|
||||
|
||||
---
|
||||
|
||||
## Major
|
||||
|
||||
**9. `frontend/src/components/track-list/track-list.ts:1906-1926` — column headers are not headers and never expose sort state**
|
||||
`<div class="header-row">` with `<div class="header-cell" @click>` per column. No `role="grid"`/
|
||||
`row`/`columnheader`, no `aria-sort`, no `tabindex`, no keydown. The sort direction is conveyed only
|
||||
by a `▲`/`▼` glyph in a `<span class="sort-arrow">` at 10px (`track-list.ts:900-901`).
|
||||
*Symptom:* AT cannot tell which column the list is sorted by or in which direction, and clicking a
|
||||
header to sort is mouse-only. (There is a redundant keyboard-reachable sort dropdown at 1806-1841,
|
||||
so this is not a total loss of function.)
|
||||
*Fix:* `role="columnheader" aria-sort=${'ascending'|'descending'|'none'}` on each header cell and
|
||||
make it a `<button>`.
|
||||
|
||||
**10. `frontend/src/components/track-list/track-list.ts:1746-1755` — the per-row favourite toggle is an unlabelled, unfocusable div**
|
||||
`<div class=${classMap({'fav-icon': true, favorited: isFav})}>` with an inline `<svg>` and
|
||||
`cursor: pointer` (`track-list.ts:1034-1043`); the click is delegated off the virtualizer. No
|
||||
`role`, no `tabindex`, no accessible name, no `aria-pressed`.
|
||||
*Symptom:* favouriting a track from the list is mouse-only, and the current favourite state of every
|
||||
row is invisible to AT (heart/star fill is a shape-and-colour change with no text equivalent).
|
||||
*Fix:* `<button role="switch" aria-checked=${isFav} aria-label="Favourite ${track.TrackName}">`.
|
||||
|
||||
**11. `frontend/src/components/queue-panel/queue-panel.ts:1417` + `cover-grid.ts:1798`, `album-dropdown.ts:385`, `app-sidebar.ts:222-232` — drag-and-drop has no keyboard equivalent anywhere**
|
||||
Queue reordering (`draggable="true"` on `.track-item`, drop index computed from cursor Y at
|
||||
`queue-panel.ts:1093-1140`), album→queue/playlist drag, expanded-album track drag, and drop-on-nav-item
|
||||
are all pointer-only. There is no Alt+Up/Down reorder, no "move to…" command, and no `aria-grabbed`/
|
||||
`aria-dropeffect` substitute.
|
||||
*Symptom:* queue order can never be changed without a mouse. Combined with finding 3 (the context
|
||||
menu is mouse-only too), there is **no** keyboard path to add a track to the queue or a playlist.
|
||||
*Fix:* add Alt+ArrowUp/Down reorder on the focused queue item, and expose the drag targets as
|
||||
context-menu commands once the menu is keyboard-reachable.
|
||||
|
||||
**12. No `aria-live` region anywhere for async status — scan/job progress, toasts, search results, now-playing**
|
||||
A repo-wide grep finds exactly one live region: `catalog-scope-notice.ts:110` (`role="status"`), and
|
||||
even that is conditionally rendered *with* its content already present, which most ATs do not
|
||||
announce. Specific gaps:
|
||||
- `frontend/src/components/config-page/config-page.ts:2137-2139` — `<div class="toast">` with no
|
||||
`role="status"`/`aria-live`; it is the only feedback that a setting saved or failed, and it
|
||||
auto-dismisses after a timer (1174-1176).
|
||||
- `frontend/src/components/jobs/job-indicator.ts:359-370` — the trigger label swings between
|
||||
"Scanning Music", "3 background jobs" and "Finished" with no live region.
|
||||
- `frontend/src/components/now-playing/now-playing.ts:340-357` — track title/artist change on every
|
||||
auto-advance with no announcement.
|
||||
- `frontend/src/components/explore-view/explore-view.ts:1270-1278` — "Searching…" and the error
|
||||
block are silent.
|
||||
- `frontend/src/components/track-list/track-list.ts:1901`, `1930-1933` — "Loading tracks…" /
|
||||
"No tracks match your search." with no `aria-live` and no `aria-busy` on the list.
|
||||
*Symptom:* a screen-reader user gets no feedback that a scan started or finished, that a setting
|
||||
saved, that a search returned nothing, or that the track changed.
|
||||
*Fix:* one `<div role="status" aria-live="polite" class="sr-only">` per surface, populated after the
|
||||
region already exists in the DOM.
|
||||
|
||||
**13. `frontend/src/components/artists-view/artists-view.ts:1059-1063` and `frontend/src/components/genres-view/genres-view.ts:947-951` — `aria-selected` on `role="button"` is invalid and dropped**
|
||||
Both cards render `role="button" aria-selected="${isSelected}"`. `aria-selected` is only valid on
|
||||
`gridcell`, `option`, `row`, `tab` and `treeitem`; on `button` it is ignored outright. These grids
|
||||
are genuinely multi-select (ctrl/shift-click via `SelectionController`).
|
||||
*Symptom:* selection state — the thing the whole ctrl/shift interaction exists to produce — is
|
||||
invisible to AT; visually it is a background-colour change only.
|
||||
*Fix:* `role="listbox" aria-multiselectable="true"` on the grid, `role="option" aria-selected` on
|
||||
the cards.
|
||||
|
||||
**14. `frontend/src/components/combobox/combobox.ts:288-303` — combobox has no `aria-controls` / `aria-activedescendant`**
|
||||
`role="combobox" aria-expanded aria-autocomplete="list"` on the input, `role="listbox"` on the `<ul>`,
|
||||
`role="option"` on the `<li>`s — but no `id` on the listbox, no `aria-controls`, no
|
||||
`aria-activedescendant`, and no `id` on the options. `aria-selected` is used to mean "highlighted"
|
||||
(302), not "chosen".
|
||||
*Symptom:* arrowing through suggestions moves the visual highlight but announces nothing; the user
|
||||
hears only their own typing.
|
||||
*Fix:* give the listbox and each option an `id`, add `aria-controls` and
|
||||
`aria-activedescendant=${optionId(highlightedIndex)}`.
|
||||
|
||||
**15. `frontend/src/components/now-playing/now-playing.ts:203-212, 391-408` — marquee text auto-scrolls with no reduced-motion guard and no pause**
|
||||
`transition: transform var(--scroll-duration, 5s) linear` re-armed in a loop by
|
||||
`onScrollCycleEnd`; when `scrollMode === 'always'` (persisted in localStorage, line 388-395) the
|
||||
title and artist scroll continuously for as long as the track plays. Only four files in the repo
|
||||
have a `prefers-reduced-motion` guard (`job-indicator.ts:126`, `job-row.ts:154`,
|
||||
`autotag-view.ts:471,599`) and this is not one of them.
|
||||
*Symptom:* WCAG 2.2.2 — moving content longer than 5s with no mechanism to pause it, and a
|
||||
vestibular-trigger risk with no reduced-motion opt-out.
|
||||
*Fix:* `@media (prefers-reduced-motion: reduce) { .scroll-content { transition: none } }` and treat
|
||||
`always` as `never` under that query.
|
||||
|
||||
**16. `frontend/src/components/config-page/config-page.ts:2091-2131` — the "Remove Library" confirmation is not a dialog**
|
||||
`<div class="cancel-dialog-overlay">` / `<div class="cancel-dialog">` with a
|
||||
`<div class="cancel-dialog-title">` — no `role="dialog"`, no `aria-modal`, no `aria-labelledby`, no
|
||||
focus move, no focus trap, no Escape handler, no focus restore. This gates deleting tracks,
|
||||
playlists and queue entries.
|
||||
*Symptom:* the destructive confirmation is never announced and can be Tab-escaped.
|
||||
*Fix:* same as finding 4 — `wa-dialog`, or role + trap + restore.
|
||||
|
||||
**17. `frontend/src/components/jobs/job-indicator.ts:378` — `role="dialog"` on an unmanaged popover**
|
||||
The panel declares `role="dialog"` (and the trigger `aria-haspopup="dialog"`, line 362) but nothing
|
||||
moves focus into it, traps Tab, handles Escape, or restores focus. It is a non-modal popover, not a
|
||||
dialog.
|
||||
*Symptom:* AT announces a dialog that never receives focus and cannot be dismissed by keyboard;
|
||||
tabbing past the trigger lands in the page behind while the panel is open.
|
||||
*Fix:* drop `role="dialog"` (use `role="group" aria-label="Background jobs"` and
|
||||
`aria-haspopup="true"`), or implement real dialog behaviour.
|
||||
|
||||
**18. `frontend/src/components/explore-view/explore-view.ts:1289-1305` — search-mode "tabs" convey the active mode by colour class only**
|
||||
`<button class="search-mode-tab ${this.searchMode === 'catalog' ? 'active' : ''}">` — no
|
||||
`role="tab"`/`aria-selected`, no `aria-pressed`, no text or icon difference between active and
|
||||
inactive.
|
||||
*Symptom:* the user cannot tell whether they are searching the catalog or lyrics.
|
||||
*Fix:* `aria-pressed=${this.searchMode === 'catalog'}` (or a proper tablist).
|
||||
|
||||
---
|
||||
|
||||
## Minor
|
||||
|
||||
**19. `frontend/src/styles/tokens.css.ts:18-22` — the entire type scale is hardcoded px**
|
||||
`--yj-text-xs: 11px` … `--yj-text-xl: 18px`, consumed by essentially every component. Combined with
|
||||
~50 further literal `font-size: Npx` declarations (e.g. `job-indicator.ts:138` at **9px**,
|
||||
`explore-view.ts:518` at 10px, `track-list.ts:901` at 10px, and inline
|
||||
`style="font-size: 12px"` at `queue-panel.ts:1518` and `playlist-view.ts:1865`).
|
||||
*Symptom:* text-only resize (WCAG 1.4.4) does nothing — a user who raises their OS/browser font size
|
||||
sees no change. 9-11px body text is below any reasonable floor to begin with.
|
||||
*Fix:* express the scale in `rem` so it tracks the root font size.
|
||||
|
||||
**20. `frontend/src/components/track-list/track-list.ts:972-985` and `frontend/src/components/queue-panel/queue-panel.ts:164-166` — fixed row heights with `contain: strict`**
|
||||
`.track-row { height: 33px; contain: strict }` and the matching virtualizer `_itemSize`
|
||||
(`track-list.ts:222`, `queue-panel.ts:165`, 49px). `contain: strict` clips overflow rather than
|
||||
growing the row.
|
||||
*Symptom:* any increase in text size (finding 19, or a user stylesheet) clips row text mid-glyph
|
||||
instead of reflowing; the virtualizer's scroll math also desynchronises.
|
||||
*Fix:* out of scope for a quick change, but at minimum document that the type scale and `_itemSize`
|
||||
are coupled.
|
||||
|
||||
**21. `frontend/index.css:12-20` — the app shell is `height: 100vh; overflow: hidden`**
|
||||
`body { height: 100vh; grid-template: "top-bar top-bar" 4em ... "bottom-bar bottom-bar" 4em; overflow: hidden }`.
|
||||
*Symptom:* at high zoom the 4em bars grow while the viewport does not, and anything that no longer
|
||||
fits is clipped with no scrollbar — WCAG 1.4.10 Reflow. The bottom bar's
|
||||
`grid-template-columns: var(--now-playing-width, 200px) 1fr auto` keeps a fixed 200px column while
|
||||
its text scales.
|
||||
*Fix:* allow the shell to scroll (`min-height: 100vh` + `overflow: auto`) below a breakpoint.
|
||||
|
||||
**22. `frontend/src/components/track-list/track-list.ts:1000-1017` — "now playing" and "selected" rows are colour-only**
|
||||
`.track-row.active { background-color: var(--yj-accent-bg); color: var(--yj-accent) }` and
|
||||
`.track-row.selected { background-color: var(--yj-selection-bg) }`; the row markup
|
||||
(`track-list.ts:1736-1745`) carries no `aria-current`, `aria-selected` or non-colour marker.
|
||||
*Symptom:* WCAG 1.4.1 — a colour-blind user cannot distinguish the playing row, and AT has no signal
|
||||
at all. Same pattern in `queue-panel.ts:1406-1409`.
|
||||
*Fix:* add a ▶ marker (or the existing play icon) to the active row and `aria-current="true"` once
|
||||
rows carry `role="row"`.
|
||||
|
||||
**23. `frontend/src/components/jobs/job-indicator.ts:150-156, 369` — the failure indicator is a bare 6px red dot**
|
||||
`<span class="alert-dot">` with `background: #ff6b6b` and no text, `aria-label` or `title`; the
|
||||
trigger's own name (`title="Background jobs"`, 363) does not change when it appears.
|
||||
*Symptom:* "a background job failed" is communicated by colour alone and not at all to AT.
|
||||
*Fix:* `<span class="alert-dot" role="img" aria-label="A background job failed"></span>`.
|
||||
|
||||
**24. Ellipsis truncation without `title` in the highest-density lists**
|
||||
`text-overflow: ellipsis` appears in 40+ places. `cover-grid.ts:1821,1832` and `home-view.ts:308`
|
||||
do add `title`; these do not:
|
||||
- `frontend/src/components/queue-panel/queue-panel.ts:389,401` (`.track-title`, `.track-artist`)
|
||||
vs. the markup at 1422-1428 — no `title`.
|
||||
- `frontend/src/components/track-info/track-info.ts:92,100` vs. markup at 118-126.
|
||||
- `frontend/src/components/track-list/track-list.ts:1018-1022` (`.cell`) vs. `1782-1788`.
|
||||
- `frontend/src/components/playlist-view/playlist-view.ts:355,360`.
|
||||
*Symptom:* long titles are clipped with no way to read the full value — acute in the queue panel,
|
||||
whose width is user-resizable down to `MIN_WIDTH`.
|
||||
*Fix:* `title=${value}` on the truncating element.
|
||||
|
||||
**25. `frontend/src/components/jobs/job-row.ts:270-272` — progress bar has no accessible name**
|
||||
`<wa-progress-bar value=...>`; Web Awesome renders `role="progressbar"` + `aria-valuenow`
|
||||
(`chunk.WDFK5BNW.js:42,47`) but no label is supplied.
|
||||
*Symptom:* announced as an unnamed "progress bar, 45%" with no indication of what is progressing.
|
||||
*Fix:* `aria-label=${job.title}` (or WA's `label` attribute).
|
||||
|
||||
**26. `frontend/src/components/search-bar/search-bar.ts:157-163` and `explore-view.ts:1308-1314` — search inputs are labelled by placeholder only**
|
||||
No `aria-label`, no `<label>`, no `role="searchbox"`, no `aria-describedby` pointing at the result
|
||||
count.
|
||||
*Fix:* `aria-label="Search library"` / `"Search catalog"`.
|
||||
|
||||
**27. `frontend/src/components/sidebar/app-sidebar.ts:202-241` — nav list has no landmark or item role** *(adjacent)*
|
||||
`<ul>` of `<li>` with `aria-current` (219) but no `role`, so `aria-current` sits on a
|
||||
non-interactive item and the whole thing is not inside a `<nav>` (`frontend/index.html:22` is a
|
||||
plain `<div class="sidebar">`).
|
||||
*Fix:* `<nav aria-label="Main">` in `index.html` and make each item a `<button>`/`<a>` — which also
|
||||
resolves the already-confirmed focusability gap.
|
||||
|
||||
**28. Mouse-only resize handles with no keyboard equivalent**
|
||||
`app-sidebar.ts:200`, `queue-panel.ts:1447`, `now-playing.ts:377`, and the track-list column
|
||||
resizers at `track-list.ts:1945-1953` are all `@mousedown`-only `<div>`s with no `role="separator"`,
|
||||
`tabindex` or arrow-key handling.
|
||||
*Symptom:* panel and column widths cannot be adjusted without a mouse. Low impact (cosmetic
|
||||
preference), but the pattern repeats four times.
|
||||
|
||||
---
|
||||
|
||||
## Polish
|
||||
|
||||
**29. `frontend/index.html:14-16` — heading hierarchy skips h1 → h3**
|
||||
`<h1 class="title">` immediately followed by `<h3 class="subtitle">`, styled at `0.8em`
|
||||
(`index.css:52-55`) — using a heading level for type size.
|
||||
*Fix:* make the subtitle a `<p>`.
|
||||
|
||||
**30. `frontend/index.html` — no skip link**
|
||||
`<main id="main-content">` exists (line 26) but nothing links to it, so keyboard users traverse the
|
||||
top bar and sidebar on every navigation.
|
||||
*Fix:* add a visually-hidden `<a href="#main-content">Skip to content</a>` as the first body child.
|
||||
|
||||
**31. `frontend/src/components/cover-grid/cover-grid.ts:509` — `<img>` with no `alt`**
|
||||
The only `alt`-less `<img>` in the codebase (every other one is either descriptive or correctly
|
||||
`alt=""`).
|
||||
*Fix:* `alt=""` if decorative.
|
||||
|
||||
**32. `frontend/src/components/queue-panel/queue-panel.ts:1431-1437` — per-row remove button is named by `title` only, and the name is not unique**
|
||||
`title="Remove from queue"` on every row provides an accname fallback, but it never identifies
|
||||
*which* track and is invisible to touch users.
|
||||
*Fix:* `aria-label="Remove ${track.title} from queue"`.
|
||||
|
||||
**33. `frontend/src/components/cover-grid/cover-grid.ts:1793-1797` — every album card is `tabindex="0"`** *(adjacent)*
|
||||
`role="button" tabindex="0"` on each virtualised card means the tab sequence length equals the number
|
||||
of rendered cards, with no roving tabindex. This is the opposite failure mode to the confirmed
|
||||
"only 14 tab stops" finding and will surface as soon as the other views are made focusable.
|
||||
*Fix:* roving tabindex (one `tabindex="0"`, the rest `-1`) once the grid gets `role="listbox"` per
|
||||
finding 13.
|
||||
|
||||
**34. `frontend/src/components/track-list/track-list.ts:900-901` — 10px sort arrow**
|
||||
`font-size: 10px; /* intentionally sub-token: tiny sort indicator */` — the comment acknowledges it.
|
||||
Combined with finding 9 (no `aria-sort`), the sort direction is a 10px glyph or nothing.
|
||||
|
||||
---
|
||||
|
||||
## What is already correct
|
||||
|
||||
- **`frontend/src/components/audio-player/controls/player-controls.ts:121-148`** — every transport
|
||||
button has an `aria-label`, shuffle and repeat carry `aria-pressed`, and repeat's three-state mode
|
||||
is spelled into the label (`Repeat: one`) rather than left to the CSS class. This is the model the
|
||||
rest of the app should follow.
|
||||
- **`frontend/src/components/audio-player/seekbar/seek-bar.ts:160-168`** and
|
||||
**`volume-control.ts:198`** — `wa-slider` with `aria-label` and a `valueFormatter`, so the seek
|
||||
position is announced as `3:42` rather than `222`.
|
||||
- **All five `wa-dialog` usages are genuinely modal and restore focus** — `track-details.ts:735`,
|
||||
`duplicate-tracks-dialog.ts:278`, `download-picker.ts:180`, `phantom-resolver.ts:927`,
|
||||
`first-run-wizard.ts:170`. Web Awesome's dialog uses native `showModal()`, `lockBodyScrolling` and
|
||||
`activeElement` restore (`chunk.ZUIYLL2X.js`), and every one of them passes a `label`. The
|
||||
hand-rolled dialogs in findings 4 and 16 are the outliers, and both have a working component to
|
||||
migrate to.
|
||||
- **`frontend/src/components/explore-artist-details/explore-artist-details.ts:2152, 2178, 2201, 2327, 2457`**
|
||||
— every disclosure toggle is a real `<button>` with `aria-expanded`, and the CSS keys off the
|
||||
attribute (`:465, :520, :680`) rather than a duplicate class. This is exactly the pattern
|
||||
`config-section.ts` (finding 1) is missing.
|
||||
- **`keyboard-shortcut-service.ts:73-83, 106-121`** — `getDeepActiveElement` correctly walks the
|
||||
shadow-root chain and `isTextInputFocused` covers `contentEditable` and the empty-`type` input
|
||||
case. The suppression logic is sound; the problems the parent already found are in *what* it does
|
||||
with the result, not in the resolution itself. Finding 5 is the autotag view failing to reuse it.
|
||||
- **`library-status-indicator.ts:186-196`** — status is conveyed by three distinct icons *and* a
|
||||
full sentence in both `title` and `aria-label`, and `handleKeydown` (175-180) stops Enter/Space
|
||||
from double-firing on the wrapping card. Correct on every axis.
|
||||
|
||||
---
|
||||
|
||||
## Residual risks / not covered
|
||||
|
||||
- Colour-contrast ratios were not measured (no rendering); the token palette
|
||||
(`--yj-text-tertiary: #888` on `--yj-bg-surface: #212529` ≈ 4.1:1) is borderline for the 11-12px
|
||||
text it is most often paired with, but that needs a real measurement.
|
||||
- `templ`-rendered HTMX fragments in `backend/config/` were out of scope and are not audited.
|
||||
- WebKit2GTK-specific behaviour (whether Ctrl+= page zoom is even reachable in the Wails shell, and
|
||||
how Orca traverses lit-virtualizer's windowed DOM) can only be confirmed on a running app.
|
||||
@@ -0,0 +1,432 @@
|
||||
# Failure UX audit — YellowJacket
|
||||
|
||||
Scope: error handling, empty/loading states, destructive actions, and failure UX
|
||||
across the frontend/backend boundary. Read-only; nothing was changed.
|
||||
|
||||
Method: `backend/app.go`, every bound service in `FEBindings` (`backend/app.go:194-215`),
|
||||
the generated bindings under `frontend/wailsjs/go/**`, all 13 stores/controllers in
|
||||
`frontend/src/store/`, and every component in `frontend/src/components/` that calls a
|
||||
binding. Counts: 165 `catch` blocks in `frontend/src`, 84 of which end in
|
||||
`console.error`/`console.warn` and nothing else.
|
||||
|
||||
**Headline:** there is no application-level notification surface. Two components grew
|
||||
private, mutually-unaware toasts (`config-page.ts:1168`, `autotag-view.ts:1318`), and
|
||||
everything else logs to a console the user cannot open. The single most common failure
|
||||
in a music player — *this file will not play* — is one of the paths that reaches the
|
||||
user as complete silence.
|
||||
|
||||
---
|
||||
|
||||
## Critical
|
||||
|
||||
### C1. A track that fails to load or play is a silent no-op, forever
|
||||
**`backend/queue/queue.go:1181-1239`** (`loadCurrentTrack`, `playCurrentTrack`),
|
||||
reached from `Queue.Play/PlayIndex/Next/Previous/SetQueue`.
|
||||
|
||||
`LoadFile` or `Play` returning an error is logged and turns into `return false`; the
|
||||
caller reverts `currentIndex` (`queue.go:1069-1074`, `queue.go:920-925`) and returns.
|
||||
No event is emitted. Every Wails binding on the path returns `Promise<void>`
|
||||
(`frontend/wailsjs/go/queue/Queue.d.ts`) because the Go methods return nothing, so the
|
||||
frontend cannot even observe the failure — and `queue-store.ts:192-263` does not
|
||||
`await` or `.catch()` any of them regardless.
|
||||
|
||||
Symptom: double-click a track whose file was moved, is corrupt, or has an unsupported
|
||||
codec — nothing happens. No row highlight, no error, no skip. Double-click it again —
|
||||
still nothing. Mid-queue auto-advance onto a bad file stops playback dead with no
|
||||
explanation (`queue.go:920-925`), and pressing Next does nothing because Next hits the
|
||||
same bad track and reverts.
|
||||
|
||||
Fix: add a `PlaybackFailed` event carrying `{filePath, reason}`, emit it from
|
||||
`loadCurrentTrack`/`playCurrentTrack`, and have `Next`/auto-advance skip the failed
|
||||
track rather than reverting.
|
||||
|
||||
### C2. `SeekFailed` is emitted by the backend and nobody listens
|
||||
**`backend/player/player.go:776`** emits `events.SeekFailed`; **`frontend/src/events.ts:8`**
|
||||
declares it; there is no `EventsOn(Events.SeekFailed, ...)` anywhere in `frontend/src`
|
||||
(verified by grep — the only other hits are `events.go` and `emit_test.go`).
|
||||
|
||||
Symptom: dragging the seek bar on a track that has no loaded seeker snaps the thumb
|
||||
back to where it was, with no indication why.
|
||||
|
||||
Fix: subscribe in `player-store.ts` and surface it (revert the optimistic seek position
|
||||
plus a message), or delete the event so it stops implying coverage that does not exist.
|
||||
|
||||
### C3. Autotag apply writes to the user's files with no cancel, no undo, and no presence outside its own page
|
||||
**`backend/autotagservice/service.go:1078-1181`**, **`frontend/src/components/autotag-view/autotag-view.ts:1624-1662`**.
|
||||
|
||||
`ApplyAsync` spawns `go s.runApply(...)` which calls `s.applier.Apply(s.ctx, ...)` —
|
||||
it rewrites tags in place across a whole folder. There is:
|
||||
- no cancel (`grep 'jobs\.' backend/autotagservice/*.go` → nothing; it is not registered
|
||||
with the `jobs.Registry`, unlike scans, index builds and downloads),
|
||||
- no undo,
|
||||
- no visibility once the user leaves the autotag page — the progress lives entirely in
|
||||
`autotag-view`'s local `applyJobs` map (`autotag-view.ts:1180`), which is discarded on
|
||||
`disconnectedCallback` (`autotag-view.ts:1239`),
|
||||
- no drain on shutdown — `OnShutdown` (`backend/app.go:498-510`) saves player and queue
|
||||
state and returns; `OnBeforeClose` (`backend/app.go:461`) unconditionally returns
|
||||
`false`. Quitting mid-apply cancels `s.ctx` and leaves the folder half-retagged with
|
||||
nothing recording where it stopped.
|
||||
|
||||
Symptom: the user starts an apply, navigates away or quits, and comes back to a folder
|
||||
where some tracks carry the new tags and some the old, with no way to tell which.
|
||||
|
||||
Fix: register the apply with `jobs.Registry` (giving it the existing cancel/progress
|
||||
surface for free) and make `OnBeforeClose` return `true` while a file-writing job is in
|
||||
flight.
|
||||
|
||||
`backend/tagwriter/pipeline.go:286-360` (batch tag writes) has the same absence from
|
||||
the job registry, but is mitigated — see the note under **M8**.
|
||||
|
||||
### C4. `libraryStore` serves the previous library's data after a filter switch
|
||||
**`frontend/src/store/library-store.ts:339-343, 445-467, 128-152`**.
|
||||
|
||||
`setSelectedLibrary()` → `invalidate()` sets `this.tracks = null` and calls
|
||||
`eagerFetch()`. If the previous library's `GetAllTracksByLibrary` is still in flight,
|
||||
`getTracks()` sees `tracks === null && tracksLoading === true` and returns
|
||||
`waitForTracks()` (`library-store.ts:494`) — which waits for the *old* request. That
|
||||
request's `try` block then assigns `this.tracks = <library A's tracks>`
|
||||
(`library-store.ts:145`) and bumps `changeGen`, so the store is now caching A's tracks
|
||||
while `selectedLibraryIdValue` is B.
|
||||
|
||||
Symptom: switch the library filter twice quickly and the track/album/artist/genre lists
|
||||
show the wrong library's contents until the next scan or filter change.
|
||||
|
||||
Fix: stamp each fetch with a `fetchGen` captured at request time and drop the
|
||||
assignment when `fetchGen !== this.changeGen` (the same version-guard pattern
|
||||
`explore-view.ts:703/793/821` already uses correctly).
|
||||
|
||||
---
|
||||
|
||||
## Major
|
||||
|
||||
### M1. A failed library fetch hangs every waiter forever
|
||||
**`frontend/src/store/library-store.ts:128-152, 494-506`** (and the identical
|
||||
`waitForAlbums`/`waitForArtists`/`waitForGenres` at 508-548).
|
||||
|
||||
`getTracks()` rejects → `finally` sets `tracksLoading = false` and notifies → the
|
||||
`waitForTracks` subscriber tests `!this.tracksLoading && this.tracks !== null`, which is
|
||||
false because `tracks` is still `null` → the promise never settles and the subscription
|
||||
is never removed.
|
||||
|
||||
Symptom: any component that called `getTracks()` while another fetch was in flight
|
||||
hangs on an unresolved promise (permanent spinner) and leaks a store subscription.
|
||||
|
||||
Fix: give the four `waitFor*` helpers a reject path, or store the in-flight promise and
|
||||
return it instead of re-deriving it from subscriber notifications.
|
||||
|
||||
### M2. The track list conflates "empty", "loading" and "failed" into one permanent "Loading tracks…"
|
||||
**`frontend/src/components/track-list/track-list.ts:1901-1902`**:
|
||||
`this.tracks.length === 0 ? html\`<p>Loading tracks...</p>\``.
|
||||
`loadTracks()` (`track-list.ts:1242-1257`) `console.error`s on failure and leaves
|
||||
`this.tracks` at `[]`.
|
||||
|
||||
Symptom: three different situations render as an infinite "Loading tracks…" —
|
||||
a genuinely empty library, a backend query that failed, and a library filter with
|
||||
nothing in it. `genre-details.ts:194-198` makes it worse: on error it sets
|
||||
`this.tracks = []` and hands that to `<track-list>`, so a failed genre query is
|
||||
indistinguishable from a slow one.
|
||||
|
||||
Fix: track `loading`/`error` as separate state and render three distinct bodies —
|
||||
the `home-view.ts:263-280` `renderBody()` is the correct model already in this repo.
|
||||
|
||||
### M3. The Settings search-index panel says "Loading status…" forever
|
||||
**`frontend/src/components/config-page/config-page.ts:186, 195, 1016-1022, 1034, 1530`**.
|
||||
|
||||
`indexStatus` is only ever assigned from the `IndexStatusChanged` event listener, and
|
||||
that event is emitted from exactly one place — `backend/explore/searchindex.go:692`,
|
||||
inside `emitStatus()`, which only fires on build status *mutations*. `indexPollTimer`
|
||||
is declared (195) and cleared (1034) but **never assigned**. The pull binding
|
||||
`GetIndexStatus()` exists (`frontend/wailsjs/go/explore/Service.d.ts:42`) and is never
|
||||
called from `frontend/src`.
|
||||
|
||||
Symptom: open Settings when no index build is running — which is the steady state —
|
||||
and the Search Index section shows "Loading status…" indefinitely, even though the
|
||||
index is fully built.
|
||||
|
||||
Fix: call `GetIndexStatus()` in `connectedCallback` to seed `indexStatus` before the
|
||||
first event arrives.
|
||||
|
||||
### M4. Job pause/resume/cancel failures are unhandled promise rejections
|
||||
**`frontend/src/components/jobs/job-controls.ts:17-35`**, wired as
|
||||
`@job-control=${applyJobControl}` at `jobs-view.ts:330`,
|
||||
`job-details-drawer.ts:335`, `job-indicator.ts:397`.
|
||||
|
||||
`applyJobControl` is `async` and is used directly as a DOM event listener, so its
|
||||
returned promise is discarded. `jobStore.pause/resume/cancel/dismiss`
|
||||
(`job-store.ts:189-204`) `await` the binding with no `catch`.
|
||||
|
||||
Symptom: press Pause on a scan and, if the backend rejects, the button does nothing —
|
||||
no state change, no message. There is also no in-flight guard, so double-clicking
|
||||
Cancel issues two `CancelJob` calls.
|
||||
|
||||
Fix: wrap the switch in try/catch inside `applyJobControl` and surface the failure;
|
||||
disable the row's controls until the next `JobsChanged` snapshot arrives.
|
||||
|
||||
### M5. Scan / full-rescan buttons fail silently
|
||||
**`frontend/src/components/jobs/jobs-view.ts:276-282, 284-290, 296-314`**.
|
||||
|
||||
All three handlers `console.error` and return. `FullRescan` returns
|
||||
`errNoLibrariesConfigured` when no library is configured
|
||||
(`backend/library/rescan.go:33-35`), and — unlike "Scan all", which is disabled on
|
||||
`this.libraries.length === 0` (`jobs-view.ts:434`) — the Full rescan button is only
|
||||
disabled on `anyScanning` (`jobs-view.ts:470`).
|
||||
|
||||
Symptom: with no libraries configured, the user reads a scary confirmation, clicks
|
||||
"Full rescan", confirms, and absolutely nothing happens.
|
||||
|
||||
Also a double-click hazard: `anyScanning` is derived from `jobStore`, which is fed by
|
||||
`JobsChanged` events coalesced at 250 ms (`backend/events` / `jobs` registry). Two
|
||||
clicks inside that window both issue `ScanLibrary`.
|
||||
|
||||
Fix: surface the error, add `|| this.libraries.length === 0` to the Full rescan
|
||||
`?disabled`, and add a local `starting` flag that disables the button until the job
|
||||
snapshot lands.
|
||||
|
||||
### M6. Deleting a playlist has no confirmation and no undo
|
||||
**`frontend/src/components/playlist-view/playlist-view.ts:1352-1372`** (multi-select
|
||||
path) and **`1381-1392`** (`handleDeletePlaylist`).
|
||||
|
||||
The multi-select branch loops `await DeletePlaylist(id)` over every selected playlist
|
||||
with no prompt. `handleDeletePlaylist` `console.error`s on failure, so a partial
|
||||
failure looks like a success until the refresh reveals the playlist is still there.
|
||||
|
||||
Compare `jobs-view.ts:296` (full rescan) and `job-controls.ts:41-53` (index cancel),
|
||||
both of which do confirm — the codebase has the convention, this path just skips it.
|
||||
|
||||
Fix: `window.confirm` naming the playlist(s) and their track counts, matching the
|
||||
pattern already used for full rescan.
|
||||
|
||||
### M7. Durable download requests are removed with one click, no confirmation, unhandled rejection
|
||||
**`frontend/src/components/downloads-view/downloads-view.ts:466-476`**
|
||||
(`void downloadStore.removeRequest(request.id)`), and the same shape at
|
||||
**`451-460`** (`pauseRequest`) and **`296-300`** (`clearSatisfiedRequests`).
|
||||
|
||||
`downloadStore.removeRequest` (`download-store.ts:434-437`) awaits `RemoveRequest` with
|
||||
no catch, and the call site discards the promise with `void`.
|
||||
|
||||
Symptom: click the ✕ next to an artist subscription you have been building for months
|
||||
— it disappears with no prompt and no undo; or, if the delete fails, it stays put with
|
||||
no explanation.
|
||||
|
||||
Fix: confirm before removing a subscription, and `.catch()` the promise into a visible
|
||||
message.
|
||||
|
||||
### M8. Stale preview overwrites newer rules in the smart-playlist editor
|
||||
**`frontend/src/components/smart-playlist-editor/smart-playlist-editor.ts:666-707`**.
|
||||
|
||||
`schedulePreview()` debounces 300 ms, then `runPreview()` awaits
|
||||
`PreviewSmartPlaylist(json)` with no request id. Debouncing only coalesces keystrokes
|
||||
*within* the window; a query that takes longer than 300 ms overlaps the next one, and
|
||||
whichever resolves last wins.
|
||||
|
||||
Symptom: edit a rule, and the preview list settles on the results of the *previous*
|
||||
rule set. The `finally` block also clears `previewLoading` from the stale response,
|
||||
so the spinner stops while the current query is still running.
|
||||
|
||||
Fix: capture `const v = ++this.previewVersion` and bail on
|
||||
`if (v !== this.previewVersion) return` in both the success and `finally` paths —
|
||||
`explore-view.ts:703/793/821/826` does exactly this correctly.
|
||||
|
||||
### M9. Raw Go error strings are rendered to the user in six places
|
||||
No error is ever mapped to human copy. Verbatim `err.Error()` / `String(err)` reaches
|
||||
the UI at:
|
||||
|
||||
| Location | What the user sees |
|
||||
|---|---|
|
||||
| `explore-album-details.ts:1755, 1811` (set at `912, 969`) | `Get "https://musicbrainz.org/ws/2/…": context deadline exceeded` |
|
||||
| `explore-artist-details.ts:2023, 2296` (set at `1294, 1466`) | same class of string |
|
||||
| `explore-view.ts:1276` (set at `823, 848`) | same |
|
||||
| `config-page.ts:1142` | `Failed to remove 'Music': sql: database is locked` |
|
||||
| `config-page.ts:1514` | index tier `${t.error}` verbatim |
|
||||
| `autotag-view.ts:1289, 1651` | `Apply failed: build plan: …` |
|
||||
| `download-picker.ts:141, 159`; `download-clients.ts:645, 668, 690` | `String(err)` verbatim |
|
||||
| `first-run-wizard.ts:239, 259` | `Could not add the folder: ${err}` |
|
||||
|
||||
These come straight out of `musicbrainzws2` / `net/http` / `database/sql`
|
||||
(`backend/explore/musicbrainz.go:267-285` returns the client error unwrapped), so the
|
||||
string is a Go stack-flavoured HTTP error, not a sentence.
|
||||
|
||||
Fix: introduce a small `describeError(err)` helper in `frontend/src/utils/` that maps
|
||||
the handful of recognisable cases (offline, timeout, not found, permission) to copy and
|
||||
falls back to a generic line, and route all eight sites through it. Keep the raw text
|
||||
in `console.error` for debugging.
|
||||
|
||||
Genuine counter-example worth preserving: `download-store.ts:337-341` deliberately lets
|
||||
`TestProvider`'s message through, and documents why — that one is the user's debugging
|
||||
tool for a misconfigured client. That is the exception, not the rule.
|
||||
|
||||
---
|
||||
|
||||
## Minor
|
||||
|
||||
### m1. Every queue and player action is fire-and-forget
|
||||
**`frontend/src/store/queue-store.ts:192-266`**, **`frontend/src/store/player-store.ts:96-114`**.
|
||||
|
||||
Twenty binding calls (`Queue.Play`, `Queue.SetQueue`, `Queue.Clear`, `Queue.RemoveTracks`,
|
||||
`Player.Pause`, `Player.LoadFile`, `Player.Seek`, `Player.SetVolume`, …) are invoked
|
||||
with no `await`, no `.catch()`, and no `void`. Wails still returns a promise, so a
|
||||
rejection (which happens if the bridge is torn down, or the arg fails to marshal)
|
||||
becomes an unhandled rejection.
|
||||
|
||||
Mostly benign today because the Go methods return nothing (see **C1**), but it means
|
||||
these methods cannot report failure even after C1 is fixed.
|
||||
|
||||
Fix: as part of the C1 fix, change the queue methods to return `error` and have the
|
||||
store `.catch()` them.
|
||||
|
||||
### m2. Favorite toggles revert silently
|
||||
**`frontend/src/store/favorites-store.ts:137-158`** (and `160-190` for the batch forms).
|
||||
|
||||
The optimistic update and its revert are both correct, but the revert is invisible.
|
||||
|
||||
Symptom: click the heart, it fills, and half a second later it empties again with no
|
||||
explanation.
|
||||
|
||||
Fix: on the revert path, surface a one-line message.
|
||||
|
||||
### m3. Clearing the queue has no confirmation and no undo
|
||||
**`frontend/src/components/queue-panel/queue-panel.ts:683-685`** →
|
||||
`queue-store.ts:262` → `backend/queue/queue.go:1138`, which stops playback and
|
||||
discards the list.
|
||||
|
||||
Not catastrophic (the queue is reconstructable), but it is the only mutation in the
|
||||
panel with no way back, and it sits next to routine controls.
|
||||
|
||||
Fix: either confirm when the queue is non-trivially long, or keep the last cleared
|
||||
queue in memory behind an "Undo" affordance.
|
||||
|
||||
### m4. Removing a download client provider has no confirmation
|
||||
**`frontend/src/components/config-page/download-clients.ts:684-692`**.
|
||||
Deleting a provider discards its stored credentials
|
||||
(`backend/download`'s `FileSecretStore`), which cannot be recovered.
|
||||
|
||||
Fix: confirm, naming the client.
|
||||
|
||||
### m5. `AddLibrary` / `RenameLibrary` failures are console-only
|
||||
**`frontend/src/components/config-page/config-page.ts:1058-1072`** (add),
|
||||
**`1082-1097`** (rename). Both `console.error`. Note that *removal* — the more
|
||||
dangerous operation — is handled correctly in the same file (impact preview at
|
||||
`1105-1114`, confirmation, `isRemoving` guard, toast at `1129-1143`).
|
||||
|
||||
Fix: route these two through the existing `showToast` (`config-page.ts:1168`).
|
||||
|
||||
### m6. Autotag warning/skip/leave dialogs stall on a rejected binding
|
||||
**`frontend/src/components/autotag-view/autotag-view.ts:1328-1334`**
|
||||
(`onWarningContinue` → `await AckLibraryWarning(...)`),
|
||||
**`1336-1342`** (`onLeaveConfirm` → `await LeaveAsIs(...)`),
|
||||
**`1660-1664`** (`onSkip` → `await Skip(...)`).
|
||||
|
||||
None is wrapped. A rejection means the lines after the await — including
|
||||
`this.dialog = 'none'` — never run.
|
||||
|
||||
Symptom: press "Continue" on the destructive-write warning and the dialog just sits
|
||||
there.
|
||||
|
||||
Fix: try/catch each, close the dialog in a `finally`, and surface the error.
|
||||
|
||||
### m7. Add-to-playlist fails silently after a correct in-flight guard
|
||||
**`frontend/src/components/playlist-picker/playlist-picker.ts:164-193, 216-231`**.
|
||||
|
||||
The `this.loading` guard is right (no double-add), the create button is disabled while
|
||||
in flight (`playlist-picker.ts:321`) — but the failure path is `console.error` and the
|
||||
picker just closes.
|
||||
|
||||
Symptom: the tracks appear not to have been added, and the user cannot tell whether to
|
||||
retry.
|
||||
|
||||
Same shape at `playlist-details.ts:396-412` (remove tracks), `414-438` (remove
|
||||
phantoms), `584-601` (remove one phantom).
|
||||
|
||||
### m8. The download search cannot be cancelled
|
||||
**`frontend/src/components/download-picker/download-picker.ts:127-148`**.
|
||||
|
||||
`downloadStore.start()` queries every enabled provider. The dialog shows a spinner and
|
||||
"Searching your download clients…" but the only exit is Close, which does not cancel
|
||||
the backend work. `search()` also has no stale guard, so a close-and-reopen for a
|
||||
different album can be overwritten by the first search's result.
|
||||
|
||||
Otherwise this file is the strongest failure UX in the codebase — see **What is
|
||||
already right** below.
|
||||
|
||||
---
|
||||
|
||||
## Polish
|
||||
|
||||
### p1. `console.log` debug output left in shipped views
|
||||
`explore-album-details.ts:667, 673, 680, 695, 715, 877`;
|
||||
`explore-artist-details.ts:1017, 1030, 1037, 1071`;
|
||||
`config-page.ts:1019` (`'IndexStatusChanged event received'`).
|
||||
|
||||
### p2. Long-running operation coverage is inconsistent by subsystem
|
||||
|
||||
| Operation | Progress | Cancel | Pause/resume | Survives quit |
|
||||
|---|---|---|---|---|
|
||||
| Library scan | ✅ jobs registry | ✅ | ✅ | ✅ paused scans restored (`backend/library/scan_jobs.go:300`) |
|
||||
| Index build | ✅ | ✅ (confirmed, `job-controls.ts:41`) | ✅ | ✅ checkpointed |
|
||||
| Downloads | ✅ (`download/manager.go:192`) | ✅ | — | ✅ swept on restart |
|
||||
| Batch tag write | ✅ event | ✅ (`track-details.ts:1767`) | — | ❌ not in registry |
|
||||
| **Autotag apply** | ⚠️ page-local only | ❌ | ❌ | ❌ (see **C3**) |
|
||||
| **Download search** | spinner | ❌ | — | ❌ (see **m8**) |
|
||||
| **Requests reconcile** | `checking` flag (`downloads-view.ts:503`) | ❌ | — | — |
|
||||
|
||||
The pattern is clear: everything routed through `jobs.Registry` gets progress, cancel
|
||||
and a global indicator for free. The three gaps are the three things not registered.
|
||||
|
||||
### p3. `EventsOff` is global
|
||||
**`frontend/src/components/track-details/track-details.ts:1765`** calls
|
||||
`EventsOff(Events.BatchWriteProgress)`, which removes *all* listeners for that event,
|
||||
not just this component's. Correct today (single listener) but fragile; prefer the
|
||||
unsubscribe function `EventsOn` returns, as `jobs-view.ts:246-249` does.
|
||||
|
||||
### p4. `OnBeforeClose` never asks
|
||||
**`backend/app.go:445-484`** always returns `false`. Quitting during a full rescan
|
||||
leaves the library partially rebuilt — recoverable, because the soft scan re-runs on
|
||||
next launch (`backend/app.go:568`), but playlists are not restored until that scan
|
||||
completes (`RestoreAllPlaylists` only runs from the `PostScan` hook,
|
||||
`backend/app.go:341`). Worth a confirm while a destructive job is running.
|
||||
|
||||
---
|
||||
|
||||
## What is already right (keep these as the templates)
|
||||
|
||||
- **`frontend/src/components/download-picker/download-picker.ts`** — distinct
|
||||
searching / auto-picked / empty ("Nothing found. Try a different spelling…") /
|
||||
error bodies, an in-flight `picking` guard on `onPick` (`154`), and a footnote that
|
||||
explains *why* it is asking rather than deciding (`243-262`). This is the standard
|
||||
the rest of the app should be measured against.
|
||||
- **`frontend/src/components/home-view/home-view.ts:263-280`** — the only place that
|
||||
correctly distinguishes loading, failed, and genuinely-empty in three separate
|
||||
bodies.
|
||||
- **`frontend/src/components/explore-view/explore-view.ts:703, 793, 820-828`** — a
|
||||
correct monotonic request-version guard on search-as-you-type, checked on the success
|
||||
path, the catch path *and* the `finally` that clears the spinner. This is the fix
|
||||
pattern for **C4** and **M8**.
|
||||
- **`frontend/src/components/track-details/track-details.ts:1706-1766`** — the best
|
||||
destructive flow in the app: an explicit change summary, a confirmation step, live
|
||||
per-file progress, a working cancel, and a per-file failure list afterwards.
|
||||
- **`frontend/src/components/config-page/config-page.ts:1105-1143`** — removal shows a
|
||||
computed impact (`GetRemovalImpact`) *before* asking, guards with `isRemoving`, and
|
||||
reports the outcome. The right shape; only the raw error string (**M9**) lets it down.
|
||||
- **`frontend/src/components/catalog-scope-notice/catalog-scope-notice.ts`** — a
|
||||
purpose-built component whose entire job is to admit what the user is looking at, with
|
||||
Retry offered only in the one scope where retrying means anything.
|
||||
- **`backend/library/scan_jobs.go:265-345`** — paused scans survive a restart, and a
|
||||
pause that outlived the process resumes as an incremental rescan with a log line
|
||||
saying so.
|
||||
- **`frontend/src/store/favorites-store.ts:137-158`** — optimistic update with a
|
||||
correct revert. Only the silence (**m2**) is wrong.
|
||||
|
||||
---
|
||||
|
||||
## Suggested order
|
||||
|
||||
1. **C1** + **C2** — playback failure is the app's core job; it currently fails mute.
|
||||
2. A minimal app-level notification surface, then route **M9**'s eight sites,
|
||||
**M5**, **M6**, **M7**, **m2**, **m5**, **m7** through it. Most of these findings
|
||||
are one problem wearing thirty hats.
|
||||
3. **M3**, **M2** — two permanent fake "loading" states.
|
||||
4. **C4** + **M1** + **M8** — the three async-correctness bugs; all three are the same
|
||||
version-guard fix, and `explore-view.ts` already contains the reference
|
||||
implementation.
|
||||
5. **C3** — register the autotag apply with `jobs.Registry` and it inherits progress,
|
||||
cancel and the global indicator at once.
|
||||
@@ -0,0 +1,259 @@
|
||||
# UI/UX audit — YellowJacket
|
||||
|
||||
Date: 2026-08-11. Method: the app driven by hand headlessly
|
||||
(`make dev-headless SEED=default` + `playwright-cli`, then
|
||||
`make dev-headless-fresh` for first run), plus three read-only static
|
||||
reviews. Nothing was changed.
|
||||
|
||||
- `hands-on.md` (this file) — the empirically confirmed findings, i.e.
|
||||
things observed happening in the running app, with the reproduction.
|
||||
- `a11y.md` — accessibility and interaction model.
|
||||
- `perf.md` — rendering performance, memory, state correctness.
|
||||
- `errors.md` — error handling, empty/loading states, destructive actions.
|
||||
|
||||
Findings below are numbered `H-n` (hands-on) and cross-reference the
|
||||
static reports where they overlap. The reconciliation plan built from
|
||||
all four files is `.planning/plans/pending/007-ui-reconciliation.md`.
|
||||
|
||||
---
|
||||
|
||||
## Critical — confirmed by reproduction
|
||||
|
||||
### H-1. A keypress on any page silently mutates the Autotag queue
|
||||
|
||||
Every view the user visits stays mounted forever (`index.ts`, class
|
||||
`view-hidden`), so `disconnectedCallback` never runs and
|
||||
`autotag-view`'s `document` keydown listener (`autotag-view.ts:1188`,
|
||||
handler at `:1706`) stays live for the rest of the session.
|
||||
|
||||
Reproduced: visited Autotag (Pending 11), navigated to Settings,
|
||||
dispatched `keydown` `s` twice → **Pending 9**. Two albums skipped from
|
||||
a page that was not on screen and gave no feedback. `a` on the same
|
||||
listener is Apply, which rewrites tags on disk.
|
||||
|
||||
### H-2. `s` and the arrow keys fire two handlers at once
|
||||
|
||||
`autotag-view`'s listener and `keyboard-shortcut-service` are both on
|
||||
`document` and neither defers. Reproduced on the Autotag page: pressing
|
||||
`s` emitted `QueueModeChanged` (shuffle toggled) *and* skipped the
|
||||
album. `ArrowUp`/`ArrowDown` navigate the folder list *and* change the
|
||||
volume by 5, so walking the autotag list with the keyboard ramps volume
|
||||
to 0 or 100.
|
||||
|
||||
### H-3. The progress bar is a local timer that lies, and a keyboard seek desyncs it by ~30 s
|
||||
|
||||
`seek-bar.ts:110-116` increments `seekValue` by 1 every 1000 ms and only
|
||||
resyncs when `trackChangeId` changes. Nothing reconciles it against
|
||||
`Player.CurrentPositionSeconds`.
|
||||
|
||||
Reproduced twice:
|
||||
|
||||
| | UI | backend |
|
||||
|---|---|---|
|
||||
| steady playback, +10 s | 00:47 → 00:57 | 50 → 60 (constant 3 s lie) |
|
||||
| after 4× `ArrowRight` (seek +5 s) | 00:08 → **00:10** | 11 → **40** |
|
||||
|
||||
The keyboard seek path (`keyboard-shortcut-service.ts:207-214`) calls
|
||||
`Player.Seek` and never tells the seek bar, so the bar does not move at
|
||||
all — the shortcut looks broken, and the displayed time is wrong for
|
||||
the rest of the track.
|
||||
|
||||
### H-4. Every icon in the app is fetched from fontawesome.com at runtime
|
||||
|
||||
Confirmed from `performance.getEntriesByType('resource')`:
|
||||
`https://ka-f.fontawesome.com/releases/v7.1.0/svgs/solid/house.svg`
|
||||
and 35 more. `setBasePath('/dist/webawesome')` in `index.ts` does not
|
||||
affect the icon resolver, and no `registerIconLibrary` call exists.
|
||||
A desktop music player offline, on a captive portal, or behind a
|
||||
firewall renders **no icons at all**. See `perf.md` M9.
|
||||
|
||||
### H-5. The whole app is unusable without a mouse
|
||||
|
||||
Tabbing through the entire app yields **14 stops**, all of them chrome
|
||||
(library filter, search, one unlabelled track-list button, two queue
|
||||
buttons, five transport buttons, volume, queue toggle, seek). The
|
||||
sidebar nav (`app-sidebar.ts:202`, bare `<li @click>`), every track
|
||||
row, every album/artist/genre card and every context menu are
|
||||
unreachable. `Enter` on a selected track does nothing — reproduced.
|
||||
|
||||
The cause of the last part is that `data-shortcut-scope` is **never set
|
||||
anywhere in the codebase**, so `resolveScope` can only return
|
||||
`text-input` or `global`, and the two panel-scoped bindings
|
||||
(`tracklist.play` = Enter, `tracklist.delete` = Delete) are dead
|
||||
shortcuts that the Settings page still advertises as configurable.
|
||||
|
||||
Related: the closed queue panel is `width: 0` but not `inert` and not
|
||||
`visibility: hidden` (`queue-panel.ts:214`), so its Clear/Add buttons
|
||||
still take tab stops and are read by screen readers — reproduced, they
|
||||
appear in the tab order at x=1440.
|
||||
|
||||
---
|
||||
|
||||
## Major — confirmed by reproduction
|
||||
|
||||
### H-6. Global single-key shortcuts hijack keys from focused controls
|
||||
|
||||
Defaults (`backend/shortcuts/shortcuts.go:16`) bind unmodified
|
||||
`Space N P S R M / Q ↑ ↓ ← →` at global scope, and the service calls
|
||||
`preventDefault()` on a match. Only text inputs are exempt. So a
|
||||
focused `<button>` cannot be activated with Space, the native
|
||||
`<select>` library filter cannot be arrowed through, the volume and
|
||||
seek sliders fight the global handler for arrow keys, and Space/arrow
|
||||
page scrolling is dead everywhere.
|
||||
|
||||
`ArrowUp` also emits `MuteChanged` alongside `VolumeChanged` even when
|
||||
nothing is muted — reproduced.
|
||||
|
||||
### H-7. The last column of the track list is always clipped by exactly 40 px
|
||||
|
||||
`computeDefaultWidths` (`track-list.ts:409`) distributes
|
||||
`this.clientWidth` across the columns but never subtracts the 24 px
|
||||
favourite column or the 2×8 px row padding that
|
||||
`colBoundaryPositions` (`:378`) knows about. Measured: every
|
||||
`.track-row` and the `.header-row` report `scrollWidth 1280` against
|
||||
`clientWidth 1240`. Duration renders as "Durat…" on a fresh profile at
|
||||
1440×900, and disappears entirely below ~1000 px.
|
||||
|
||||
### H-8. The app never lands on Home
|
||||
|
||||
`app-sidebar.ts:124` defaults `activeView = 'tracks'`. The curated Home
|
||||
page — the one with the "somewhere to start listening" shelves — is
|
||||
listed first in the nav and is never what the user sees on launch.
|
||||
|
||||
### H-9. On the Home page, an album with no cover art renders as nothing
|
||||
|
||||
The Home shelf card's missing-art placeholder has no background, so the
|
||||
tile is invisible against the page and the shelf reads as having holes
|
||||
in it. The Albums grid and the Artists grid both do this correctly
|
||||
(letter-on-a-tile), so this is one card renderer disagreeing with the
|
||||
other two.
|
||||
|
||||
Also on Home: with a small library all three shelves ("Fresh in your
|
||||
library", "Never played", "Take a chance") show the **same seven
|
||||
albums** in different orders, so the page reads as repeating itself.
|
||||
A shelf whose contents largely duplicate the shelf above it would be
|
||||
better suppressed, the way an empty one already is.
|
||||
|
||||
### H-10. The header search is view-scoped but looks global
|
||||
|
||||
Typing `tide` on the Playlists page produced **"No playlists match your
|
||||
search"** while three tracks named *Tideline* sat in the library. The
|
||||
box is in the global header, is placeheld "Search…", and persists its
|
||||
term across navigation, so it reads as a library-wide search and is
|
||||
not one. It also vanishes entirely on Home and Explore (Explore has its
|
||||
own second search box), and its appearing/disappearing shifts the whole
|
||||
header layout.
|
||||
|
||||
### H-11. The layout has no responsive behaviour and the enforced minimum window is too small
|
||||
|
||||
`MinWidth/MinHeight` are 512×384 (`backend/config/window.go:15`). At
|
||||
900×600 the Duration column is off-screen; at 700×480 the sidebar
|
||||
overflows behind the player bar with no scroll, so **Settings and Jobs
|
||||
become unreachable**, and the app title wraps into the nav. The sidebar
|
||||
has a `.collapsed` icon mode but nothing triggers it automatically.
|
||||
|
||||
### H-12. First run shows "Loading tracks…" behind an inert copy of the whole app
|
||||
|
||||
On an empty `YJ_HOME` the wizard is a modal over a fully rendered app —
|
||||
sidebar, transport, search, library filter all visible and all inert —
|
||||
with a permanent "Loading tracks…" in the content area (the track list
|
||||
cannot tell empty from loading, `track-list.ts:1901`). Meanwhile the
|
||||
"Building search index" job is already downloading a 1.1 M-row catalog
|
||||
before the user has chosen a folder or consented to it.
|
||||
|
||||
`Get Started` is correctly disabled until a folder is chosen, but it is
|
||||
the filled accent button and its disabled state is barely visible.
|
||||
|
||||
### H-13. The album detail page has no way to play the album
|
||||
|
||||
The primary action is missing: no Play, no Shuffle, no Add to queue on
|
||||
the album header. Nor is there any legend for the green ✓ badges shown
|
||||
against the album title and every track.
|
||||
|
||||
### H-14. `IndexStatusChanged` is emitted every 3 seconds forever
|
||||
|
||||
`searchindex.go:276` starts an unconditional 3 s ticker in
|
||||
`SetContext` and never stops it. The payload is byte-identical once the
|
||||
index is ready (`building:false, ready:true`) and it keeps firing for
|
||||
the life of the process. Each tick re-renders the 2 149-line
|
||||
`config-page` (which never unmounts) and writes a `console.log`
|
||||
(`config-page.ts:1019`) — the browser console filled with ~200
|
||||
identical lines during a 20-minute session. See `perf.md` M6.
|
||||
|
||||
---
|
||||
|
||||
## Minor — confirmed by observation
|
||||
|
||||
- **H-15.** Three identical `Tideline / Aurora Fields / 00:06` rows are
|
||||
indistinguishable in the track list; the default columns carry no
|
||||
album, format or path, so the app's own duplicate fixtures cannot be
|
||||
told apart by eye in a library manager that has a duplicate-detection
|
||||
feature.
|
||||
- **H-16.** The remaining-time label is a countdown with no minus sign,
|
||||
no label and no toggle to total duration — `01:21` next to a track
|
||||
the list says is `01:30`.
|
||||
- **H-17.** The now-playing artist is truncated to a fixed ~120 px
|
||||
("The Orchestra Of") while ~400 px of empty space sits between it and
|
||||
the transport controls.
|
||||
- **H-18.** When a queue finishes, the now-playing bar empties
|
||||
completely, losing the context of what just played, while the queue
|
||||
panel still lists the finished track.
|
||||
- **H-19.** Page headings are inconsistent: Playlists, Downloads, Jobs,
|
||||
Settings and Home have a title (and Playlists/Downloads/Jobs have
|
||||
header actions); Artists, Genres, Albums and Tracks have none, and
|
||||
none of them shows a count. Sort controls exist on Albums and Tracks
|
||||
but not on Artists or Genres.
|
||||
- **H-20.** The sidebar's hover colour (`#343a40`) and its active
|
||||
colour (`#495057`) are close enough that a hovered item reads as a
|
||||
second selected item.
|
||||
- **H-21.** The track context menu has no Escape handler, no keyboard
|
||||
navigation and no focus movement (`context-menu-controller.ts` binds
|
||||
only click/contextmenu/mousedown), and is missing the conventional
|
||||
entries: Go to album, Go to artist, Show in file manager, Edit tags,
|
||||
Remove from library.
|
||||
- **H-22.** In Settings, "Libraries" — the section that matters most —
|
||||
is last and below the fold, while "Search Index" is first and
|
||||
expanded by default. There is no Playback/Audio section at all (no
|
||||
output device, gapless, crossfade or replay gain).
|
||||
- **H-23.** Explore is an empty page with a search box over a 1.1 M-row
|
||||
catalog: no browse, no popular-artists entry point, nothing to do
|
||||
without typing.
|
||||
- **H-24.** Long body copy (Downloads' intro, Jobs' descriptions) runs
|
||||
the full ~1200 px content width with no measure cap.
|
||||
|
||||
---
|
||||
|
||||
## Where the bar is already high
|
||||
|
||||
Worth naming, because the findings above are the exceptions:
|
||||
|
||||
- **`downloads-view`** — the best empty state in the app: it says what
|
||||
the feature is, why nothing is happening, and exactly what to do next.
|
||||
- **`autotag-view`** — genuinely dense and legible: per-field match
|
||||
breakdown, your-folder-vs-candidate side by side, confidence stated
|
||||
rather than hidden.
|
||||
- **`jobs-view`** — running / libraries / maintenance / recently
|
||||
finished, with the destructive action visually separated and honestly
|
||||
described.
|
||||
- **`track-list`** — a properly built virtualized list (memoized
|
||||
filter/sort, delegated handlers, `_itemSize` hint, inline SVG for the
|
||||
per-row icon). Its problems are at the edges, not in the core.
|
||||
- **`player-controls`** — every button labelled, `aria-pressed` on the
|
||||
toggles, repeat's three-state mode spelled into the label.
|
||||
|
||||
---
|
||||
|
||||
## Suggested order
|
||||
|
||||
1. **H-1 / H-2** — a hidden page mutating files on a keystroke is the
|
||||
only finding here that loses user data. Fix the view lifecycle
|
||||
(deactivate hidden views) and make the two keydown listeners agree.
|
||||
2. **H-3** — drive the seek bar from the backend position; the core
|
||||
surface of a music player currently lies.
|
||||
3. **H-4** — bundle the icons; the app is not usable offline.
|
||||
4. `errors.md` **C1** — a track that fails to play is a silent no-op,
|
||||
which is the same class of problem as H-3 on the same surface.
|
||||
5. **H-5 / H-6** — keyboard access, and stop the global shortcuts
|
||||
stealing keys from focused controls.
|
||||
6. **H-7 / H-11** — the layout arithmetic and a real minimum size.
|
||||
7. Then the consistency pass: **H-8, H-9, H-10, H-13, H-19**.
|
||||
@@ -0,0 +1,505 @@
|
||||
# Frontend performance / memory / state-correctness audit
|
||||
|
||||
**Scope:** `frontend/src/store/**`, `frontend/src/components/**`, `frontend/src/events.ts`,
|
||||
`frontend/vite.config.mts`, `frontend/package.json`, `frontend/index.ts`, `frontend/index.html`.
|
||||
Read-only. Nothing in the repo was modified. (Two throwaway production builds were emitted to
|
||||
`/tmp/yjbuild*` to measure bundle composition; `frontend/dist/` was not touched.)
|
||||
|
||||
**Excluded as already-known** (traced for consequences, not re-reported): views never unmount,
|
||||
`autotag-view`'s document keydown, `IndexStatusChanged` every 3 s, seek-bar drift.
|
||||
|
||||
---
|
||||
|
||||
## Critical
|
||||
|
||||
### C1 — Finishing a track re-downloads the entire library
|
||||
|
||||
`backend/queue/playhistory.go:63` → `frontend/src/store/library-store.ts:85` → `:445`
|
||||
|
||||
`recordPlay()` emits `TrackMetadataChanged` on **every naturally finished track**
|
||||
(`backend/queue/handlers.go:24,34,45,52`). `LibraryStore` treats that event exactly like a retag:
|
||||
`invalidate()` nulls tracks/albums/artists/genres and immediately `eagerFetch()`es all four
|
||||
(`library-store.ts:445-476`). On a 50 k-track library that is `GetAllTracks` +
|
||||
`GetAllAlbums` + `GetAllArtists` + `GetAllGenresWithCounts` — roughly 25 MB of JSON across the
|
||||
Wails IPC, parsed on the main thread — **once per song**, forever, whether or not the user is
|
||||
looking at a list.
|
||||
|
||||
The invalidation itself is correct and deliberate (`frontend/test/stores/library-store.test.ts:94-110`
|
||||
asserts it); the defect is that the backend reuses one event for "tags were rewritten" and
|
||||
"play_count went up by one".
|
||||
|
||||
*Symptom:* a multi-second main-thread stall between every two tracks on a large library, plus
|
||||
constant SQLite churn.
|
||||
*Fix:* emit a distinct `TrackPlayCountChanged` from `recordPlay` and have `LibraryStore` patch the
|
||||
one track in place instead of invalidating.
|
||||
|
||||
### C2 — …and silently wipes the user's selection while it does
|
||||
|
||||
`frontend/src/components/track-list/track-list.ts:1198-1211` → `:1242-1246`
|
||||
|
||||
`updated()` notices `libraryCtrl.cachedTracks` has a new identity and calls `loadTracks()`, which
|
||||
does `this.selection.clear()` (`:1246`). Combined with C1, **every track change clears whatever the
|
||||
user had selected in the track list.** Selecting 40 tracks to drag into a playlist while music plays
|
||||
is not possible.
|
||||
|
||||
*Fix:* re-key the selection against the new array (`selection` is keyed by `FilePath`, which
|
||||
survives a refetch) instead of clearing it.
|
||||
|
||||
### C3 — Library-filter / rescan race caches the wrong library's data
|
||||
|
||||
`frontend/src/store/library-store.ts:133-155` (and the identical `getAlbums`/`getArtists`/`getGenres`)
|
||||
|
||||
`getTracks()` guards on `tracksLoading`, but `invalidate()` (`:445`) clears `tracks` **without**
|
||||
clearing `tracksLoading`. Sequence:
|
||||
|
||||
1. `getTracks()` starts for library A → `tracksLoading = true`.
|
||||
2. User picks library B → `setSelectedLibrary` (`:339`) → `invalidate()` → `tracks = null`,
|
||||
`eagerFetch()` → `getTracks()` sees `tracks === null && tracksLoading === true` → returns
|
||||
`waitForTracks()`.
|
||||
3. Library A's response lands, is stored as `this.tracks`, `changeGen++`.
|
||||
4. `waitForTracks()` resolves with library A's tracks — under library B's filter.
|
||||
|
||||
The same window exists for `LibraryScanComplete` arriving while a fetch is in flight, in which case
|
||||
the pre-scan snapshot is cached as if it were post-scan and the newly scanned tracks never appear.
|
||||
|
||||
*Fix:* stamp each fetch with a request id (or the `selectedLibraryIdValue` + `changeGen` it started
|
||||
under) and discard the result if it no longer matches.
|
||||
|
||||
### C4 — `waitFor*` never resolves on a failed fetch, and leaks a subscriber forever
|
||||
|
||||
`frontend/src/store/library-store.ts:494-547` (4 copies), `frontend/src/store/playlist-store.ts:143-157`
|
||||
|
||||
`waitForTracks()` resolves only when `!tracksLoading && tracks !== null`. If the underlying binding
|
||||
rejects, `finally` sets `tracksLoading = false` but `tracks` stays `null`, so the promise **never
|
||||
settles** and its `subscribe()` callback is never removed from `LibraryStore.subscribers`. Every
|
||||
component or `explore-link` lookup awaiting that promise hangs, and each hung wait permanently adds
|
||||
a closure to the notify set that runs on every subsequent store change. `eagerFetch()`'s
|
||||
`void this.getTracks()` (`:474-477`) also swallows the rejection into an unhandled promise rejection.
|
||||
|
||||
*Fix:* have the fetch record an error state and reject/resolve all waiters in `finally`.
|
||||
|
||||
### C5 — Adding one track to one playlist re-downloads every track of every playlist
|
||||
|
||||
`frontend/src/store/playlist-store.ts:31-33` → `:124-129` → `:60`
|
||||
|
||||
`PlaylistTracksChanged` (emitted from 8 backend sites including `backend/playlist/favorites.go:200,231`)
|
||||
calls `invalidate()` → `GetAllPlaylistsWithTracks()`, which the backend implements as
|
||||
`GetAllPlaylists` + `GetAllPlaylistTracksWithMetadata` — **all rows of all playlists with full track
|
||||
metadata** (`backend/playlist/playlist.go:206-234`).
|
||||
|
||||
Toggling a single heart in the track list therefore refetches every playlist in the app. The store
|
||||
does this unconditionally (`void this.getPlaylists()` inside `invalidate()`), so it fires even when
|
||||
`playlist-view` — the only subscriber — has never been opened.
|
||||
|
||||
*Fix:* the event already carries the playlist id; refetch that one playlist, and only when there is
|
||||
a subscriber.
|
||||
|
||||
---
|
||||
|
||||
## Major
|
||||
|
||||
### M1 — One keystroke in the search box re-ranks every list in the app
|
||||
|
||||
`frontend/src/store/search-store.ts:55-57`, `frontend/src/store/controllers/search-controller.ts:29-32`
|
||||
|
||||
`SearchStore.notify()` is an unbatched broadcast to every subscriber, and `SearchController` maps it
|
||||
straight to `host.requestUpdate()`. Eight components hold a `SearchController`
|
||||
(`track-list`, `cover-grid`, `artists-view`, `genres-view`, `playlist-view`, `playlist-details`,
|
||||
`smart-playlist-details`, `search-bar`) and — because views stay mounted — **all of the mounted ones
|
||||
recompute on every keystroke**, not just the visible one:
|
||||
|
||||
- `track-list` → `rankTracks()` over 50 k tracks (`track-list.ts:271-289`)
|
||||
- `cover-grid` → filter + `[...albums].sort()` over 5 k albums (`cover-grid.ts:215-248`)
|
||||
- `artists-view`, `genres-view` → their own filter passes
|
||||
|
||||
Measured on Node/V8 (WebKit2GTK will be slower): `rankTracks`-equivalent work over 50 k tracks is
|
||||
**~18 ms**, so a single keystroke costs 50–100 ms of main-thread work across the mounted set even
|
||||
though four of the five results are invisible.
|
||||
|
||||
*Fix:* gate the notify on `searchStore.isSearchableView()` matching the subscriber's own view (the
|
||||
predicate already exists at `search-store.ts:41-43`), or have `SearchController` skip
|
||||
`requestUpdate()` when its host carries `view-hidden`.
|
||||
|
||||
### M2 — `rankTracks` allocates a `Set` and a closure per track, per keystroke
|
||||
|
||||
`frontend/src/components/track-list/search-ranking.ts:98-135`
|
||||
|
||||
`scoreTrack()` builds `new Set<string>()` plus a `check` closure for **every** track, then calls
|
||||
`col.accessor(track).toLowerCase()` (a fresh string allocation) per field. At 50 k tracks × 3 core
|
||||
fields that is 50 k Sets, 50 k closures and 150 k throwaway strings per keystroke. Benchmarked
|
||||
against a flat three-field comparison: **18.1 ms vs 5.8 ms** — a 3× tax purely from the dedup
|
||||
machinery, for a `seen` set that only ever contains 3–6 fixed ids.
|
||||
|
||||
*Fix:* hoist the deduped column list out of the per-track loop (compute it once in `rankTracks`) and
|
||||
drop the closure.
|
||||
|
||||
### M3 — Full-size original cover art rendered as a 24 px thumbnail in the track list
|
||||
|
||||
`frontend/src/components/track-list/columns.ts:53-63`
|
||||
|
||||
The `albumArt` column renders `track.CoverArtPath` — the **original embedded artwork**, commonly
|
||||
1500×1500 and several hundred KB — scaled to `width:24px;height:24px` by CSS. `CoverArtSmall`
|
||||
(100 px, quality 75) and `CoverArtMedium` (200 px) already exist on the same model
|
||||
(`wailsjs/go/models.ts:1583-1586`, generated by `backend/library/coverart.go:41-45`) and are used
|
||||
correctly everywhere else. There is also no `loading="lazy"` and no `decoding="async"`, so every row
|
||||
the virtualizer scrolls into view decodes a full-resolution JPEG synchronously on the main thread.
|
||||
|
||||
*Symptom:* enabling the Art column makes track-list scrolling stutter and inflates memory by the
|
||||
decoded bitmap of every album scrolled past.
|
||||
*Fix:* `track.CoverArtSmall || track.CoverArtPath`, plus `loading="lazy" decoding="async"`.
|
||||
|
||||
### M4 — Artist grid does a full linear scan of the album cache per card, per frame
|
||||
|
||||
`frontend/src/components/artists-view/artists-view.ts:988-1029`, called from `:1044` /
|
||||
`.renderItem` at `:1298`
|
||||
|
||||
When an artist has no `ImageSmall/Medium/Large` — the common case for a locally-tagged library —
|
||||
`renderArtistAvatar()` falls back to scanning **all of `libraryStore.cachedAlbums`** with
|
||||
`a.ArtistName.toLowerCase() === name` until it finds a match, allocating two lowercased strings per
|
||||
comparison. This runs inside the virtualizer's `renderItem`, i.e. for every visible card on every
|
||||
render pass. At 5 000 albums × ~50 visible cards that is 250 000 comparisons and 500 000 string
|
||||
allocations per scroll frame.
|
||||
|
||||
*Fix:* build a `Map<lowercasedArtistName, coverUrls>` once when `cachedAlbums` identity changes, and
|
||||
look up in O(1).
|
||||
|
||||
### M5 — Playlist and smart-playlist track lists are not virtualized
|
||||
|
||||
`frontend/src/components/playlist-details/playlist-details.ts:1265-1396`,
|
||||
`frontend/src/components/smart-playlist-details/smart-playlist-details.ts:1176-1250`
|
||||
|
||||
Both render **every** track with a plain `.map()` — no `lit-virtualizer`, no `repeat()` key. For a
|
||||
2 000-track playlist that is 2 000 rows × 8 elements in the DOM, and:
|
||||
|
||||
- `getVisibleTracks()` (`playlist-details.ts:750-780`) allocates a fresh `{track, trackIndex}`
|
||||
wrapper object for every track on **every** render, so the array identity always changes;
|
||||
- five event bindings per row (`@click`, `@dblclick`, `@contextmenu`, `@dragstart`, `@dragend`,
|
||||
`:1305-1330`) are new arrow functions each render, so lit removes and re-adds 10 000 listeners
|
||||
per pass;
|
||||
- both components hold a `PlayerController` (`playlist-details.ts` imports it), whose subscription
|
||||
is unfiltered — so **every** `PlaybackStateChanged` / `TrackChanged` / `VolumeChanged` /
|
||||
`MuteChanged` triggers that whole pass;
|
||||
- the row `<img>` (`:1386`, `smart-playlist-details.ts:1245`) has no `loading="lazy"`, so opening a
|
||||
2 000-track playlist fires 2 000 simultaneous cover-art requests at the Go asset handler.
|
||||
|
||||
Both files are ~30 kB of the bundle each and duplicate the same list; `track-list` already solves
|
||||
all of this (delegated handlers via `data-index`, stable `renderItem`, memoized caches) and is
|
||||
already reused by `genre-details.ts:276-278` via `.externalTracks`.
|
||||
|
||||
*Fix:* render these with `<track-list .externalTracks=…>` the way `genre-details` does, or at minimum
|
||||
add `lit-virtualizer` + delegated handlers.
|
||||
|
||||
### M6 — Visiting Settings costs a full re-render (and a console entry) every 3 seconds, forever
|
||||
|
||||
`frontend/src/components/config-page/config-page.ts:1016-1022`, `@state` at `:186`
|
||||
|
||||
The `IndexStatusChanged` handler assigns a freshly deserialized object to a `@state` field, so the
|
||||
identity always differs and Lit re-renders the entire 2 149-line `config-page` template every 3 s —
|
||||
for the rest of the session, since `config-page` is a cached primary view that never unmounts
|
||||
(`index.ts:71`) and its `disconnectedCallback` cleanup (`:1024-1036`, including
|
||||
`this.cancelIndexStatus?.()`) never runs.
|
||||
|
||||
The handler also does `console.log('IndexStatusChanged event received', status)` on every tick. With
|
||||
devtools open that retains ~1 200 status objects per hour as a genuine, unbounded leak.
|
||||
|
||||
*Fix:* drop the `console.log`; compare the incoming status field-wise and only assign on change.
|
||||
|
||||
### M7 — `explore-view` retains base64 image data forever
|
||||
|
||||
`frontend/src/components/explore-view/explore-view.ts:99-100`, `:987`, `:1003-1019`, `:936-944`
|
||||
|
||||
`thumbnailCache` stores the **data URL** returned by `GetThumbnails` —
|
||||
`"data:image/jpeg;base64," + base64(front-250 JPEG)` (`backend/explore/coverartproxy.go:114`,
|
||||
`backend/explore/coverart.go:27-29`). A 250 px CAA JPEG is ~15–25 kB, ~20–33 kB base64, and JS
|
||||
strings are UTF-16, so **~40–66 kB of retained heap per cached album**, plus the browser's decoded
|
||||
bitmap keyed off that same multi-kilobyte string.
|
||||
|
||||
Neither `thumbnailCache` nor `artistImageCache` is ever evicted, and `explore-view` is a cached
|
||||
primary view (`index.ts:67`) that never unmounts. A session of browsing — a desktop player runs for
|
||||
days — grows monotonically: a few hundred searches × ~50 results is on the order of hundreds of MB.
|
||||
|
||||
*Fix:* cap both maps with an LRU (a few hundred entries), or return a `/coverart/<mbid>` URL from the
|
||||
backend instead of a data URL so the browser's own image cache handles eviction.
|
||||
|
||||
### M8 — `exploreCache` is a second unbounded, never-evicted cache
|
||||
|
||||
`frontend/src/store/explore-cache.ts:35-38`
|
||||
|
||||
Four module-level `Map`s (`artists`, `albums`, `artistAlbums`, `artistTopTracks`) with `set` but no
|
||||
`delete`, no size cap and no TTL. `artistAlbums` holds full `MBReleaseGroup[]` discographies and
|
||||
`artistTopTracks` full `LBTopRecording[]` lists. Grows for the lifetime of the process.
|
||||
|
||||
*Fix:* bound each map (LRU, ~100 entries is plenty for "avoid a refetch when the user hits back").
|
||||
|
||||
### M9 — Every `<wa-icon>` is fetched from a remote CDN at runtime
|
||||
|
||||
`frontend/index.ts:29-30,47`; resolver in
|
||||
`@awesome.me/webawesome/dist/chunks/chunk.F5JLNOSF.js` (`library.default`)
|
||||
|
||||
WebAwesome's default icon library resolves to
|
||||
`https://ka-f.fontawesome.com/releases/v7.1.0/svgs/<folder>/<name>.svg`. The literal is present in
|
||||
the built bundle. `setBasePath('/dist/webawesome')` does **not** change this — `getBasePath` is only
|
||||
consumed by the component autoloader (`chunk.2PWIIYRH.js:51`), and no
|
||||
`registerIconLibrary(...)` call exists anywhere in the app.
|
||||
|
||||
There are 165 `<wa-icon>` instances across 36 distinct names, so first paint of each view fires up to
|
||||
36 cross-origin requests. The icon module caches by URL, so it is bounded per session — but a
|
||||
desktop music player that is offline, on a captive network, or behind a firewall renders **no icons
|
||||
at all**, and cold start waits on fontawesome.com.
|
||||
|
||||
*Fix:* register a local icon library resolving to bundled SVGs (`src/assets/images/icons/` already
|
||||
holds a set), and add a `vite-plugin-static-copy` rule — the plugin is already a declared devDep
|
||||
(`package.json`) but is not referenced by `vite.config.mts`, and `dist/webawesome/` does not exist.
|
||||
|
||||
### M10 — 1.18 MB single chunk, no route-level code splitting
|
||||
|
||||
`frontend/vite.config.mts:16-22`, `frontend/index.ts:1-27`
|
||||
|
||||
Verified build (`vite build --outDir /tmp/yjbuild`):
|
||||
|
||||
```
|
||||
assets/main-BAFmIgXb.css 53.46 kB │ gzip: 7.48 kB
|
||||
assets/main-yB2fsiPY.js 1,183.64 kB │ gzip: 242.14 kB
|
||||
(!) Some chunks are larger than 500 kB after minification.
|
||||
```
|
||||
|
||||
`rollupOptions` sets only `input`; there is no `manualChunks` and no `import()` anywhere, and
|
||||
`index.ts` statically imports all 27 views, so every module is downloaded, parsed and
|
||||
**side-effect-evaluated** (every store singleton constructed, every `@customElement` registered)
|
||||
before first paint.
|
||||
|
||||
Sourcemap-attributed composition of the 1.16 MB of mapped output:
|
||||
|
||||
| bytes | source |
|
||||
|---|---|
|
||||
| 199 497 | `@awesome.me/webawesome` |
|
||||
| 76 008 | `components/autotag-view/autotag-view.ts` |
|
||||
| 52 828 | `components/explore-artist-details/…` |
|
||||
| 48 519 | `components/config-page/config-page.ts` |
|
||||
| 42 172 | `components/track-details/track-details.ts` |
|
||||
| 37 394 | `@lit-labs/virtualizer` |
|
||||
| 36 666 | `components/playlist-view/playlist-view.ts` |
|
||||
| 36 333 | `components/explore-album-details/…` |
|
||||
| 34 457 | `components/explore-view/explore-view.ts` |
|
||||
| 31 317 | `components/track-list/track-list.ts` |
|
||||
| 30 989 | `components/playlist-details/…` |
|
||||
| 30 661 | `components/cover-grid/cover-grid.ts` |
|
||||
| 30 180 | `wailsjs/go/models.ts` |
|
||||
|
||||
The startup-critical path is roughly `track-list` + `cover-grid` + `now-playing` + `audio-player` +
|
||||
`app-sidebar` + lit + virtualizer ≈ 200 kB. `autotag-view` (76 kB, the single largest app module),
|
||||
`config-page`, `explore-*`, `track-details`, `jobs-*` and `downloads-view` are all reachable only
|
||||
from a sidebar click.
|
||||
|
||||
*Fix:* replace the static imports in `index.ts` with `await import()` inside the `navigate` handler's
|
||||
`VIEW_TAGS` branch — the view is already created lazily there (`index.ts:120-127`), only the module
|
||||
is eager.
|
||||
|
||||
---
|
||||
|
||||
## Minor
|
||||
|
||||
### m1 — `.renderItem` / `.keyFunction` are new closures every render in two virtualized views
|
||||
|
||||
`frontend/src/components/artists-view/artists-view.ts:1298-1299`,
|
||||
`frontend/src/components/genres-view/genres-view.ts:1196-1197`
|
||||
|
||||
`LitVirtualizer` declares both as `@property()` with the default `!==` `hasChanged`
|
||||
(`@lit-labs/virtualizer/LitVirtualizer.js:48-54`), so a fresh arrow function marks the property
|
||||
dirty and forces the virtualizer's own render pass on every host update. `cover-grid.ts:1893-1894`
|
||||
and `track-list.ts:1936-1937` correctly bind the stable `this.renderGridEntry` /
|
||||
`this.renderTrackRow` — these two do not. (`keyFunction` is a fresh closure in all four; `repeat()`
|
||||
keying limits the DOM damage to re-evaluated templates for the visible window.)
|
||||
|
||||
*Fix:* hoist to bound class fields, as `cover-grid` already does.
|
||||
|
||||
### m2 — Serial N+1 binding calls behind "play these"
|
||||
|
||||
- `frontend/src/components/artists-view/artists-view.ts:945-971` — `GetAlbumsByArtist`, then
|
||||
`await GetAlbumTracks(album.ID)` **inside a `for` loop**. A 30-album artist is 31 sequential IPC
|
||||
round-trips.
|
||||
- `frontend/src/components/cover-grid/album-selection.ts:100-112` — same shape; Ctrl+A over 5 000
|
||||
albums is 5 000 sequential round-trips (partly mitigated by `albumFilePathCache`).
|
||||
- `frontend/src/components/genres-view/genres-view.ts:740-751` — one `GetTracksByGenre` per selected
|
||||
genre, all fired concurrently, each returning full track rows that are then deduped client-side.
|
||||
|
||||
*Fix:* add a single `GetTracksByAlbumIDs([]int64)` / `GetTracksByGenres([]string)` binding.
|
||||
|
||||
### m3 — Timers that survive because their view never unmounts
|
||||
|
||||
The cleanup is written correctly; it simply never executes for cached primary views.
|
||||
|
||||
- `frontend/src/components/downloads-view/downloads-view.ts:216-218` — a 30 s `setInterval` clock,
|
||||
cleared at `:226` in `disconnectedCallback`. Once Downloads is visited it ticks and re-renders the
|
||||
view for the rest of the session.
|
||||
- `frontend/src/components/now-playing/now-playing.ts:481,503` — `onScrollCycleEnd` schedules
|
||||
`startScrollCycle` (2 s) which schedules the scroll (1.5 s), indefinitely, so a long track title
|
||||
drives a state change + re-render every ~3.5 s forever while it plays.
|
||||
|
||||
*Fix:* drive these off the `view-hidden` class (a `MutationObserver` on the host, or an explicit
|
||||
`viewActivated`/`viewDeactivated` hook in `index.ts`) rather than connect/disconnect.
|
||||
|
||||
### m4 — Permanent global `mousemove`/`mouseup` listeners for drag interactions
|
||||
|
||||
`frontend/src/components/track-list/track-list.ts:1076-1077`,
|
||||
`frontend/src/components/now-playing/now-playing.ts:240-241`
|
||||
|
||||
Column resize and panel resize register document-level `mousemove` in `connectedCallback` and only
|
||||
remove it in `disconnectedCallback`. Both guard-and-return immediately
|
||||
(`track-list.ts:622-623`, `now-playing.ts:582-583`), so the cost is small, but they run on every
|
||||
pointer move anywhere in the app for the process lifetime and defeat the browser's ability to skip
|
||||
the listener entirely.
|
||||
|
||||
*Fix:* attach on `mousedown`, detach on `mouseup` — the standard drag pattern.
|
||||
|
||||
### m5 — `updated()` does unconditional DOM work every cycle
|
||||
|
||||
- `frontend/src/components/artists-view/artists-view.ts:417-420` and
|
||||
`genres-view.ts:409-412` — `updateSizeProperties()` writes 2 `style.setProperty` calls on the host
|
||||
unconditionally (`artists-view.ts:671-701`), and `ensureWheelListener()` does a
|
||||
`shadowRoot.querySelector` every pass just to check a boolean it already stores
|
||||
(`:611-629`). Both should be guarded on the value/flag they already track.
|
||||
- `frontend/src/components/now-playing/now-playing.ts:259-263` — `checkOverflows()` +
|
||||
`applyScrollDistances()` do 6 `querySelector`s and interleave `scrollWidth`/`clientWidth` reads
|
||||
with `style.setProperty` writes on every update, i.e. forced synchronous layout followed by
|
||||
invalidation, on a component that re-renders on every player-store change.
|
||||
|
||||
### m6 — O(total items) helpers on the selection hot path
|
||||
|
||||
`frontend/src/utils/selection-controller.ts:160-173`
|
||||
|
||||
`getSelectedKeysOrdered()` walks the entire item list (50 k `getItemKey` calls) rather than the
|
||||
selection. It is called from every context-menu action, every favourite toggle and every
|
||||
`dragstart` (`track-list.ts:1379-1400`), so starting a drag of one row costs a 50 k-iteration loop.
|
||||
|
||||
Related: `frontend/src/components/track-list/track-list.ts:1507-1520` —
|
||||
`openBatchTrackDetails` does `filePaths.map(fp => this.tracks.find(...))`, i.e. O(selection × total).
|
||||
"Select all → Edit tags" on 50 k tracks is 2.5 × 10⁹ comparisons and will hang the renderer.
|
||||
|
||||
*Fix:* keep an index-ordered selection, and build a `Map<FilePath, Track>` for the batch lookup.
|
||||
|
||||
### m7 — The queue list stays live at zero width
|
||||
|
||||
`frontend/src/components/queue-panel/queue-panel.ts:214-231` (`:host { width: 0 }` when closed),
|
||||
`:653-681`
|
||||
|
||||
`contain: layout style paint` limits the blast radius, but the `lit-virtualizer` inside still has a
|
||||
real height and `min-width: 300px`, so it renders and measures its visible window on every queue
|
||||
change even with the panel closed — and `updated()` calls `scrollToIndex()` (`:675`) on every
|
||||
current-index change, which is `element(i).scrollIntoView()` on a laid-out but invisible element.
|
||||
|
||||
*Fix:* render `nothing` for the list body when the `open` attribute is absent.
|
||||
|
||||
### m8 — Backend emits scan progress nothing listens to
|
||||
|
||||
`frontend/src/events.ts:34-35`
|
||||
|
||||
`LibraryScanStarted` and `LibraryScanProgress` are declared but have **zero** consumers in
|
||||
`frontend/src/`. During a 50 k-file scan the backend serializes and pushes a progress payload across
|
||||
the IPC for an empty listener set.
|
||||
|
||||
*Fix:* either wire them into a scan indicator or stop emitting them.
|
||||
|
||||
### m9 — Remote artist avatars in Explore load eagerly
|
||||
|
||||
`frontend/src/components/explore-view/explore-view.ts:1461-1465`
|
||||
|
||||
The artist avatar `<img>` has neither `loading="lazy"` nor `decoding="async"`, unlike the album card
|
||||
20 lines below (`:1515-1519`) which has both. Every artist in a search result starts loading
|
||||
immediately.
|
||||
|
||||
---
|
||||
|
||||
## Polish
|
||||
|
||||
### p1 — Dead dependency
|
||||
|
||||
`@lit-labs/signals` is declared in `frontend/package.json` but imported nowhere in `src/` or
|
||||
`index.ts`. Rollup tree-shakes it out of the bundle, so this is install-size only — but it also
|
||||
signals a state-management direction that was never taken, next to five hand-rolled
|
||||
`Set<Subscriber>` stores.
|
||||
|
||||
### p2 — Dead code carried in the bundle
|
||||
|
||||
`frontend/src/components/cover-grid/cover-grid.ts:1908-1962` — `renderSplitGrid()` is documented in
|
||||
its own comment as "Currently unreferenced (the single-grid path is the active rendering mode)",
|
||||
along with `getBeforeEntries`/`getAfterEntries`/`ensureSplitCache` and the `splitMode` branches that
|
||||
feed it. `cover-grid.ts` is 30.6 kB of the bundle.
|
||||
|
||||
### p3 — Store notify batching is inconsistent
|
||||
|
||||
`library-store`, `player-store`, `queue-store`, `job-store` and `download-store` all coalesce with
|
||||
`queueMicrotask` + a `notifyScheduled` flag. `search-store.ts:55-57` and `playlist-store.ts:133-135`
|
||||
do not. Lit batches the resulting `requestUpdate()`s anyway, so the impact is small, but the
|
||||
inconsistency is the kind that hides a real double-notify later.
|
||||
|
||||
### p4 — Empty library reads as "Loading tracks..." forever
|
||||
|
||||
`frontend/src/components/track-list/track-list.ts:1900-1902` branches on `this.tracks.length === 0`
|
||||
rather than a loading flag, so a genuinely empty (or fully filtered-out) library shows a permanent
|
||||
loading message. `libraryCtrl.tracksLoading` already exists for this.
|
||||
|
||||
### p5 — `selectAll()` compares sizes, not membership
|
||||
|
||||
`frontend/src/utils/selection-controller.ts:148` — `if (next.size === this._selectedItems.size) return;`
|
||||
short-circuits on cardinality alone. Same-size-different-membership is hard to reach today, but the
|
||||
guard is wrong as written; comparing against `this.host.getItemCount()` would express the intent.
|
||||
|
||||
### p6 — `ResizeObserver` on hidden views writes localStorage on every navigation
|
||||
|
||||
`frontend/src/components/track-list/track-list.ts:1079-1085` → `onHostResize` (`:1218-1243`) →
|
||||
`normalizeWidths` + `saveColumnWidths` (`:515-534`). `.view-hidden` is
|
||||
`visibility: hidden; height: 0` (`frontend/index.css:162-170`), not `display: none`, so hidden views
|
||||
stay in the layout tree and their `ResizeObserver`s fire on every navigation. Cheap (localStorage
|
||||
only), but it is work done for an invisible element.
|
||||
|
||||
---
|
||||
|
||||
## What is already right
|
||||
|
||||
Worth stating plainly, because it is most of the codebase and the findings above are the exceptions:
|
||||
|
||||
- **`track-list` is a well-built virtualized list.** Memoized filter/sort caches keyed on input
|
||||
identity (`:238-270`), delegated event handlers via `data-index` with zero per-row closures
|
||||
(`:1140-1157`, `:1290-1312`), a stable `renderItem`, an `_itemSize` hint that avoids
|
||||
lit-virtualizer's scroll-error correction (`:222-228`), RAF-throttled scroll persistence
|
||||
(`:1280-1291`), and an inline `<svg>` for the per-row favourite icon instead of a `<wa-icon>` that
|
||||
would fetch. All 50 k rows go through this path.
|
||||
- **`cover-grid` memoizes correctly** — `buildGridEntries()` is keyed on the filtered-albums array
|
||||
identity (`:906-926`), so the virtualizer's `items` reference is stable across re-renders, and its
|
||||
covers pick the right thumbnail tier with `loading="lazy" decoding="async"` and explicit
|
||||
`width`/`height` (`:1803-1814`).
|
||||
- **`queue-store` is delta-driven**, not snapshot-driven (`queue-store.ts:82-110`) — index, mode and
|
||||
track-list mutations each ride their own event.
|
||||
- **`job-store` is the model for a push store**: microtask-coalesced notify with a documented
|
||||
rationale, and it evicts cached logs for jobs the backend has forgotten
|
||||
(`job-store.ts:229-236, 263-276`).
|
||||
- **`favorites-store` is Set-keyed**, so `isFavorited` in a row render is O(1) (`:99-101`).
|
||||
- **`LibraryController`'s `changeGeneration` guard** correctly suppresses `requestUpdate()` when only
|
||||
a loading flag toggled (`library-controller.ts:33-47`) — exactly the granularity most of the other
|
||||
controllers lack.
|
||||
- **`genre-details` and `artist-details` reuse `track-list` / `cover-grid`** via `.externalTracks` /
|
||||
`.externalAlbums` instead of reimplementing a list — which is precisely the fix M5 asks for.
|
||||
- **Detail views are ephemeral** (`index.ts:143-147`), so their `disconnectedCallback` cleanup does
|
||||
run and their per-instance caches (e.g. `explore-artist-details`' three `Map`s) are collectable.
|
||||
The leaks in M7/M8/m3 are all on the *cached* primary views.
|
||||
|
||||
## Things I checked and found no problem with
|
||||
|
||||
Recorded so they are not re-audited:
|
||||
|
||||
- **`localeCompare` in sort comparators** (`track-list/columns.ts:15`,
|
||||
`cover-grid/cover-grid-types.ts:50-76`). Benchmarked 50 k-element sorts: bare `localeCompare`
|
||||
**16.5 ms** vs a hoisted `Intl.Collator.compare` **28.7 ms**. V8 already caches the default
|
||||
collator; hoisting one would be a pessimization. No finding.
|
||||
- **Repeated `addEventListener('visibilityChanged', this.onVisibilityChanged)` in
|
||||
`track-list.loadTracks()`** (`:1249-1254`). The handler is a stable class-field arrow, so repeat
|
||||
registration with the same type+function is a spec-level no-op. Not a leak.
|
||||
- **WebAwesome's autoloader `MutationObserver`.** `startLoader()` is exported from
|
||||
`webawesome.js` but never called by the app, so no global mutation observer is installed. (The
|
||||
icon CDN issue in M9 is a separate mechanism.)
|
||||
- **`layout shift` from row cover art.** Every list container has a fixed pixel box
|
||||
(`playlist-details.ts:984-998`, `columns.ts:61`, `cover-grid.ts:1809-1810`), so images do not
|
||||
reflow their rows.
|
||||
- **`job-store` / `download-store` growth.** Both bound their state to the backend snapshot and
|
||||
evict.
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user