`.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.
22 KiB
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 thejobs.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 localapplyJobsmap (autotag-view.ts:1180), which is discarded ondisconnectedCallback(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 returnsfalse. Quitting mid-apply cancelss.ctxand 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\
Loading tracks...
`. loadTracks() (track-list.ts:1242-1257) console.errors on failure and leaves this.tracksat[]`.
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.errors 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-flightpickingguard ononPick(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 thefinallythat 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 withisRemoving, 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
- C1 + C2 — playback failure is the app's core job; it currently fails mute.
- 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.
- M3, M2 — two permanent fake "loading" states.
- C4 + M1 + M8 — the three async-correctness bugs; all three are the same
version-guard fix, and
explore-view.tsalready contains the reference implementation. - C3 — register the autotag apply with
jobs.Registryand it inherits progress, cancel and the global indicator at once.