docs: close plan 009, and what a decision phase found
Two of Phase 2's three judgement calls were answered by reading the code rather than by choosing: there is no artist badge to make a button, and a track badge stops reading as noise the moment it means something. The third went the other way — `EntityRecording` reads like a placeholder and is real work.
This commit is contained in:
@@ -2264,3 +2264,65 @@ Six more things worth keeping:
|
|||||||
question. Same shape as `getCoverUrl()`, `track-index.ts` and
|
question. Same shape as `getCoverUrl()`, `track-index.ts` and
|
||||||
`page-header` — when a rule is written per call site, the call sites
|
`page-header` — when a rule is written per call site, the call sites
|
||||||
do not disagree, they are all incomplete in the same way.
|
do not disagree, they are all incomplete in the same way.
|
||||||
|
|
||||||
|
## A decision phase earns its keep by finding it was not a decision
|
||||||
|
|
||||||
|
Plan 009 phases 2 and 3: the badge becomes a button where it can act.
|
||||||
|
|
||||||
|
The generalisation: **the questions worth taking a phase over are the
|
||||||
|
ones the code can answer, and you cannot tell which those are without
|
||||||
|
asking them.** Phase 2 was written as three judgement calls. Two turned
|
||||||
|
out not to be:
|
||||||
|
|
||||||
|
- "An artist badge would commit a user to a whole discography" —
|
||||||
|
describing a badge that **does not exist**. `top-results-row` renders
|
||||||
|
`nothing` for an artist and no other site passes
|
||||||
|
`entity-type="artist"` to the component at all. Artist subscription
|
||||||
|
already had a labelled Follow button.
|
||||||
|
- "Should a track inside a requested album show something different" —
|
||||||
|
evaporated. It read as noise only while a plus on a track meant
|
||||||
|
nothing; once it means *want just this one*, the mixed row is the
|
||||||
|
interface working.
|
||||||
|
|
||||||
|
The third — whether a track can be requested at all — went the other
|
||||||
|
way and is the more useful lesson. **`EntityRecording` reads like a
|
||||||
|
placeholder and is load-bearing.** It would have cost nothing to rule
|
||||||
|
tracks out as unsupported, and `Reconciler.tracklistFor` has an
|
||||||
|
explicit branch for them whose comment explains that a one-entry
|
||||||
|
expected tracklist is what lets filename matching score a single-track
|
||||||
|
download at all. A feature removed by assumption leaves no trace that
|
||||||
|
it was ever there.
|
||||||
|
|
||||||
|
Five more things worth keeping:
|
||||||
|
|
||||||
|
- **A test that passes on the neutered build is not a test, and the
|
||||||
|
vacuous ones are the negative assertions.** "Keeps its click off the
|
||||||
|
card it sits on" asserted that nothing bubbled — free when there is
|
||||||
|
no button, since `?.click()` on null is a silent no-op. It passed on
|
||||||
|
the neutered build while its seven neighbours failed. It asserts the
|
||||||
|
click *did the thing it was swallowed for* as well now. Same family
|
||||||
|
as `overflow: hidden` permitting programmatic scrolling, and the tell
|
||||||
|
was identical: **it could not fail.**
|
||||||
|
- **A measured coordinate is stale before it is used.** The e2e gesture
|
||||||
|
read a bounding box the moment the search settled; cover art is still
|
||||||
|
arriving then and a card that grows moves the badge, so the click
|
||||||
|
landed on the card and opened the album — reported as *a failure to
|
||||||
|
file a request*, which is a different bug. A Playwright locator
|
||||||
|
re-resolves and waits for the element to stop moving. Prefer one to
|
||||||
|
`mouse.click(x, y)` whenever the thing being clicked is in a list
|
||||||
|
that is still loading, which is most lists here.
|
||||||
|
- **A fix moves its own assertions, and that is not churn.** Phase 1's
|
||||||
|
spec asserted the badge announced "… is queued for download". A
|
||||||
|
*control* is named after what activating it does, so two commits
|
||||||
|
later it is "Cancel the request for …". Naming a thing after its
|
||||||
|
state is correct right up until it grows an action.
|
||||||
|
- **An opt-in makes a redundancy visible.** The badge could have known
|
||||||
|
which pages have a "Want this" button; instead a call site passes
|
||||||
|
`request-mbid` or does not, so `explore-album-details`'s header
|
||||||
|
declines in its own template. The rule is greppable and the component
|
||||||
|
has no list of exceptions to go stale.
|
||||||
|
- **Verify a control with the gesture, not with the event.** A synthetic
|
||||||
|
`MouseEvent` does not prove hit-testing, and a `.click()` on a shadow
|
||||||
|
child does not prove the icon inside it is `pointer-events: none`.
|
||||||
|
Both were checked with a real mouse (`mousemove`/`mousedown`/
|
||||||
|
`mouseup`) and a real Tab/Enter before either was believed.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 009 — The badge that cannot act, and the state it already had
|
# 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.
|
**Status:** complete — all three phases shipped.
|
||||||
**Branch:** main
|
**Branch:** main
|
||||||
**Created:** 2026-08-13
|
**Created:** 2026-08-13
|
||||||
**Follows:** 008-the-last-audit
|
**Follows:** 008-the-last-audit
|
||||||
@@ -165,27 +165,112 @@ Six things, and the first is the plan's own framing.
|
|||||||
|
|
||||||
## Phase 2 — what a badge click means, per entity
|
## Phase 2 — what a badge click means, per entity
|
||||||
|
|
||||||
*(decision, before code — not started)*
|
*(Decided 2026-08-13, before any code.)*
|
||||||
|
|
||||||
What Phase 1 leaves for it, now as observations rather than guesses:
|
**A badge is a button where it is the only way to act, and what it
|
||||||
|
toggles is a request — never a download.**
|
||||||
|
|
||||||
- On the album page the badge and the "Want this" button now say the
|
Two of the three questions were answered by the code rather than by a
|
||||||
same thing twice, four centimetres apart. That is an argument for the
|
judgement, which is the point of asking them before writing anything.
|
||||||
badge being **read-only there** and clickable only where there is no
|
|
||||||
button — or for the button going.
|
**There is no artist badge, and there never was.** The worry that one
|
||||||
- A requested album shows an amber hourglass while every track in its
|
20 px circle would commit a user to a whole discography does not apply:
|
||||||
tracklist shows a plus, which is correct per the rule and reads as
|
`top-results-row` renders `nothing` for an artist, and no other site
|
||||||
busy. Worth deciding whether a track inside a requested album should
|
passes `entity-type="artist"` to this component at all. Artist
|
||||||
render *nothing* rather than a plus.
|
subscription already has a home — `explore-artist-details`'s
|
||||||
- An artist badge would mean a discography subscription, which is the
|
`renderFollowAction()`, a labelled button with the scope beside it,
|
||||||
heaviest commitment in the download subsystem behind the smallest
|
which is where a commitment that never completes belongs.
|
||||||
control in the app.
|
|
||||||
|
**A track badge is honoured end to end.** `EntityRecording` is not a
|
||||||
|
placeholder in the request model: `Reconciler.tracklistFor` has a
|
||||||
|
deliberate branch for it ("A track request is its own tracklist") whose
|
||||||
|
comment explains that the single expected title is what lets filename
|
||||||
|
matching score a one-song download at all. So a track badge promises
|
||||||
|
something the backend can keep, and it is a button too.
|
||||||
|
|
||||||
|
That also disposes of the second observation. An hourglass on an album
|
||||||
|
over a row of plusses read as noise while a plus meant nothing; once a
|
||||||
|
plus on a track means *want just this one*, the mixed row is the
|
||||||
|
interface working. No special case, and none of the four surfaces needs
|
||||||
|
to know what contains what.
|
||||||
|
|
||||||
|
**The album detail header keeps its badge read-only.** "Want this" sits
|
||||||
|
directly below it saying the same thing in words. The rule is not "a
|
||||||
|
badge is decorative on detail pages" — it is that a call site **opts in
|
||||||
|
by supplying the MBID to act on**, so a redundancy is visible in the
|
||||||
|
template rather than hidden in the component.
|
||||||
|
|
||||||
|
**And it is a request, not an acquisition.** The old copy said "Add …
|
||||||
|
to library", which 007 called the button's promise written into the
|
||||||
|
copy — and it would still be a lie, because clicking adds a row to the
|
||||||
|
request list and nothing to the library. The name is the action, in the
|
||||||
|
words the rest of the app already uses: **"Want …"**, and **"Cancel the
|
||||||
|
request for …"** when it is already wanted. No confirmation: the action
|
||||||
|
is one click to undo, which is the whole test for whether a dialog is
|
||||||
|
owed.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 3 — the button
|
## Phase 3 — the button
|
||||||
|
|
||||||
*(scope depends on Phase 2)*
|
Ships what Phase 2 decided: `request-mbid` as the opt-in, a `<button>`
|
||||||
|
where a call site passes one and the entity is not already owned, and
|
||||||
|
`toggleRequest()` beside `libraryStatusFor()` because
|
||||||
|
`explore-album-details`'s "Want this" asks the same question and two
|
||||||
|
implementations of *what wanting something means* is what Phase 1 was
|
||||||
|
about.
|
||||||
|
|
||||||
|
### Phase 3 — what actually shipped
|
||||||
|
|
||||||
|
Seven of the eight call sites opt in; the album header does not.
|
||||||
|
`make ui-test` 685 → **695**; `make e2e` 92 → **93**.
|
||||||
|
|
||||||
|
Verified in the running app with a **real mouse gesture and a real
|
||||||
|
keyboard path**, not a synthetic event: click the badge → the request
|
||||||
|
is filed, the badge becomes an hourglass, the album page does not
|
||||||
|
open. Tab → the badge takes focus with its own ring inside the card's;
|
||||||
|
Enter → same, and the card's own Enter handler does not fire.
|
||||||
|
|
||||||
|
Pinned by `library-status.test.ts` (+10, watched failing on the
|
||||||
|
pre-fix build — 8 of 18) and `requested-badge.spec.ts` (+1).
|
||||||
|
|
||||||
|
#### Where the plan was wrong — Phase 3
|
||||||
|
|
||||||
|
Five things, and the first two are the plan asking questions the code
|
||||||
|
had already answered.
|
||||||
|
|
||||||
|
- **Two thirds of the Phase 2 decision was not a decision.** "An artist
|
||||||
|
badge would mean a discography subscription" describes a badge that
|
||||||
|
does not exist — `top-results-row` renders `nothing` for an artist
|
||||||
|
and no other site passes `entity-type="artist"` at all. And "should a
|
||||||
|
track inside a requested album show something different" evaporated
|
||||||
|
the moment a plus on a track meant *want just this one*. A decision
|
||||||
|
phase is worth having; two of its three items were answered by
|
||||||
|
reading rather than by choosing, which is the cheaper half of it
|
||||||
|
working.
|
||||||
|
- **`EntityRecording` is load-bearing and reads like a placeholder.**
|
||||||
|
It would have been easy to rule tracks out as unsupported; the
|
||||||
|
reconciler has an explicit branch for them whose comment explains
|
||||||
|
that a one-entry expected tracklist is what lets filename matching
|
||||||
|
score a single-track download at all. Ruling it out would have been a
|
||||||
|
feature removed by assumption.
|
||||||
|
- **A test that passes on the neutered build is not a test.** "Keeps
|
||||||
|
its click off the card it sits on" asserted that nothing bubbled —
|
||||||
|
which is free when there is no button to click, since `?.click()` on
|
||||||
|
null is a silent no-op. It passed on the neutered build. It asserts
|
||||||
|
the click *did the thing it was swallowed for* as well now, and fails
|
||||||
|
there like the other seven.
|
||||||
|
- **A measured coordinate is stale before it is used.** The e2e gesture
|
||||||
|
read a bounding box the moment the search settled; cover art is still
|
||||||
|
arriving then, and a card that grows moves the badge, so the click
|
||||||
|
landed on the card and opened the album — reported as a failure to
|
||||||
|
file a request, which is a different bug entirely. A locator
|
||||||
|
re-resolves and waits for the element to stop moving.
|
||||||
|
- **A fix moves its own assertions.** Phase 1's spec asserted the
|
||||||
|
badge's name was "… is queued for download"; a control is named after
|
||||||
|
what activating it does, so it is "Cancel the request for …" now. The
|
||||||
|
spec was right when it was written and wrong two commits later, which
|
||||||
|
is the ordinary cost of naming a thing after its state.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -785,21 +785,57 @@ result would silently reorder a queue — and because `cover-grid`'s drag
|
|||||||
cache stores them per album. A `libraryID` of 0 means "every library",
|
cache stores them per album. A `libraryID` of 0 means "every library",
|
||||||
matching an unset library filter.
|
matching an unset library filter.
|
||||||
|
|
||||||
**A badge is not a button, and a control that cannot act is worse than
|
**A badge is a button only where it can act, and it says which.**
|
||||||
none.** `library-status-indicator` — the tick/plus on every Explore
|
`library-status-indicator` — the tick/hourglass/plus on every Explore
|
||||||
card and track row — was a `<button>` whose click handler was a
|
card and track row — was a `<button>` whose click handler was a
|
||||||
`stopPropagation()` and a comment saying to wire up the download client
|
`stopPropagation()` and a comment saying to wire up the download client
|
||||||
later: 20 of the 66 tab stops on a results page announced themselves as
|
later: 20 of the 66 tab stops on a results page announced themselves as
|
||||||
buttons and did nothing (46 and 0 after). It is `role="img"` with a
|
buttons and did nothing. 007 made it `role="img"` on the rule that a
|
||||||
label until there is something to click, and its unowned label says
|
control which cannot act is worse than none, and named the condition
|
||||||
"… is not in your library" rather than "Add … to library", which was
|
that would change the answer: a `<button>` again *with* a handler,
|
||||||
the button's promise written into the copy. When the download client
|
never a handler bolted onto something already shaped like one.
|
||||||
lands, the change is a `<button>` *with* a handler — not a handler
|
|
||||||
bolted onto something already shaped like one. Two smaller things came
|
It is that now, and three rules hold it up. **A call site opts in** by
|
||||||
with it: a `<span>` does not get `box-sizing: border-box` from the UA
|
passing `request-mbid`, so a redundancy is visible in the template
|
||||||
stylesheet the way a `<button>` does (the badge grew 36→38px, caught by
|
rather than hidden in the component — `explore-album-details`'s header
|
||||||
a stored screenshot), and with no click of its own the badge is part of
|
has "Want this" in words directly below it and does not opt in. **An
|
||||||
its card, so a click on it means what the card means.
|
owned entity is never a button**, because there is nothing left to ask
|
||||||
|
for, which is what stops the returned tab stops being spent on nothing.
|
||||||
|
And **the name is the action and the action is a request**: "Want album
|
||||||
|
X" / "Cancel the request for album X". Clicking adds a row to the
|
||||||
|
request list and nothing to the library, which is exactly what made the
|
||||||
|
original "Add … to library" a promise the control could not keep.
|
||||||
|
|
||||||
|
Two entities and not the third. A track is requestable because
|
||||||
|
`EntityRecording` is real work in the backend — `Reconciler.tracklistFor`
|
||||||
|
has a branch for it, since one expected title is what lets filename
|
||||||
|
matching score a single-track download at all. An artist is not: there
|
||||||
|
is no artist badge anywhere (`top-results-row` renders `nothing` for
|
||||||
|
one), and a discography subscription — never satisfied, expanding into
|
||||||
|
child requests — belongs on `explore-artist-details`'s Follow button,
|
||||||
|
which can say what it commits to.
|
||||||
|
|
||||||
|
Two smaller things, both still true: a `<span>` does not get
|
||||||
|
`box-sizing: border-box` from the UA stylesheet the way a `<button>`
|
||||||
|
does (the badge grew 36→38px, caught by a stored screenshot, and both
|
||||||
|
branches now set it), and the click is swallowed again — for the
|
||||||
|
opposite reason to before. With no action of its own the badge was part
|
||||||
|
of its card and a click on it meant what the card means; with one, it
|
||||||
|
does not. Enter and Space are stopped for the same reason, since every
|
||||||
|
card holding one is a `role="button"` or `role="option"` with its own
|
||||||
|
handler.
|
||||||
|
|
||||||
|
**Its third state was declared for a year and produced by nothing.**
|
||||||
|
`queued` was styled amber, given an hourglass and given the sentence
|
||||||
|
"… is queued for download", and all eight call sites were a two-way
|
||||||
|
ternary — so an album already on the request list showed a plus and
|
||||||
|
said it was not in the library, on the same page as a filled button
|
||||||
|
reading "Wanted". `utils/library-status.ts` is that rule written once:
|
||||||
|
`libraryStatusFor()` (owning outranks wanting; a *satisfied* request is
|
||||||
|
not queued, because nothing is coming; a request is by MBID, so a track
|
||||||
|
inside a requested album is not itself requested) and `toggleRequest()`
|
||||||
|
beside it, because the "Want this" button asks the same question and
|
||||||
|
two definitions of *what wanting means* is the fault this replaced.
|
||||||
|
|
||||||
**A grid moves by a row, and `offsetTop` cannot tell you how wide a row
|
**A grid moves by a row, and `offsetTop` cannot tell you how wide a row
|
||||||
is.** `utils/roving-grid.ts` measured columns by counting cards sharing
|
is.** `utils/roving-grid.ts` measured columns by counting cards sharing
|
||||||
|
|||||||
Reference in New Issue
Block a user