Files
yellowjacket/.planning/plans/completed/008-the-last-audit.md
yonluandClaude Opus 5 dc890d1fcc feat(library): remove a track from the library without deleting the file
RemoveFromLibrary deletes the audio_files rows the way the scan's own
orphan cleanup does and records each path in excluded_paths. The
exclusion is not an enhancement: without it the next scan finds the
file, sees no row and imports it again, so the button undoes itself.

The soft scan compares files on disk against rows in the database, so
surveyAudioFiles and countAudioFiles both take the exclusion set —
otherwise an excluded path makes the two disagree forever and queues a
full scan on every launch. Deleting a row cascades to queue_tracks, so
the removal calls the same CompactQueue hook RemoveLibrary does.

Also lands the requested badge: library-status-indicator is a button
again where it can act, utils/library-status.ts states once what owning
and wanting mean, and the long-declared queued state finally has a
producer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UDCbcCZQepnpSQYJ6SxxZm
2026-08-14 13:12:01 -04:00

34 KiB
Raw Permalink Blame History

008 — The last audit, and the one binding that outlived six phases

Status: complete — all four phases shipped. a11y.md is closed, and with it all four audits from 2026-08-11. Branch: main Created: 2026-08-12 Follows: 007-ui-reconciliation Source: .planning/audits/2026-08-11-ui/a11y.md (34 findings), plus one item inherited through all six phases of 007.

Problem

Three of the four audits from 2026-08-11 are closed. a11y.md is not, and it is the least verified material in the repo — 007's own notes say so twice, and every pass that touched it found the audit wrong about something.

Two things follow from that, and they are the shape of this plan.

The coverage map lies, and it lies in the direction of more work than exists. 007's map assigns a11y.7 (top-results cards are click-only divs) to Phase 6; the code has carried role="button" tabindex="0" and an Enter/Space handler since Phase 1. It is not alone. A grep pass over all 34 findings against the current tree closes at least five the map still shows open, and three of those (17, 19, 27) were closed by phases that were not about them.

And what survives is not evenly distributed. Of the ~13 that look open, three are genuine loss of function and the rest are Minor or Polish. One of the three — 15 — is a hard WCAG conformance failure that has been sitting under a "Major" heading being read as a nice-to- have.

Cutting across both: two items in this audit were never measured at all. Colour contrast is flagged borderline (--yj-text-tertiary on --yj-bg-surface ≈ 4.1:1 against 11 px text) with "that needs a real measurement" written next to it, and 007 parked it under "deliberately not planned — worth measuring before planning". The mouse-only resize handles (28) were dropped as "cosmetic preference, no function lost", which is a judgement made by reading. Both are claims with no number behind them, which by this repo's own standard is not a finding yet.

Separately, and not from any audit: tracklist.delete. Advertised in Settings as configurable, bound to nothing, and carried through six phases because it needs an operation that does not exist.

The triage, as of 2026-08-12

Grep-level against 1e4a4e6. Every row is a hypothesis — this is where the audit's claims are, not where the code is. Nothing here is fixed until it has been reproduced in the running app.

Closed (verified present in code): 1, 2, 3, 4, 5, 6, 7, 8, 12, 13, 16, 17, 19, 27, 33.

