Feedback pass: red read as "bad", so the spectrum is now green -> blue through cyan (deep/exploratory,
not alarming). The orb glow was way too hot — cut it right down and softened the pulse ring + the colour
aura. Dialed the wiggle back (lower amplitude, fewer cycles, slower) so the adventurous end is lively,
not chaotic. Header is now a small uppercase "ADVENTUROUSNESS" overline above the big colour-shifting
state word, instead of the plain bold title.
Scrapped the basic slider for a custom animated control matching the vision: a draggable orb rides an
SVG line that is redrawn every frame (rAF). At the left (value 0) the line is green and waves gently —
"artists you already like", alive but calm; drag the orb right and the colour shifts through the warm
spectrum (green -> yellow -> orange -> red) while the wave grows taller and more erratic (a detuned
second harmonic). The orb glows the current colour and rides the wave; the state word recolours too.
Releasing saves (POST /api/discover/adventurousness) and re-fetches both rec rows. The loop skips work
while the page is hidden. Still one source of truth with the Settings slider.
64 script-integrity tests green.
The first cut had two problems (screenshot): the gradient track was invisible (Chromium does not render
the range inputs own background as the track when appearance:none — must style ::-webkit-slider-runnable-
track / ::-moz-range-track) and the grid layout stretched it full-width into a sparse void.
Now: a contained (max-width 880) single-row flex control — icon | title+state | Safe | gradient track |
Adventurous | live %. The gradient (green->accent->orange) renders on the track pseudo-elements, the
thumb is centred on it, and a % readout anchors the right edge. Mobile wraps the slider to its own row.
A prominent control above the rec rows: gradient slider (Safe green -> accent -> hot orange), glowing
grab-thumb, a compass icon that swaps with the level (lifebuoy/compass/dice/crystal-ball) and a live
state word ("Playing it safe" -> "Deep cuts only"). Dragging updates the label live; releasing saves
and immediately re-fetches both rec rows so the effect is instant.
Shares the config key discover.adventurousness with the Settings -> Discovery slider via a new
GET/POST /api/discover/adventurousness endpoint, so the two controls stay in sync (change one, the
other reflects it on next load) — one source of truth, no divergence.
64 script-integrity tests green; route ruff-clean + compiles.
_reorderDiscoverSections() runs after all loaders and moves each section (incl. ones nested in the
mid-page #discover-bylt-sections) into the intended top-to-bottom order: Genre Explorer -> Your/Year
Mixes -> for-you (listening/recommended/BYLT) -> new (recent/genres/seasonal) -> library (artists/
albums) -> deeper digs (undiscovered/labels/deep cuts) -> stations & tools (Last.fm/ListenBrainz/
Build). Hero + Artist Map stay near the top; hidden collapsed-mix sections are left invisible.
Also: ListenBrainz/Last.fm recording playlists carry no album or duration, so _normalizeTrack now
leaves album blank (not "Unknown Album") and renderCompactPlaylist blanks the duration when 0 (not
"0:00"), matching the original displayListenBrainzTracks behaviour.
64 script-integrity tests green.
_normalizeTrack fell back to {} when there was no track_data_json, so it never read the top-level
name/artists[]/album that decade (and Spotify-shaped) rows carry — every field defaulted to Unknown.
Fall back to the track object itself (|| track), matching _renderTabbedTrackLists trackData = track
behaviour. Flat (track_name) + nested (track_data_json) shapes still resolve.
Two bugs in the new playlist-card sections:
- "undefined" track rows: those sources nest fields under track.track_data_json (name/artists[]/
album/duration_ms), but renderCompactPlaylist read flat track.track_name/etc. Added _normalizeTrack
(handles both shapes) and routed renderCompactPlaylist + the mix-card mosaic through it, so decade /
Last.fm / ListenBrainz tracks render real titles instead of undefined.
- amateur covers: cards showed a single flat emoji. Now _hydrateMixCovers background-fetches each
cards tracks, upgrades the placeholder to a real 2x2 album-art mosaic (matching Your Mixes), and
caches the tracks on the mix so the modal opens instantly. Wired into _renderMixGrid (decades) and
the ListenBrainz/Last.fm render. Dropped the emoji coverHtml.
64 script-integrity tests green.
buildListenBrainzPlaylistsHtml (shared by both sections) now emits a grid of mix cards instead of
full-width track-table subsections — each playlist is a card that opens its tracks (lazy-loaded from
/api/discover/listenbrainz/playlist/{id}) + Download/Sync in the shared modal. ListenBrainz sync uses
its own -sync-total/-sync-matched status spans, so the mix carries a matching statusHtml; the sync
button id (discover-lb-playlist-{id}-sync-btn) lines up so startListenBrainzPlaylistSync drives it.
The old eager loadTracksForPlaylists calls are now harmless no-ops (their -playlist containers are
gone, so loadListenBrainzPlaylistTracks early-returns). Wing-It is omitted from the card modal for now
(its dropdown needs modal-aware positioning).
Browse by Genre removed (section markup + the loadGenreBrowserTabs call): it was empty
(/api/discover/genres/available returns nothing) and redundant with the Genre Explorer pills.
64 script-integrity tests green.
Foundation for folding the remaining playlist sections into the mix-card system:
- openMixModal now takes a custom `actions` list (not just the built-in Download/Sync), a `statusBase`
so any sections live sync lands in the modal, a `pollerKey` for in-flight re-show, and `fetchTracks`
for lazy sections that load tracks on open.
- _buildMixCard supports a non-mosaic `coverHtml` + `trackCount` (for art-less cards like decades).
- new _renderMixGrid(container, mixes) renders cards into any sections own grid (not the shared shelf).
Time Machine -> "Year Mixes": a shelf of decade cards (gradient cover + "1980s" label); opening one
lazy-loads /api/discover/decade/{year} into the modal (populating decadeTracksCache so Sync works)
with Download/Sync. Tabs removed.
Fix: _upsertMixCard rendered the WHOLE shared registry into Your Mixes, so decades (added by
_renderMixGrid for the modal lookup) leaked in. Your Mixes now tracks its own _yourMixKeys and renders
only those. 64 script-integrity tests green.
Because You Listen To rows now render .ya-card grids (with the Show-all clamp) instead of the old
discover-card carousels. The genre-browser tiles (_renderGenreCard) are converted too for consistency
— that loader is currently dead code (loadGenreBrowser is never called; only the tabbed Browse-by-Genre
modal runs), so this just future-proofs it. With this, no discover-card markup remains in discover.js
— the whole Discover page is one card language (.ya-card + the mix cards), leaving only the Genre
Explorer pills + Artist Map as the intentional splash elements.
64 script-integrity tests green.
Based On Your Listening + Recommended For You now use the same .ya-card as the rest (square cover,
name overlaid, recommendation reason as the subtitle) in wrapping grids with the Show-all clamp,
replacing the bespoke recommended-artist-card layout. The watchlist toggle and live image enrichment
are preserved by keeping their hooks on the new markup — .recommended-artist-card[data-artist-id],
.recommended-card-image, and .recommended-card-watchlist-btn (restyled as a hover-reveal corner pill
that stays lit when watching). _clampGrid wired into both onRendered callbacks.
64 script-integrity tests green.
Step 2 of the makeover — the album/release sections now use the same .ya-card as Your Artists
(square cover so art is not cropped, name overlaid, artist beneath) in responsive wrapping grids,
replacing the old spotify-library-card / discover-card / carousels:
- Recent Releases, Your Albums (owned/missing badge kept), Undiscovered Albums, New In Your Genres,
From Your Labels (one shared _cacheDiscoverCard), Seasonal Albums.
- _insertCacheSection gained a wrapGrid flag; Genre Explorer opts out (wrapGrid=false) so its pill
layout is not squashed into the card grid (fixes the regression where it broke under the hero).
- new _clampGrid caps a grid at ~12 cards with a Show all (N) / Show less toggle, so a 30-item
section is not a wall; wired into the cache sections + Recent Releases + Seasonal Albums.
64 script-integrity tests green. Next: the two artist sections (Based On Your Listening / Recommended
For You) need their watchlist + live-enrichment hooks moved before they can switch to .ya-card.
Fresh Tape (release_radar), The Archives (discovery_weekly), and the Seasonal/Summer Mix
(seasonal_playlist) move out of their old full-width compact-track-table sections into the unified
shelf as mix cards (set the global in the controller renderItems, then _upsertMixCard + collapse the
old section after load). Daily Mixes loader is converted the same way (each mix → a card) for if/when
that HIDDEN feature is re-enabled. So every active mix now lives in one consistent shelf.
64 script-integrity tests green.
Reworks the Discover page mixes from full-width compact-track tables into a consistent shelf of
playlist cards, the way Spotify Home does it. Each mix is ONE card (a 2x2 mosaic cover from its top
tracks + name/count, hover play button); clicking it opens a modal with the track list + the mix
actions — the track table now lives where a track list belongs (inside the opened mix).
- new "Your Mixes" section + .discover-mix-card / .discover-mixes-grid + .mix-modal styles.
- _buildMixCard / _upsertMixCard / openMixModal + a mix registry; the four personalized track-mix
loaders (Listening Mix, Popular Picks, Hidden Gems, Discovery Shuffle) collapse their old table
section into a card via _collapseOldMixSection (which also strips the old section's duplicate
sync ids so the modal owns the live status).
- modal actions use the standard btn--secondary/btn--primary; Download closes the mix modal first
so its own modal is interactable; Sync shows live progress IN the modal and survives close/re-open
(detects an in-flight sync via discoverSyncPollers and re-reveals the status).
64 script-integrity tests green. Next: fold the remaining mixes (Fresh Tape / The Archives /
Seasonal / Daily Mixes) into the same shelf, then unify the album/genre sections onto the card.
#913 was silently producing 0 recs: similar_artists.source_artist_id is a SOURCE id (Spotify/etc.), but the scan keyed id->name by internal artists.id (resolved nothing), and the consensus ranker was fed the name-collapsed get_top_similar_artists (consensus could never fire). Fixed + elevated:
- id->name keyed by source-id columns; raw per-seed edges (real consensus); similarity_rank threaded into the score; recency-weighted seeds (recent plays boost lifetime favs)
- new 'Based On Your Listening' artist row (/api/discover/listening-recommendations) with 'because you listen to X' explanations
- new 'Your Listening Mix' track row: each rec's top tracks via a guarded, name-resolved Spotify/Deezer fetch (falls back to the discovery pool), stored as full render dicts so the row can't shrink on pool rotation
- pure tested core: similarity_from_rank, build_recency_weighted_seeds, to_mix_track, names_match (+ rank-aware grouping)
Fresh Tape (5-10 tracks): future-dated albums sorted to the top of get_discovery_recent_albums and ate the 50-album budget before the is_future_release skip ran. Add exclude_future_years + fetch a generous budget; downstream caps unchanged. Regression tested.
Also drop the per-track block 'X' from the compact playlist rows (wrong spot). Plan/audit in DISCOVER_BEST_IN_CLASS_PLAN.md.
- New 'Recommended For You' carousel section on the Discover page (between the
hero and Your Artists), so recommendations aren't buried behind a hero modal
button. Reuses the recommended-card markup/CSS, the watchlist add handler, and
primes the modal cache so 'View All' opens instantly in sync.
- Re-frames the now-stale copy: recommendations are library-wide (the similar-
artists worker feeds the whole library), not watchlist-only.
- Shows the real explanation from the backend's 'because' field —
'Because you have X & Y' (with a full-list hover tooltip) instead of just a
count — in both the section cards, the modal cards, and the hero subtitle.
- Cards lazy-enrich their images via the same endpoint the modal uses.
- Toolbar wraps on phones (<=760px): back + title + stats and the compact tools
stay on row 1, the search drops to its own full-width row below so nothing gets
crushed. Brand text hidden, stats truncate with ellipsis.
- Island nav + canvas height now MEASURE the toolbar height instead of assuming
~50px, so the taller wrapped header doesn't overlap the nav or clip the canvas.
64 JS integrity tests pass.
- Info panel becomes a bottom SHEET on phones (<=760px): slides up when you tap
a bubble, doesn't steal map width (islands frame full-width via _artMapReservedW
= 0 on mobile). Grip/handle to dismiss; a floating menu FAB opens it to the
dashboard + top-artists. Desktop stays the right sidebar.
- Genre sidebar hidden on mobile (the top-left quick-jump nav handles genre
switching; no room for a sidebar).
- Touch tap now selects a bubble (card in the sheet) instead of opening the modal,
matching desktop click; ignores taps that were drags.
- Resize/orientation: debounced reflow that re-styles the panel for the new
breakpoint, recomputes canvas size (minus sidebar/toolbar), and re-frames the
focused island / fit. 64 JS integrity tests pass.
- Watchlist button now reflects real state: shows 'On watchlist' (filled) vs
'Watchlist' (outline), confirmed per-artist via /api/watchlist/check, and
flips instantly when you add/remove (cached in _watchSet so it stays correct
as you browse). Uses the artist's source id, works on any map.
- Debounced hover-select: the card only swaps to a bubble you've settled on for
~0.8s, so sweeping toward the panel no longer keeps changing the card on
bubbles you pass over. Clicking a bubble selects it instantly (bypasses the
debounce, pins the card) instead of auto-opening the modal — Details button
still opens it.
- Fix: panel started at top:0 and covered the navbar; now it starts below the
.artist-map-toolbar (measured) so the toolbar stays clear. 64 tests pass.
A polished detail panel on the right of every map (never collides with the genre
sidebar; islands now frame in the space left of it):
- Header dashboard: view title, Artists / Watchlist / Genres stat tiles, and a
watchlist-coverage bar for the current genre/view.
- Top-artists list: the current island's biggest artists, clickable (shows
their card + ripples them on the map).
- Rich artist card on hover/click: large art (from the decoded bitmap), genre
chips, popularity bar, connection count, watchlist/discovered badge, and
actions — Explore from here, Details, Watchlist toggle, Open artist page.
Card stays pinned (no auto-revert) so you can reach its buttons; a back
button returns to the list.
64 JS integrity tests pass.
Bubbles now rise up into position (water-surfacing) with a soft ease-out-back
settle and alpha fading in a touch faster than scale. Stagger is continuous
radial + a deterministic per-bubble jitter so they fill in organically instead
of popping in visible rings/segments. 64 JS integrity tests pass.
Root cause of the 'loads as placeholder orbs, only pops in after a zoom' bug:
streamed images were cached in _artMap.images but written into the buffer via
the per-node composite path, which didn't reliably refresh in one-island /
overflow mode — so covers stayed as placeholders until a zoom forced a full
rebuild that picked up the cached bitmaps.
Now that each map's buffer is small (one focused island, or a small explore
map), a throttled FULL rebuild on image arrival is cheap and always bakes every
cached image. Dropped the composite call from the stream; art fills in by itself
as it loads. 64 JS integrity tests pass.
- Soft genre-hued halo glows behind the focused island (cached per-hue sprite →
one drawImage, no per-frame gradient) so it reads as a place on the water.
- Hover-pop: hovered bubbles scale up + get a bright hue ring + glow, even on
static genre islands (drawn on top), so hover always feels tactile/responsive.
- Genre quick-jump: click the genre name in the nav for a dropdown of every
genre island — jump straight to one instead of only prev/next.
- Decluttered: dropped the redundant in-world island titles in one-island mode
(the nav bar already names the genre, and they could clip off the top).
64 JS integrity tests pass.
- Focused islands now render from the high-res buffer (one cheap crisp blit)
instead of redrawing every bubble each frame for the bob. In one-island mode
the buffer already covers just that island at high resolution, so this is
crisp AND cheap — kills the genre lag. Bob/shove stay live only for small
views (zoomed-in subsets, explore) where per-frame redraw is cheap.
(Overflow threshold 650→140; the loop parks once the island bakes.)
- Fewer bubbles per island (maxPerIsland 500→300) — less cramped, lighter bloom.
- Island nav bar moved from bottom-center to top-left (clears the genre sidebar
+ toolbar). 64 JS integrity tests pass.
Two things from feedback:
1) Toolbar search now queries the metadata source for ANY artist (like the
discover page) and launches an exploration on click — instead of only
filtering the current map's nodes (which showed nothing for off-map names).
2) Genre + watchlist maps now frame ONE genre island at a time, with prev/next
nav (and ← / → keys) through the genres. This sidesteps the persistent
'renders small/sparse' bug entirely: only the focused island is visible, so
the buffer covers a small region at HIGH res (crisp covers, no more shrunk
images) and the live layer handles just ~hundreds of bubbles (bob works, no
overflow). Each island blooms in (drop-in-water) on focus. Explore stays
multi-island (it's small). A bottom nav bar shows genre name + i/N.
Streaming caches off-island images silently (no redraw) so navigating is
instant. 64 JS integrity tests pass.
Fixes the genre-map 'renders small/sparse after the reveal, zoom fixes it' bug.
Root cause: tighter islands (Phase D) raised the fit-zoom so nearly every bubble
crossed the live-size threshold → the buffer excluded them all (thought they
were live) but the live layer is capped, so only ~600 of 1800 drew until a zoom
rebuilt the partition.
Fix: _artMapRebuildBuffer now counts would-be-live bubbles; if more than the
live layer can draw (>450), it sets _liveOverflow and bakes EVERYTHING into the
buffer (full, correct render). The live layer + bob only take over once zoomed
in enough that few bubbles qualify. So the overview is always complete,
regardless of zoom. Trade-off: very large maps (genre 1800) render from the
buffer (no per-bubble bob, slightly softer when deeply zoomed until the
zoom-rebuild sharpens) — correctness over flourish on the crowd.
Also: whole animation loop capped at ~30fps (reveal/ripple/bob all read fine at
30) to cut the churn on dense maps; a pending rebuild (dirty) always draws so the
throttle can't skip the post-reveal bake. 64 JS integrity tests pass.
Addresses the perf + tooltip feedback:
- Hover constellation no longer clips per node every frame (images are already
pre-masked circles) — that per-node ctx.clip() was the hover-lag culprit once
the ambient loop forced continuous redraws. Now a plain drawImage + arc tint.
- Ambient buoyancy loop runs at ~30fps when idle (full 60 only during
reveal/ripple), halving redraw cost on dense zoomed-in maps while keeping the
bob smooth.
- Gloss highlight gated to bubbles >=12px on screen (skips the dense swarm) —
halves per-frame drawImage cost when zoomed in.
- Tooltip photo now paints from the already-decoded bitmap into a canvas
instead of a fresh <img src> reload — fixes the blank photo when sweeping
across dense zoomed-in bubbles (the <img> was churn-reloading faster than it
could decode). 64 JS integrity tests pass.
Clicking (or tapping) the map now drops a water ripple: a hue-tinted ring
expands from the point AND nearby bubbles get shoved radially outward at the
wavefront, then settle back as it passes and decays (_artMapNodeDisplacement —
a gaussian bump at the expanding front, world-space radial push). Ripples emit
from the clicked bubble's centre in its genre hue (or the bare click point),
and still open the artist after a beat. Replaces the old single purple ring.
Note: the physical shove acts on live-layer (zoomed-in) bubbles; at the far-out
overview the ring shows but the tiny baked bubbles don't move. 64 tests pass.
- Ambient bob: bubbles gently float (sine offset, phase varies by position so
they move in a wave, not in unison). Driven by a persistent rAF loop that runs
only while bubbles are on screen + the tab is visible, and parks when zoomed
out (_liveCount==0) or the map closes — so an idle overview costs nothing.
- Glassy specular highlight (cached sprite, cheap drawImage per bubble) so
bubbles read as glossy orbs at every size.
- Tighter island spacing (water gap 7*nodeR → 3.5*nodeR) so the settled
overview is more substantial, not thin-spread — addresses the 'mini version'
feel after the reveal ripples fade.
- Ambient resumes on zoom and on tab re-focus; stops cleanly on close.
64 JS integrity tests pass.
Islands now bloom in like drops on water instead of a flat fade:
- Each island reveals in turn (staggered by island order); within an island,
bubbles fade + scale (0.55→1, ease-out) outward from the centre by radial
distance — a drop-in-water bloom. Genre titles fade in just after.
- A hue-tinted water ripple ring expands from each island centre as it blooms
(_artMapDrawRipples — reused by click ripples in Phase E).
- During the reveal the static buffer is bypassed so EVERY bubble can animate
(live layer, cap 2200); when the bloom ends it bakes into the buffer once and
steady-state returns to the cheap two-layer path.
- aAlpha folds into the global draw-alpha multiplier so fades compose cleanly.
64 JS integrity tests pass.
All three maps (watchlist / genre / explore) now lay out as genre 'islands' on
the water via one shared engine (_artMapLayoutIslands):
- Group artists by primary genre (long tail folds into 'Other'; max 14 islands).
- Each island is a FILLED disc of covers packed centre-out (no empty donut
hole), most-popular nearest the middle, focal artists sized up + centre-most.
- Islands spread by golden spiral + push-apart with generous water between.
- Clean floating genre TITLE above each island (hue-tinted, glow) instead of
the old giant translucent label bubble.
- Per-genre accent hue tints member-bubble borders so clusters read as a family.
- Discovery edges (watchlist→similar, center→ring1→ring2) remapped to the new
node ids so the hover constellation still works across islands.
Replaces the per-artist donut clusters from the screenshots. Shared helpers:
_artMapGroupByGenre, _artMapPackDisc, _artMapRemapEdges, _artMapFitToContent.
64 JS integrity tests pass.
Addresses the screenshot feedback (mix of detailed covers + blank dots, lag,
'weird' load):
- Pre-mask each album image into a circle ONCE at load (a canvas), so every
draw is a plain drawImage instead of a per-frame ctx.clip(). Clipping was
the live-layer stutter — hundreds of clips per frame. Now free.
- Draw album art at nearly every on-screen size (only sub-2.2px fall back to a
dot), instead of detailed-vs-blank-dot tiers. Consistent 'sea of covers'.
- Reveal is now a clean ease-out-cubic fade of the whole map (buffer blit +
live layer ramp together via _drawAlphaMul) — dropped the bouncy per-node
pop that read as 'weird'. The real island ripple bloom comes in Phase C.
64 JS integrity tests pass.
Foundation for the water/ripple redesign. Splits rendering into:
- Static far-field buffer: small/distant bubbles, baked once (cheap blit).
- Live overlay layer: every bubble big enough to read (radius*zoom >= LIVE_PX)
redrawn each frame in world space, so it can scale/bob/ripple. Viewport-
culled + capped at 600 draws.
The partition is frozen at buffer-build zoom (_liveBuildZoom) so the two sets
stay exact complements even mid-zoom — no flicker, no double-draw.
Adds an idle-capable rAF loop (_artMapStartLoop/_artMapStepAnimations) that runs
only while something animates and stops when still. First payload: a reveal —
the far field fades in globally while live bubbles pop outward from the camera
centre (ease-out-back, staggered by distance). Wired into all three loaders.
Bonus: live bubbles now draw full-res at the current zoom instead of through the
4096px-capped buffer, so zoomed-in artwork is crisp (addresses the earlier
low-res complaint structurally). Engine only — the island layout, ripple
choreography and click physics build on this in B–E. 64 JS integrity tests pass.
The streaming fix in Phase 4 still rebuilt the ENTIRE offscreen buffer (~1500
nodes) on each image wave, and any hover/pan during streaming hit that same
dirty flag — so interacting while images loaded redrew the whole world over and
over (the 'laggy until all images load' jank).
Now each arriving image composites ONLY its own node into the existing buffer
(_artMapCompositeNode) and does a cheap rAF-coalesced blit — no full rebuild.
The per-node draw is extracted into _artMapDrawNodeToBuffer so the full rebuild
and the incremental compositor share identical drawing (can't drift). Falls back
to a full rebuild only if the buffer isn't built yet. Pan/hover stay at
blit-speed the entire time images stream in.
All three maps (watchlist/genre/explore) now paint instantly with placeholder
circles and stay fully interactive (pan/zoom/hover/click) while images stream
in throttled ~280ms waves and sharpen the map in place. Replaces the old
blocking 'await all N images then paint' loaders — the headline 'feels slow'
fix. Focal/large nodes fetch first; a per-open load token cancels stale streams
when you jump to another artist, so rapid click-through never piles up fetches.
- Images: decode adaptively (focal/watchlist nodes ~256-384px, small nodes
~112-150px) instead of a flat 128px — crisp where it matters, memory still
bounded (~150-250MB, not 6GB). Fixes the low-res look.
- Hover constellation: drop the activation delay 800ms → 220ms (it felt 'gone'
because nothing happened for nearly a second), and draw the connection lines
as a wide-faint halo + crisp core (a real glow) with no per-frame gradients
or shadowBlur — stays cheap.
- Backdrop: subtle cached radial glow + vignette behind the map for depth
instead of a flat fill (one cheap fillRect/frame).
JS clean; 64 integrity tests pass.
Perf telemetry was the giveaway: after the buffer cap, rebuild + draw were both
~10ms, yet fps stayed 1-3 and the browser 'locked'. Cheap draw + locked system =
memory/GPU thrash, not drawing.
Cause: artist images load at up to 1000×1000, and a dense map holds ~1500 of
them — ~1500 × 1000² × 4B ≈ 6 GB of decoded ImageBitmap memory. The browser GCs/
evicts textures constantly → systemic lag the canvas timers don't see.
Fix: decode straight to a 128px avatar via createImageBitmap resize options
(nodes render tiny anyway). ~1500 × 128² × 4B ≈ 100 MB instead of 6 GB. Falls
back to full decode on engines that ignore the resize opts.
This is the one that should actually make it smooth. Perf overlay stays on 'd'.
Perf telemetry from the genre map (2004 nodes) proved it: the offscreen buffer
was 7465×10240 (76 megapixels) — rebuilt in ~979ms on every zoom and blitted at
~150ms/frame (3 fps), with the constellation overlay piling on top. The buffer
renders the WHOLE world, and the size cap was 10240px.
Cap the max buffer dimension to 4096 (MAX_BUFFER_PX). On the dense genre map
that's ~12MP instead of 76MP → ~6x faster rebuild and blit, and more nodes drop
under the LOD dot threshold so the rebuild also draws fewer image-clips. The cap
only binds on large worlds; small watchlist/explorer maps don't reach it and
stay full-resolution.
Tunable; perf overlay ('d' → app.log) stays so we can confirm the new numbers.
The on-canvas overlay text can't be copied (and can't be grabbed mid-freeze), so
when perf mode is on ('d'), the frontend now also POSTs the render timings to
/api/discover/artist-map/perf ~1.5x/sec, which logs them as [ARTMAP-PERF] in
app.log. Lets the bottleneck be diagnosed from the server side with no manual
copying.
- REVERT the spatial-grid hit-test I added in Phase 1. It inserted each node
into every grid cell its bounding box overlaps; the genre map's huge cluster
nodes span an enormous number of cells, so the first hover/click triggered a
multi-second synchronous build → 'can't hover or click' freeze. Back to a flat
O(N) single-pass hit-test (no per-move sort) — sub-ms even for thousands of
nodes, can't lock up.
- Keep the safe Phase 1 wins (render coalescing, tooltip de-churn, solid-stroke
connection lines).
- Add a perf overlay toggled with 'd' on the map: shows node/edge counts, the
offscreen buffer size + scale, zoom, and the last buffer-rebuild + draw times.
So we can measure the real drag/zoom bottleneck (buffer rebuild) instead of
optimising blind.
JS clean; 64 integrity tests pass.
The Explorer prompt accepted any loose text and explored whatever you typed.
Now it's a proper picker: type -> debounced search of the metadata source
(reuses /api/discover/build-playlist/search-artists — Hydrabase if active,
Spotify if configured, else the active metadata source) -> shows real artist
results with images -> click one to explore that resolved artist. Enter picks
the top match (never explores raw text); Escape/Cancel/backdrop close.
Pure frontend: rebuilds _showArtistMapSearchPrompt() (same Promise<name|null>
contract, so the caller is unchanged), reusing the playlist-builder's search
endpoint + picker styling. No backend change.
Investigating 'each mode is different / not good enough' showed the engine is
already shared across all three modes (watchlist/genre/explore) and already does
LOD rendering, eased camera, and debounced zoom-rebuilds — so the inconsistency
was perception driven mostly by the (now-fixed) lag, not separate engines.
This phase surfaces more real data the map already has: the hover tooltip now
shows each artist's live connection count (computed from the map edges), shown
consistently across all three modes. Cheap (only recomputed when the hovered
artist changes, after Phase 1's de-churn). Additive + safe.
JS syntax clean.
Kills the hover/move lag on dense maps. Root causes were in the live
mouse/render path, not the layout:
- Render coalescing: _artMapRender() now just requests a single rAF; the actual
draw (_artMapDraw) runs at most once per frame. A burst of mousemove/pan/
animation calls no longer triggers many full-buffer blits per second.
- Tooltip de-churn: only rebuild the tooltip innerHTML (and reload its image)
when the hovered artist changes; a plain mousemove just repositions. Was
rebuilding innerHTML + a new <img> every pixel of movement.
- Spatial-grid hit-test: bucket nodes into a coarse world grid and test only the
cell under the cursor, instead of sorting + scanning every node each move.
Grid rebuilds only when the node set changes.
- Constellation lines: draw all connection lines as ONE solid-stroke path
instead of creating a fresh linear-gradient object per line every frame —
that per-frame gradient churn was the main 'connected lines' lag.
No layout/data/click changes; behaviour identical, just frame-bound. Pure
frontend; JS syntax clean.
Continue the design-system unification (kettui UI-consistency item):
migrate the five remaining compact button families onto the shared
.btn .btn--sm primitive + color modifiers, and drop their bespoke base
CSS (net -125 lines of CSS).
- ya-header-btn (Your Albums/Artists, discover.js-injected) -> .btn .btn--sm
.btn--secondary; ya-refresh/ya-settings/ya-viewall co-modifiers kept.
- explorer-action-btn (Playlist Explorer) -> .btn--secondary / .btn--primary.
- repair-bulk-btn -> .btn--secondary / .btn--primary / .btn--warning (fix-all).
- enhanced-bulk-btn (Library bulk bar, library.js-injected) -> .btn--primary/
--secondary/--danger; class kept as a hook for the mobile.css size
override + the .tag-write / .rg-analyze special colors.
- profile-create-btn (init.js-injected) -> .btn .btn--block .btn--primary;
class kept for the scoped .profile-edit-buttons flex:1 rule.
mini-nav-btn deliberately left as a distinct icon-button archetype.
Update Import Music album and queue artwork fallbacks to use the shipped /static/placeholder-album.png asset instead of the nonexistent /static/placeholder.png path.
Replace the remaining static UI fallback to the missing placeholder path and add a regression test that fails if static JS references it again.
- replace click-driven artist-detail hops with semantic links
- keep SPA transitions via shell bridge interception for /artist-detail/:source/:id
- drop legacy page helper wrappers and dead bridge plumbing
- add a canonical TanStack route for artist-detail and keep the legacy page as the renderer target
- expose page-level artist-detail navigation on the shell bridge for legacy callers
- remove artist-detail-specific routing, origin stack, and back-label logic from the shared shell helpers
Add MusicBrainz watchlist artist ID storage, badges, linked-provider editing, and per-artist preferred source support.
Backfill watchlist MusicBrainz matches from already-enriched library artists so existing MusicBrainz worker matches appear in watchlist cards and settings.
Extend bulk watchlist add, liked artist matching, artist map source picking, and service status labels to recognize MusicBrainz, with regression tests for watchlist ID persistence and backfill.
Preserve source metadata for seasonal and cached discover album modals so artist links use real provider IDs instead of falling back to library/name routes.
Treat source-only artist detail discographies as clickable missing releases and skip library-only ownership/enhancement checks.