The plan's own framing was wrong in a way worth keeping: the badge was not waiting on the download client, which had largely landed already — it was waiting on somebody looking at a state nothing produced.
9.9 KiB
009 — The badge that cannot act, and the state it already had
Status: active — Phase 1 shipped; Phase 2 is a decision, not written yet. Branch: main Created: 2026-08-13 Follows: 008-the-last-audit
Problem
007 phase 6 turned library-status-indicator from a <button> that did
nothing into a role="img" badge, on the rule that a control which
cannot act is worse than none, and wrote down what would change the
answer: "when the download-client integration lands, the right change
is to make it a <button> again with a handler."
Two things about that are wrong, and both were found by reading the code and then the running app rather than the note.
The download client has largely already landed. backend/download
is 16 541 lines: a durable request model with four entity types
(artist / release-group / release / recording, request.go), a
reconciler, a staging importer, six provider adapters, 20 bound methods,
downloads-view, the download-picker dialog, and a working "Want
this" toggle on explore-album-details. What has not landed is the
badge.
And the badge is not merely inert — it is wrong. LibraryStatus
declares, styles and labels a third state, queued ("… is queued for
download"). Zero of the eight call sites ever produce it
(explore-view:1839,1877, explore-artist-details:2116,2228,2323,
explore-album-details:1641,2283, top-results-row:258 — every one is
a two-way ternary). So an album the user has already requested
displays a plus and says it is not in their library.
Reproduced, 2026-08-13, before anything was written
Against SEED=default with the real 900 000-row catalog:
AddRequest({mbid: e51c54ea…, entity: 'release-group'}) for GOLDEN by
Jung Kook, then Explore → search "GOLDEN":
status not-in-library
icon plus
aria Album "GOLDEN" is not in your library
and on the album's own detail page, forty pixels apart in the same screenshot: the button reads "Wanted" (filled) and the badge beside the title reads plus / "is not in your library". One component, two surfaces, opposite answers. This is the header-badge-contradicting- Settings failure again, and again only a PNG showed it.
The same PNG showed a second one, which is why it is in this plan:
bookmark-check is not a bundled icon. window.__yjIconMisses
reports exactly ["bookmark-check"], so the "Wanted" button renders the
fallback question-mark glyph. e2e/specs/offline-icons.spec.ts asserts
that array is empty and passes, because no spec has ever put the app in
a state where an album is requested — precisely the "twenty call sites
compute their icon name from state" case names.txt exists for.
Ordering principle
By what is a fact and what is a decision.
Phase 1 is a bug: the badge contradicts the app's own state, and fixing it needs no interaction design at all. It also produces the evidence Phase 2 needs — once the badge can say "requested", whether it must also become requestable is a question that can be looked at rather than assumed.
Phase 2 is a decision made before any code, in the shape 008 phase 4
used, because one 20 px circle would otherwise mean three different
commitments: on an artist card a discography subscription
(scope: 'future', Expands(), never satisfied), on an album a
release-group request, on a track row a recording request.
Phase 3 is whatever Phase 2 leaves. "Album only" is a legitimate outcome and shrinks this plan rather than inventing work for it.
Phase 1 — the badge tells the truth
Ships:
utils/library-status.ts— one definition of the rule, since the reason all eight sites are two-state is that the rule is written at all eight. Owning something outranks wanting it, soin-librarywins overqueued.- The eight call sites using it.
explore-viewgaining thedownloadStoresubscription both detail views already have (init()+subscribe()), throughview-lifecycle— it is a cached primary view, so a rawconnectedCallbacksubscription would live for the session.bookmark-checkinsrc/icons/names.txt, and an e2e case that reaches the state that exposes it.
The badge stays role="img". Telling the truth is not acting.
Watch for: downloadStore.init() fetches providers, descriptors,
downloads and requests, so this warms a singleton on a page that
previously did not construct it — "a store with no subscriber fetches
nothing" cuts the other way here, and the cost belongs in the note.
Phase 1 — what actually shipped
Three landings. make ui-test 677 → 685; make e2e 90 → 92.
- The rule, written once.
utils/library-status.ts, the eight call sites, andexplore-view's subscription. - The Pro icon.
regular/bookmark/solid/bookmark, vendored. e2e/specs/requested-badge.spec.ts, which is also the first spec that reaches the state the icon sweep needed.
Pinned by library-status.test.ts (8) and requested-badge.spec.ts
(2). Both e2e cases were watched failing on the pre-fix build by
neutering one line each — the badge reported not-in-library where
queued was expected, and the sweep returned ["bookmark-check"].
Where the plan was wrong — Phase 1
Six things, and the first is the plan's own framing.
- "When the download client lands" had already half happened, and
the note that said otherwise was written before it. 007 phase 6
left a condition ("make it a button with a handler") that reads as
future work;
backend/downloadwas 16 541 lines and 20 bound methods at the time it was written. The badge was not waiting on the download client. It was waiting on somebody looking. - The bug was one layer below the one in the plan. The plan says the badge cannot act. What the reproduction says is that it could not even report — three states declared, two produced, at eight sites none of which knew about the third. "A control that cannot act" and "a control that is wrong" are different faults and only the second one is a lie.
- The second bug was in the screenshot of the first. The "Wanted"
button rendered a question mark, which is the missing-icon fallback:
bookmark-checkis a Pro name. It has been that way for as long as anything could be requested, andoffline-icons.spec.ts— which exists to assert exactly this — passed throughout, because it never reached a state where an album was requested. Seventh regression in five plans that only a PNG has caught, and the first one caught in a PNG taken of a different bug. - A sibling component does not hear its host re-render.
top-results-rowtakesresultsas a property;explore-viewre-rendering hands back the same array, so Lit stops at the property and the row keeps its old badges. Same shape as the virtualizer rule one level milder, and the fix is the same: subscribe where the state is read. - The cleanup ran on a page that could not run it.
afterAllusedcallBinding, which goes throughwindow.__yjEvents— installed by theappfixture and not bybrowser.newPage(). It threw where nothing was watching, left the request behind, and failed the next run of the same spec with a stalequeued. A spec that gives state back has to be checked by running it twice, which is what found this. - A freshly launched app cannot search its own catalog for ~40 s.
The core artifact merge (
core artifact: merge completein.dev/app.log) has to land first, and until it does Explore's search returns nothing — including for rows staged directly intoexplore_indexa moment earlier, which is what makes it look like a staging bug. It cost a cycle here reading as a failure of the neuter it was run under.
Phase 2 — what a badge click means, per entity
(decision, before code — not started)
What Phase 1 leaves for it, now as observations rather than guesses:
- On the album page the badge and the "Want this" button now say the same thing twice, four centimetres apart. That is an argument for the badge being read-only there and clickable only where there is no button — or for the button going.
- A requested album shows an amber hourglass while every track in its tracklist shows a plus, which is correct per the rule and reads as busy. Worth deciding whether a track inside a requested album should render nothing rather than a plus.
- An artist badge would mean a discography subscription, which is the heaviest commitment in the download subsystem behind the smallest control in the app.
Phase 3 — the button
(scope depends on Phase 2)
Deliberately not in this plan
- Deleting the file from disk (008 phase 4's explicit sequel). Not
refused — mis-ordered. 008's own notes record that the reversible
option shipped with nothing implementing its reversibility:
excluded_pathshas no management surface, and "a full rescan clears it" is the escape hatch. Shipping an irreversible delete beside a reversible one that cannot yet be undone is backwards, and the platform trash is a new cross-platform dependency besides. a11y.20, deriving_itemSizefrom a measured row. Real and confirmed in code —.track-rowisheight: 33px; contain: strictwith aremfont size, so text scales and the box does not, across four lists (33 / 49 / 45 / 45 px). It waits because its only honest verification does not exist yet: both surviving comments (track-list.ts:349,queue-panel.ts:179) say a wrong_itemSizedesynchronises the native scrollbar at 20k+ rows, andmake perfhas no scroll-fidelity row. That measurement is its own first phase and belongs to a plan that is about it.
First step
Phase 1, and within it the helper rather than the call sites — the reproduction above is already the failing case, and the point of the helper is that there is one place for the next state to be added.