Closed by argument rather than by code, to confirm by reading: 20 (the type-scale/_itemSize coupling is now documented in tokens.css.ts, which is what the finding asked for), 31 (one of cover-grid's two <img>s has an alt; the finding named one).

Open:

# Level What the grep says
15 Major now-playing is not among the four files carrying prefers-reduced-motion. WCAG 2.2.2: moving content over 5 s with no pause mechanism.
14 Major combobox.ts has no aria-controls, no aria-activedescendant, no option ids.
11 Major No altKey handler in queue-panel. The other half of this finding — "no keyboard path to add a track to the queue or a playlist" — was closed by Phase 5's MenuKeyboard.
21 Minor body { height: 100vh; overflow: hidden } unchanged. WCAG 1.4.10. Shipped — and the stated mechanism was wrong; the failure is horizontal.
22 Minor queue-panel gained aria-current; track-list did not, and neither has a non-colour marker. Shipped.
24 Minor No title on the truncating element in track-info, playlist-view, queue-panel or track-list. Shipped.
25 Minor <wa-progress-bar value=…> with no label, verbatim as filed. Shipped — it was named "Progress", not unnamed.
new Two unnamed native <select>s, one of them page-header's sort control on nine views. False. The sort control is named "Sort: " by its wrapping <label> on all nine. The two unnamed roles were one config-field select and the seek bar. See Phase 3's list.
new 24 of 93 controls on Settings unnamed — every config-field select and toggle, all eighteen column checkboxes. Shipped, 0 of 93.
new Both wa-sliders have no accessible name, which a11y.md files under what is already correct. Shipped.
26 Minor Explore's search box is named by its placeholder only — which is an accname fallback, so an AX sweep reports it clean. search-bar was already fixed. Shipped.
28 dropped Measured, stays dropped. One header label clips at 800×600; zero data cells do.
29 Polish <h3 class="subtitle"> for type size. Shipped.
30 Polish No skip link anywhere. Shipped.
32 Polish title="Remove from queue", not identifying the track. Shipped.
34 Polish The 10 px sort arrow, unchanged. Shipped — half of it was closed by Phase 1's aria-sort.
Colour contrast, never measured. Measured and fixed in Phase 2.

Ordering principle

By what is lost, then by containment.

Phase 1 first because it is the only phase where something a user needs is unavailable: a marquee they cannot stop, a combobox that announces nothing while they arrow through it, and a queue whose order cannot be changed without a mouse.

Phase 2 second because both of its items are questions, and the answers change what Phase 3 contains. If the contrast measurement comes back below 4.5:1 it is a Phase 1 item wearing a Polish hat; if the resize handles turn out to lose function rather than preference, 28 stops being dropped.

Phase 3 is the tail, batched, because each item is a line and the cost is in the verification rather than the change.

Phase 4 is tracklist.delete, last, because it is the only work in this plan that can destroy a user's data and it should not share a pass with anything.


Phase 1 — The three that lose function

15 — the marquee cannot be stopped

now-playing's title and artist scroll continuously while a track plays when scrollMode === 'always' (persisted in localStorage), re-armed in a loop by onScrollCycleEnd.

Ships: a prefers-reduced-motion: reduce guard that treats always as never and disables the transition. The setting stays; the query overrides it, which is the right precedence — a stated OS-level accessibility preference outranks an app default the user may never have touched.

Reproduce first: the guard is two lines and will look like it worked whether or not it did. Check under an emulated prefers-reduced-motion in the running app, and check that the hover scroll (scrollMode === 'hover', the default) is also covered — the finding names always and the mechanism is shared.

Watch for: now-playing's geometry work in updated() keys on the two scroll flags, because .will-scroll .scroll-content carries padding-right: 2em and changing the class changes the distance the marquee travels. A guard that suppresses the animation without telling the geometry key will leave a stale measurement behind.

14 — the combobox announces nothing

role="combobox" aria-expanded aria-autocomplete="list" on the input and role="listbox"/role="option" below it, with no id on the listbox, no aria-controls, no aria-activedescendant, and no id on the options. aria-selected is used to mean "highlighted".

Ships: ids on the listbox and each option, aria-controls, aria-activedescendant tracking the highlight, and aria-selected meaning chosen.

Reproduce first: the a11y snapshot could not see a dialog's name and may not see this either — 007 lost twenty minutes to exactly that. CDP's Accessibility.getFullAXTree reports the computed value and where it came from; use it, not the snapshot.

11 — queue order cannot be changed without a mouse

Reordering is draggable="true" with the drop index computed from cursor Y. There is no keyboard equivalent and no aria- substitute.

Ships: Alt+ArrowUp / Alt+ArrowDown moves the focused queue item, on the roving tab stop utils/roving-rows.ts already gives that list, with a live region announcing the new position.

Watch for: Alt+Arrow is unmodified-adjacent but not unmodified, so focusedControlOwnsKey does not apply — this is a panel binding in backend/shortcuts/config.go, registered the way tracklist.* is, not a document listener. And the queue panel renders no list at all when closed, so anything asserting on it has to open it first.

This is the risky one. It is a new interaction model, it touches the backend shortcut table, and the drag path it parallels computes its drop index geometrically. It lands last in the phase, alone.

Verification

make ui-test per rule, and then run the existing tests — that is what has caught every bad version of a new rule in 007, including twice in the last pass. make ui-visual for 15 (it changes what renders). An e2e case for 11, because the queue panel's animated width means a click issued while it moves lands on whatever slid under the pointer. A manual pass per landing, with a screenshot read.

Phase 1 — what actually shipped

Three landings, one per finding, each reproduced in the running app before anything was written and each watched failing on the pre-fix build before being believed.

  • 15. shouldScroll() returns false under prefers-reduced-motion: reduce, live (a matchMedia listener, so changing the OS setting is honoured without a reload — verified). It covers hover as well as always.
  • 14. Ids on the listbox and every option, aria-controls, aria-activedescendant, and aria-selected meaning chosen rather than highlighted.
  • 11. Alt+ArrowUp/Down moves the focused queue row, with a live region saying where it went.

Pinned by now-playing.test.ts (+2), combobox-aria.test.ts (5), queue-reorder.test.ts (7), e2e/specs/reduced-motion.spec.ts (2) and e2e/specs/queue-reorder.spec.ts (4). make ui-test 558 → 572; make e2e 68 → 74.

Where the plan was wrong — Phase 1

Nine things, and the first group is the triage being right for the wrong reason.

  • The grep triage was accurate about what is open and wrong about why two of them are. It is a good first pass and it cannot see mechanism. 15 is filed as "no reduced-motion guard", which is true; what makes a CSS-only guard wrong is that the cycle is a transition out, a transitionend and a transition back, so suppressing the animation strands the text off its own box with nothing to bring it back. That is only visible by reading the cycle.
  • A fix routes people into a state nobody has looked at. With the marquee off, the fallback hard-clipped — "Overlong Trac|", no ellipsis — because text-overflow was on the outer span while the overflowing box is the inline-block child. It had never produced an ellipsis in any mode, including the default, and no test saw it. Found by reading the screenshot of the fix.
  • …and fixing that broke the measurement it depends on. Giving the child its own overflow: hidden stops the parent overflowing, so titleOverflows went false and nothing would ever have scrolled again, for anyone. Caught by the new test's positive case, which is the whole reason it has one.
  • a11y.6 scanned <button>, and says so. "The only truly unnamed controls" is a claim about buttons. The AX tree has two unnamed combobox roles that are native <select>s — one of them the page header's sort control, on nine views. Not fixed here; it is a sweep of every form control, not a one-liner, and it belongs with a11y.26 in Phase 3.
  • The reproduction of the fix was wrong twice, on the probe side both times. Reading activedescendant out of the AX tree as relatedNodes[0].text returned (none) on a working build — the property is there, with value.type: "idref". And last('QueueChanged') returned a stale payload, so a reorder that had happened looked like one that had not. Ask GetState, dump the whole property.
  • 11's stated scope is half done and the other half was already closed. The finding is "drag-and-drop has no keyboard equivalent anywhere" and lists four sites; its stated symptom — "there is no keyboard path to add a track to the queue or a playlist" — was closed by Phase 5's MenuKeyboard. What was left is the queue's order, which is the one the menu cannot express. Album→queue drag and drop-on-nav-item remain, and are menu commands, not reorder.
  • The plan said a backend panel binding; it should not be one. The queue panel already handles Enter and the roving arrows in its own delegated (not document) keydown, which is the sanctioned pattern. Alt+Arrow joins them: it cannot collide with the global Up/Down volume bindings (measured — 0 VolumeChanged events from a focused row), and it keeps a reordering key out of a user-editable table where it could be rebound onto something unmodified.
  • The index arithmetic is not symmetric, and the symmetric version fails silently. MoveQueueTracks takes an index into the array before the move, so down-by-one must ask for i + 2; i + 1 is where the row already is once its own removal is accounted for, and the backend's contiguous-block guard correctly returns without doing anything. Pinned in both tiers.
  • focusedIndex was only ever moved by an arrow key. A row reached by a click or by Tab left it at 0, so Enter played the first track in the queue from any focused row. Pre-existing, invisible until a key moved something, fixed by reading the index off the row the event came from.

And one that is not about the audit: the backtick-in-a-css-comment trap cost a cycle again, in the same session as reading the warning about it twice. It is worth treating as a lint rule rather than a piece of knowledge.


Phase 2 — The two that were never measured

Neither is a fix. Both are a number, and the number decides whether there is work.

Colour contrast. Measured against the rendered app, not against the token file: the tokens are what a component may use, and what matters is the pairs that actually appear. Sample the real computed colours at the real sizes, report the ratios, and only then decide. The audit's own number (≈ 4.1:1) is a hand calculation from two hex values and has the status of a hypothesis.

a11y.28, the resize handles. Four of them: the sidebar, the queue panel, the now-playing column, and the track-list column resizers. "Cosmetic preference, no function lost" is the claim to test. The track-list one is the suspicious member — a column narrowed to its floor clips its label (007 phase 5 found "Durat…" at 800 px), so widening a column may be the only way to read a value, which is function.

Record both outcomes either way. A measurement that closes a finding is worth as much as one that opens it, and this plan's predecessor got about a third of its value from findings that evaporated.

Phase 2 — what the measurements said

One opened much wider than filed; one closed.

Contrast: worse than "borderline", and it was never one token

The audit's ≈ 4.1:1 was a hand calculation from two hex values, and plan 007 filed it under "deliberately not planned — worth measuring before planning". Measured against the rendered app across twelve views and then across all three ramps: 110 failing nodes, and textTertiary failing AA in nine of twelve text/surface combinations — 4.35:1 on dark's surface, 3.25:1 on its elevated, 2.31:1 on its overlay, and 2.553.32:1 on every surface of the light ramp, which the audit never considered.

Fixed, and now 0 of 659 nodes on dark and darker. Three mechanisms, only the first of which is the finding:

  • The ramps. textTertiary per ramp — #a6a6a6 / #949494 / #5c636a — sized to the lightest surface it actually sits on and keeping its hue.
  • The avatar generator, which is not a colour but a family of them: hsl(hue, 45%, 35%) behind white initials failed for 35 of 360 hues, so which artists were unreadable depended on how their names hashed. 32% clears every hue.
  • Jobs' local #ff6b6b, 4.15:1 on elevated.

Pinned by theme-contrast.test.ts and avatar-color.test.ts — unit tests over the data, not sweeps of the DOM. make ui-test 572 → 608.

a11y.28: the drop was right, and now for a measured reason

"Cosmetic preference, no function lost" holds. At the window minimum (800×600, which is where the shell was measured in 007) the track list clips exactly one thing: the Duration header label. Zero data cells clip, and the sort that label names has a redundant keyboard-reachable dropdown. The queue panel at its default 321px clips nothing either. A keyboard-only user cannot change a panel width; they do not lose access to any value by not being able to. Stays dropped.

Two things the measurements found that are not in the audit

Both were bigger than what they were found under. Both are now fixed — see the third landing below.

  • The semantic colours were fixed across ramps, and a fixed colour cannot serve a near-black and a near-white background. --yj-error measured 3.42:1 on dark's surface and 2.55:1 on its elevated; --yj-info 3.10:1 and 2.31:1; success and warning failed on dark and light both. As backgrounds under white text, success (3.45) and warning (3.58) failed too.
  • The light ramp was not a usable theme. With the greyscale fixed it still had 50 failing nodes: the accent yellow under white text (1.43:1) and the autotag diff's pale greens and reds on white (1.362.59:1).

Phase 2, third landing — the ramp reaches the semantic colours

2237 nodes across three ramps and twelve views, 0 failing.

The split is by the question a colour answers. A fill is "what colour is a danger button" — red in every theme, unchanged. A text colour is "what colour is the word failed on this background" — per ramp, because one value cannot clear 4.5:1 against both a near-black and a near-white surface. bgOverlay keeps the exception it already had on the dark ramp.

And every fill now carries a computed foreground, because the accent is a colour picker and no fixed answer survives one: white if white clears 4.5:1, else black. That keeps a red danger button white and flips a green or amber one to black. Accent-as-text goes through accentTextOn(), which mixes along the hue until it clears the ramp's surface and stops — returning the accent unchanged on both dark ramps, so the dark themes are visually untouched by that half.

make ui-test 608 → 649.

Where this pass was wrong

  • "The chrome stays dark while the body goes light" was mine, and it was false. I read it off a screenshot; the DOM says .top-bar is #e9ecef and .sidebar #f8f9fa under the light ramp, and a re-taken screenshot agrees. The first one was captured before the theme had propagated. Third time in two passes that a screenshot read at the wrong moment produced a confident wrong claim — and the second time this pass that the picture and the number disagreed and the number was mine.
  • A color: regex matches border-color:. Twice: once rewriting semantic text colours (3 borders) and once rewriting accent text (30 more). A border is a fill, not text. Caught by grepping the result rather than by any test, because nothing renders differently enough to fail.
  • Two accent buttons took their foreground from --yj-bg-base, which inverts with the ramp — white on yellow at 1.43:1. That is not a colour that was chosen badly; it is a token used for the wrong meaning, and it only shows up in the theme nobody looks at.

Phase 3 — The tail

21, 22, 24, 25, 29, 30, 32, 34, plus whatever Phase 2 promotes or closes. One landing, batched, each item confirmed against the code before it is touched.

Two of them are not one-liners and should be treated as such:

  • 21 (the shell is 100vh; overflow: hidden) is a layout change to the app frame, and 007 phase 5 already measured the frame's real minimum at 800×600. Reflow at high zoom is the same question one variable over. It may want its own landing.
  • The unnamed <select>s from Phase 1, with 26.
  • The semantic palette and the light ramp — both landed in Phase 2's third pass rather than waiting for this tail.
  • 22 asks for a non-colour marker on the playing row, which is a visual change to the densest list in the app and moves a baseline.

Phase 3 — what actually shipped

Six landings rather than one, ordered by risk, each reproduced in the running app before anything was written and each watched failing on the pre-fix build.

  • Web Awesome's two hidden roles. label on both wa-sliders and on wa-progress-bar (25), plus styles/wa-slider-label.css.ts, which hides the slider's visible label by part and puts back the 8px margin #slider takes as soon as one exists.
  • Settings' form controls. for/id in config-field, aria-label on the eighteen column toggles and thirty-six column arrows, and the action's name on every shortcut-capture. 24 unnamed of 93 → 0.
  • 24 and 32. title on the four clipping surfaces, on the track-list cell rather than on what is inside it; and a queue row's remove button named after its own track.
  • 29, 30, 34. A skip link, <h3><p>, and the sort arrow at the type scale's floor. Plus the state that landed in: the hgroup measured 67px in a 64px bar and the subtitle's descenders were clipped once the h3's bottom margin went with it.
  • 22. A triangle in each row's own left padding, in both lists, and aria-current on the track-list row.
  • 21. overflow-x: auto — measured, and the finding's stated mechanism is not the one that exists.

And 26's remaining half, found last: Explore's search box.

Pinned by wa-control-names.test.ts (4), settings-names.test.ts (11), aria-tail.test.ts (+5), queue-reorder.test.ts (+3), e2e/specs/control-names.spec.ts (3), e2e/specs/skip-link.spec.ts (4), e2e/specs/layout-overflow.spec.ts (+6) and e2e/specs/playback.spec.ts (+1). make ui-test 649 → 672; make e2e 74 → 88.

Where the plan was wrong — Phase 3

Ten things. The first four are the audit or the plan being wrong about where a control's name lives.

  • The two unnamed <select>s from Phase 1 were one <select> and a slider, and neither was the page header's. page-header's sort control computes "Sort: " from its wrapping <label>, on every one of the nine views — checked with getFullAXTree, from: relatedElement. The other unnamed role was the seek bar, which a11y.md lists under what is already correct. Fourth probe error in two passes, and the same shape as the rest: read at the wrong level.
  • aria-label on a Web Awesome host does not name the control. wa-slider puts role="slider" on a div in its own shadow root pointing aria-labelledby at an empty internal <label>, and that IDREF outranks the host's aria-label. Both sliders computed "". Exactly wa-dialog's trap one component over, and the audit made exactly the same mistake in the opposite direction — it read the source and credited a name that was never computed. volume-control did not even have the aria-label it is credited with.
  • a11y.25 is not "unnamed". wa-progress-bar falls back to the localised word progress, so it announced "Progress, 45%" — named after the widget rather than after the work. Same fix, smaller claim.
  • Settings was full of unnamed controls and no finding says so. 24 of 93. a11y.6 is not wrong: it says in its own line that it scanned every <button>. Third time this pass that a count in the audit was answering a narrower question than it reads as.
  • A placeholder is an accessible name. Explore's search box therefore reported clean in an AX sweep of all eleven views, which is why a11y.26 outlived four phases of people looking for exactly this. A sweep for empty names cannot see a weak one.
  • a11y.21's mechanism does not exist. "The 4em bars grow while the viewport does not, and anything that no longer fits is clipped with no scrollbar" — the middle row is 1fr and absorbs them exactly. At 200% text on 800×600 the bars go 64 → 128 and the panel 472 → 344, footer still on 600. The real failure is horizontal, which the finding does not mention: 784px of app in a 320px viewport, 464px of it unreachable.
  • …and the obvious probe for it passes on the broken build. overflow: hidden still permits programmatic scrolling, so scrollLeft = 9999 returns a healthy number on the build with the bug. It did. The spec is a wheel gesture now.
  • A fix's own test was pinning the bug. transport.test.ts asserted aria-label on the wa-slider host and called it "carries an accessible name". Running the existing suite is what found it, for the third plan running.
  • a11y.34 was half closed by Phase 1 and nobody had noticed. "The sort direction is a 10px glyph or nothing" — it is announced now, via the aria-sort Phase 1 added. What was left is one declaration.
  • The queue's aria-current is dead in the common path. A track started from the track list leaves the queue's currentIndex at 1, so the panel has no current row at all — which is why 22's marker looked broken the first time it was checked in the running app. Pre-existing, not fixed here, and the reason the e2e case plays from the queue.

And one that is about the harness rather than the audit: a synthetic MouseEvent does not reach a delegated handler the way a real gesture does. Three probes in a row reported the queue row as never becoming active; page.getByTestId('queue-row').dblclick() made it active immediately. Same family as everything above — the probe was wrong, not the code.


Phase 4 — tracklist.delete, and the operation behind it

The decision

(Decided 2026-08-12, before any code.)

"Remove from library" removes the database row and excludes the path from future scans. It does not touch the file.

The comment at backend/shortcuts/config.go:36 states the fork exactly: the row (which the next scan puts back unless the path is also excluded) or the file (a delete-your-music button one keystroke from a focused row). Three shapes were considered:

  • A — row + path exclusion. Reversible, needs an exclusions table, so a schema file and a migration.
  • B — delete the file, to the platform trash. Real user intent for an app with duplicate detection, genuinely destructive, and a new cross-platform dependency.
  • C — ship the operation as a menu command only, leave Delete unbound.

A, delivered as C, and then the keystroke. Without the exclusion, A is a button that undoes itself on the next scan, which is worse than no button — so the exclusion is not an enhancement, it is what makes the operation mean anything. Delete is bound only to open the confirmation, never to perform the removal: that makes the keystroke a request rather than an action, which is the only version defensible one key from a focused row.

B is not foreclosed and is not in this plan. It deserves its own argument.

What ships

  • An exclusions table, following the two-file schema discipline (sql/schemas/ for the target shape, sql/migrations/ for the existing install, column order matching, no index on a migrated column in the schema file).
  • RemoveFromLibrary(filePaths) — rows deleted, paths excluded, one event carrying enough for the stores to patch rather than invalidate. It is a write, so it goes through ExecContext, not the read pool.
  • A context-menu command behind confirmAction(), with impact copy naming the count and saying explicitly that files on disk are not touched.
  • tracklist.delete re-advertised, bound to opening that dialog.
  • The scanner honouring the exclusion list, which is the half that makes the rest true.

Verification

A Go test that a removed path survives a rescan; an e2e case that the row is gone, the dialog said so, and the file still exists. Both halves matter — the second is the promise the copy makes.

Phase 4 — what actually shipped

Three landings, in the order the plan proposed, each watched failing on the pre-fix build by neutering one line rather than stashing.

  • The schema, RemoveFromLibrary, and the scanner honouring the list. excluded_paths (one file — see below), rows deleted the way the scan's own orphan cleanup deletes them, TracksRemovedFromLibrary carrying {filePaths, count}, and both of the scanner's walks taking the exclusion set.
  • The context-menu command, behind confirmAction() with an impact line that says the files are not deleted, plus library-store splicing rather than invalidating.
  • tracklist.delete, bound to opening that dialog, and the e2e case.

Pinned by remove_tracks_test.go (6), library-store.test.ts (+4), keyboard-shortcuts.test.ts (+1) and e2e/specs/remove-from-library.spec.ts (2). make ui-test 672 → 677; make e2e 88 → 90.

Where the plan was wrong — Phase 4

Six things, and the first two are the plan asking for work that does not exist and skipping work that does.

  • "Following the two-file schema discipline" is wrong for a new table. applySchema runs every file in sql/schemas/ on every open, so a CREATE TABLE IF NOT EXISTS reaches an existing install verbatim; the migration file the plan asked for would have been a second description of the same table, which is the one thing the checklist's third rule forbids. Column order and "no index on a migrated column" do not apply either — nothing is being added to an existing table, so the index lives beside its own CREATE TABLE.
  • The half that would have undone the feature is not in the plan. The startup soft scan decides "library unchanged" by comparing files on disk against rows in the database. An excluded path is on disk and deliberately not a row, so the two counts disagree forever and every launch queues a full scan of the whole library. Both walks take the exclusion set now. Nothing in any tier would have caught it: it is not a wrong answer, it is a permanent, invisible re-scan.
  • …and neither is the queue. Deleting an audio_files row cascades to queue_tracks, so the queue's in-memory copy — and possibly the playing track — goes stale. RemoveLibrary has had the CompactQueue hook for exactly this since it was written; the removal reuses it.
  • The plan says nothing about undo, and the operation needs one. An exclusion with no UI to clear it is a one-way door: the file is on disk and the user cannot get it back. A full rescan clears the table, which is the escape hatch until there is a list to manage. Recorded rather than implied, because it is the difference between "reversible" (shape A's stated advantage) and a claim.
  • A new table has a second gate nobody remembers. backend/datamap catalogues every table's Kind and Lifetime, and two of its tests fail on a new one: TestCatalogCoversSchema for the missing entry, then TestAuthoredCascadesAreDeliberate because an authored table that cascades needs an argued exemption. Both are right to ask; neither is mentioned in references/schema-change.md.
  • The copy was wrong in the first screenshot, and only there. The title was singular and the body said "They are removed" — the message and impact strings were written for the multi-select case and used for both. Nothing failed. Found by reading the PNG, which is now the sixth regression in four plans that only a PNG has caught.

And one about the harness rather than the work: a hook gets 30 seconds, not the test's timeout. The e2e case's afterAll restore passed in isolation and timed out in the full suite, where earlier specs have staged an explore catalog and the restore takes longer than the hook's default budget. test.setTimeout() inside the hook is what raises it.


Deliberately not in this plan

  • Splitting explore-view.ts (1 900 lines). The shelves are in it because a separate component would need its own art fetching and therefore its own cache, cap and probe, and perf.M7 exists because that view never unmounts. The reasoning holds; the size is the price.
  • A make perf before/after for Phase 6's shelves. Both seeds get their catalog from the artifact rather than from the seed tarball, so a before and an after are not the same corpus unless the e2e staging fixture is extended to bulk scale. Recorded as unmeasured in 007 rather than implied to be free.
  • The unowned badge's + glyph. It becomes correct the day the badge becomes a button. Changing it now touches four components' visual baselines for a call better made then.
  • The albums shelf leading with one act. Needs dump-side data to express "these eight artists are one group and its solo members". A plan, not a fix.
  • WebKit2GTK-specific behaviour (page zoom in the Wails shell, how Orca traverses the virtualizer's windowed DOM). Only answerable on the real shell, and CI is the only place WebKit runs.

First step

Phase 1, and within it 15 — reproduced under an emulated prefers-reduced-motion before the guard is written, because a two-line CSS change looks identical whether or not it worked, and this plan's predecessor met that failure in seven different costumes.