diff --git a/.pi/skills/yellowjacket-dev/SKILL.md b/.pi/skills/yellowjacket-dev/SKILL.md index 23ccda8..7d8cc04 100644 --- a/.pi/skills/yellowjacket-dev/SKILL.md +++ b/.pi/skills/yellowjacket-dev/SKILL.md @@ -18,7 +18,7 @@ here has disappeared. ## Read this part before you fail -Five things cost a cycle each the first time. They are here, not in a +Seven things cost a cycle each the first time. They are here, not in a reference, because you need them *before* the failure, not after. - **Time out every binding call.** A bound Go method called with wrong @@ -41,6 +41,25 @@ reference, because you need them *before* the failure, not after. the real `AddLibrary` binding and waits for the real scan. - **Playwright's WebKit does not run on Arch** (Ubuntu-only libs). `--browser=webkit` is CI-only; local work is Chromium. +- **`make e2e` needs `SEED=default`.** Its specs assert on fixture + content — unicode tracks, the fixture artists, a known playable file. + Run against the `bulk` seed a measurement session left behind and 13 + of 36 fail, in a list that reads exactly like a regression in + whatever you are holding. `make dev-headless SEED=default` first. +- **A frontend edit is not live until you restart the app.** Vite + updates the module, but an already-registered custom element class + cannot be re-registered, so a running page keeps the old one and your + change reads as having done nothing — including across a browser + reload. `make dev-stop && make dev-headless SEED=…`, then re-check. + The nastier version: a **build error leaves the dev server serving + the last good bundle**, so the page still works and still shows the + old behaviour. `make dev-headless` prints the esbuild error; a + reload does not. One way to cause one is a stray backtick inside a + comment in a `css` tagged template literal, which ends the literal. +- **`npx tsc --noEmit` is part of the gate, and nothing else runs it.** + CI does (`.gitea/workflows/ci.yml`), and it typechecks + `frontend/test/` — which `make lint`, `make test`, `make ui-test` and + `make e2e` do not. A tree can be green on all four and red in CI. ## Which tier @@ -56,6 +75,7 @@ only climb when it cannot. | A bound method or a bound struct field | `make bindings` then `make ui-test` | ~1.5 s + 2 s | | A user-visible flow across frontend *and* backend | `make e2e` (needs the app up) | ~1 min | | Something you cannot predict — exploring | `make dev-headless SEED=default` + `playwright-cli` | interactive | +| Something whose answer is a *number*, not a pass | `make perf` against a bulk-seeded app | ~1 min + setup | | A `.sql` or `.templ` file | `make generate`, then the checklist in [references/schema-change.md](references/schema-change.md) | | Two targets are once-per-clone prerequisites that are **not** @@ -73,13 +93,178 @@ Two rules about climbing: - **A component test passing is not the app rendering.** If you touched anything in `frontend/src`, verify it in the real app too — start it headless, `screenshot --filename=/tmp/shot.png`, and *read the PNG*. + Two of this repo's worst regressions were only ever visible there: a + header badge contradicting the settings page, and a virtualized row + whose columns no longer lined up with its own header. Neither failed + anything. +- **A list that renders is not a list that repaints.** `lit-virtualizer` + re-renders its rows when one of its *own* properties changes, not + when the parent does — so selection highlighting, the playing-track + row and anything else driven by host state need an explicit + `virtualizer.requestUpdate()`. Click a row and look, every time you + touch one of these lists; the controller will hold the right state + either way. **Check `el.viewActive` first**: dispatching a raw + `navigate` event does not always activate a view, and an inactive one + does not render at all — which looks exactly like this bug (the + controller holds the selection, no row highlights) and is Phase 1 + working as designed. Navigate by clicking the sidebar. - **Do not write an e2e spec first.** Drive the flow by hand, then promote it with `/e2e`. Specs written blind assert on selectors that do not exist. -Before a commit, the gate is `make lint`, `make test`, `make ui-test` -and `make bindings-check` — all four are also lefthook hooks, so -skipping them locally only defers the failure. +Before a commit, the gate is `make lint`, `make test`, `make ui-test`, +`make bindings-check` and — from `frontend/` — `npx tsc --noEmit`. The +first four are lefthook hooks, so skipping them locally only defers the +failure; the typecheck is a hook too but only CI runs it over the test +tree, which is where it has actually broken. + +Two things about the e2e tier that are not obvious until they bite. +**The 36 specs share one backend process in file order**, so a spec +that leaves the app somewhere passes alone and fails the suite — leave +the UI as you found it, and *wait* for it rather than trusting the +click to have finished. The queue panel's width is animated and the +transport slides with it, so a click issued while it closes lands on +whichever button moved under the pointer. And **anything asserting on +the queue panel's rows must open it first**: a closed panel renders no +list at all. + +## Measuring, when a pass is not the answer + +Performance claims need a before and an after on the same machine +against the same library, or they are anecdotes. The fixture library +is a few dozen tracks and cannot show any of it. + +```bash +make bulkdata # ~11 s, 466 MB into a gitignored .dev/ +make sandbox-seed-bulk # minutes: it is a real scan of 50 000 files +make dev-headless SEED=bulk +make perf LABEL=before # ... make the change ... +make perf LABEL=after +make perf-compare BEFORE=before AFTER=after +``` + +Fourteen numbers: startup (and the count of cross-origin requests, which +is whether the app works offline), the bundle's shape and each view's +first open, keystroke-to-paint in the search box, what a naturally +finished track provokes, what one favourite toggle costs, what sitting +idle on Settings costs, what **scrolling** a long list costs (image +bytes and the tier they were requested at, plus frame cost through the +artist grid), what a long **Explore session** retains (heap sampled +after each of twenty-four searches, plus every registered cache's +size), what opening a **2 000-track playlist** costs (elements +retained, eager cover requests, heap, and what one update pass costs +and rebinds), what the **selection** costs (ordering the selected keys +with one row selected at either end of 50 000 and with all of them, and +what "Select all → Edit tags" blocks for), what an **update pass of the +player bar** costs (querySelectors, layout reads, style writes and the +read-after-write interleaves inside `updated()`, measured with a clean +DOM and a dirty one, plus six seconds of real playback), how many +**document pointer listeners** are installed at rest (via CDP, so +nothing else in the run is perturbed), what **"play these"** costs for +an artist, twenty albums and five genres, and heap after a scripted +browse. It wraps every bound Go method, so "did that refetch the +library" is a fact rather than an inference. + +`window.__yjCacheStats()` reports every registered cache's entries, +retained chars and cap in one eval — which is how you check a bound is +still holding without rebuilding the reproduction that justified it. + +Adding a number is usually the first half of an item's work: most +findings are not among the seven, and the fix cannot be believed +without one. Two rules for adding one. + +**Stage what the seed does not have, idempotently and by name.** The +bulk seed has one empty playlist, against which "toggling a heart +refetches every playlist" costs nothing and cannot be reproduced; the +favourite measurement builds ten 500-track playlists first. Staging by +name means a before and an after see the same shape — and +`dev-headless` restores the seed tarball on every launch, so it is +rebuilt each run anyway. + +**Measure both halves of a trade.** Route splitting reports bytes +before first paint *and* the slowest first open of a view, because a +split that halves startup by making every page visibly slower has not +helped anyone. + +**Measure the state the cost depends on, not just the operation.** A +forced layout costs 3 µs against a clean layout and 0.1 ms against a +dirty one, so a component measured only in its steady state reports +that the finding about it is imaginary. If the work is conditional, +stage both conditions and put both rows in the table — they explain +each other, and one of them is the number the fix has to move. + +Fourteen traps, each of which produced a wrong number first: + +- **A label is a filename, and audit IDs are case-insensitive as + filenames.** `.dev/perf/before-m6.json` is the *capital* `M6` (the + 3 s ticker) from an earlier pass; measuring lowercase `m6` under that + name silently overwrites a baseline three passes of numbers depend + on. Name a label after the *change*, not the finding. +- **The first run after a rebuild is not a measurement — and the + second is not reliably a good one either.** A run taken immediately + after `make dev-headless` often reports first contentful paint at + 96–112 ms against 28–32 ms on the next run of the same build (a cold + Vite module graph). But the ordering does not hold: one pass saw 100 + then 96, and another 28 then 76. FCP moves ±50 ms for reasons this + harness does not control, so take two, and if they disagree report it + as noise rather than taking a third until they agree. +- **A measurement is against whatever seed the app is running.** + `make e2e` needs `SEED=default`, so a confirming perf run taken + straight after one measures a few dozen tracks: "Play 20 albums" + becomes a dash and an artist's bytes fall 40×. Plausible in shape, + meaningless. Restart on `bulk` before re-measuring anything. +- **A `longtask` entry is delivered *after* the task that produced + it.** Reading `window.__yjPerf.longtasks` synchronously after the + operation you just timed reports **0 ms of blocking beside a + six-second stall**. Wait a couple of hundred milliseconds first. The + tell is that the two numbers in the row disagree — which is a good + reason to always measure blocking *and* wall time. + + +- **`make dev-headless` immediately after `make sandbox-seed`** loses + the race for port 34115 and comes up with no dev server, while still + printing `up`. The measurement then attaches to a dying app. Sleep, + or check `curl -s -o /dev/null -w '%{http_code}' localhost:34115`. +- **`search-bar` debounces 150 ms.** Anything measuring to the next + frame measures the input echoing its own character. +- **`__yjEvents.wait()` returns an already-buffered event.** Without a + `reset()` first you get the previous run's answer, which looks like a + real result and is off by one iteration. +- **A `0 ms` result is usually a broken measurement, not a win.** + Waiting for `#main-content > :not(.view-hidden)` after a navigation + matches the view being left — it stays on screen until the incoming + one is ready — so every view reported 0 ms on every build. Wait for + the specific element, never a generic selector. Same tell as the + debounce: **a number that cannot move is not evidence.** +- **`git stash` will not give you a baseline** on a tree carrying + uncommitted phases: stashing one file reverts *every* uncommitted + change in it, not the one being measured. Build the before by undoing + the single change by hand in the current file. For a cap or a + threshold, setting the constant to `Infinity` is the cleanest + possible one-variable undo. +- **A bound cannot be verified by a run that never reaches it.** The + first bounded build measured *identical* to the unbounded one, + because the session cached 180 entries against a cap of 192 and never + evicted anything. Same tell as the two traps above — before and after + suspiciously equal. Make the session overrun the limit. +- **A negative result inherits the coverage of whatever produced it.** + Two sessions recorded the unbounded Explore caches as "does not + reproduce" from a browse script that visits Explore and never + *searches* in it — so both caches were empty the whole time. Before + believing a finding did not reproduce, check the code path it names + actually ran. +- **A measurement that warms something has to run after everything + that reads it.** The playlist-open number pulls ~90 cover images; + placed before the scroll measurement it filled the HTTP cache and + took that row's request count from 26 to 0 — a clean, plausible, + entirely fabricated improvement in a number nothing had touched. It + runs last now, which costs it its own request count (zero on any + build, so that row is in the JSON and off the table). +- **The bulk library's covers are 300×300 and ~3.7 kB**, deliberately + (a realistic cover generator made a 2 GB library). Any finding about + full-size artwork cannot show its magnitude here; measure the + mechanism instead — e.g. *which tier the request asked for* rather + than bytes saved. ## Running the app diff --git a/.planning/NOTES.md b/.planning/NOTES.md index a9667fc..98eb563 100644 --- a/.planning/NOTES.md +++ b/.planning/NOTES.md @@ -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 + `
` 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: **`