- depth setting (light = core tags + matched source ids; full = same
multi-source enrichment cascade a fresh download gets, run additively
via embed_source_ids). Threaded through scan/finding/auto-apply and the
repair_worker fix handler.
- source now defaults to 'auto' (= your source priority / active source)
instead of blank.
- give native <option> popups a solid dark background (were white-on-white).
- tests for full-depth full_meta payload + enrich invocation + light no-op.
The old per-download Retag Tool was limited (only native-pipeline downloads,
100-group cap, manual per-group) and did the wrong thing — it moved/reorganized
files instead of just tagging. It's superseded by the new Library Re-tag job
(whole-library, in-place) + the enhanced-library 'Write Tags' button.
Removed: the post-download record_retag_download ingestion hook (stops writing
retag_groups on every download), core/library/retag.py, the web_server state +
deps + /api/retag/* endpoints + the tool:retag WebSocket emit, the dashboard
card + both modals (index.html), the core.js socket handler, and the tools-page
wiring + help entry (wishlist-tools.js). Updated the import-pipeline test.
Verified: web_server parses, app + core imports OK, 392 tests pass, no live
references to removed symbols.
Left as inert (harmless) for a careful follow-up sweep: the retag_groups/
retag_tracks tables + their DB CRUD methods (no longer written/read), and the
now-orphaned retag JS helper functions (no entry point/wiring/socket calls them;
interspersed with wishlist functions, so not blind-deleted).
Wire library_retag into the repair findings UI: a 'Re-tag' type badge, an
'Apply Tags' fix button, and an expandable detail that shows, per track, every
tag that would change as old -> new (plus source/mode/cover-action summary and
any unmatched tracks). So the dry-run finding is actually reviewable before you
apply it — the rich details_json the job stores now surfaces in the card.
The version-button modal renders from VERSION_MODAL_SECTIONS (the curated
highlight reel), separate from the WHATS_NEW detailed log. Its top entries were
stale (2.5/2.6.0 era), so promote the 2.6.6 highlights to the top per the file's
release process: Artist Map v2, self-explaining recommendations, the cover-art
filler file-embedding, and a Recent Fixes & Performance roundup (qBittorrent
5.2.0, organize-by-playlist #780, nav/scroll perf #783, dashboard mobile).
- Bump _SOULSYNC_BASE_VERSION 2.6.5 -> 2.6.6 (the single source of truth that
propagates to the UI, backups, and the update check).
- Add the 2.6.6 What's New block (qBittorrent 5.2.0 login fix, Cover Art Filler
on-disk detection + file embedding + stricter matching, recommendations
explainability + Discover section, organize-by-playlist #780, nav/scroll perf
#783, dashboard mobile polish).
- Finalize the 2.6.5 block: it shipped in tag 2.6.5 but was left flagged
unreleased (so its notes never displayed) — stripped the flag + dated it per
the file's own release convention.
The #rate-monitor-section equalizer had breakpoints but two narrow-bar gaps:
- The status pill ("Not configured", "Yielding") is wider than a thin
equalizer column and spilled over neighbours — now capped to the bar width
with the label truncating via ellipsis.
- Wrapped rows were left-aligned (orphan bar stranded) with no vertical gap —
now centered with a row-gap so multi-row layouts read intentionally.
Plus smaller value/name fonts at <=480px so tiny bars stay legible.
PR #783 reordered transports to websocket-first for faster connects. Reverting
to the polling-first default: it's the most compatible behind reverse proxies
that don't forward WebSocket upgrade headers (common self-hosted setups), where
websocket-first silently breaks real-time updates. The connect-time gain isn't
worth the connectivity risk. Everything else from #783 (scroll-pause, content-
visibility, dashboard parallelization, settings fixes, reduce-effects) kept.
- Stale-cache check (playlistTrackCacheIsStale) compared raw track_count to the
filtered/cached track list, so any playlist with local or unavailable tracks
always looked 'stale' and refetched + re-mirrored on every modal open. Now it
compares the upstream snapshot_id (stored at cache time in the shared fetch
choke point), and returns not-stale when no snapshot is available — explicit
invalidation on refresh still handles real changes.
- organize_download: guard executor.submit so a refused job cleans up the batch
instead of stranding it in 'analysis' (holding a limited analysis slot).
- Removed the dead, deprecated, unused mirrorSpotifyPlaylistTracks.
The error/health state was jarring: a red ring flickering at sin(time*12) plus
stress speeding up the heartbeat, which read as the whole glow flickering. Now
it's all gradual: stress no longer changes the heartbeat speed, the red tint is
softened (never full alarm-red) and eases in/out via a small accumulating
errorHeat bump + smooth decay, and the warning ring is a single soft ring that
breathes slowly (sin*1.4) at low alpha instead of strobing.
On mobile, worker-orbs is disabled so the enrichment buttons render as real
buttons. They were a ragged centered flex-wrap with the wide 'Manage Workers'
pill jammed inline. Now (<=768px, scoped to #dashboard-page so Settings etc.
are untouched): the 44px icon buttons spread evenly across the full width in an
auto-fit grid, and the Manage Workers pill gets its own full-width row.
The expanding heartbeat ring read as a heavy circular pulse. Now: the nucleus
barely breathes (size oscillation cut ~70%), the glow holds steady instead of
pulsing, the logo no longer visibly throbs, and the heartbeat ring is a single
very-faint halo that only appears when workers are actually busy. The red
error-warning ring is unchanged (still punchy, since it only fires on real
failures).
- 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.
The hub now reads as a health gauge on top of the activity gauge. A new
decaying errorHeat (0..1) is bumped by onStatus whenever a worker reports a
real error increment, and cools over ~6s. While stressed the nucleus blends
toward red, its heartbeat quickens (agitation), and a fast-flickering red
warning ring appears — so a glance distinguishes 'busy and healthy' from
'something's actually failing'. Since 404s are classified as not_found now,
this only lights up on genuine failures (timeouts, 5xx).
Status pushes land every ~2s, so the previous fixed 'drain 2/frame' fired a
whole window's worth of pulses in a fraction of a second then went quiet.
Now each orb sets a release rate when a status arrives (pending / ~2s, with
a floor so a lone event still shows within ~0.75s) and the loop drips pulses
out via a fractional accumulator — so a busy worker streams a steady line up
its spoke and a slow one sends the occasional single pulse.
The inbound pulses are now event-driven instead of a random trickle:
- core.js forwards every enrichment:<id> WebSocket status to a new
window.workerOrbs.onStatus hook (extra listener, UI handlers untouched).
- onStatus diffs the cumulative stats counters (matched/not_found/repaired/
synced/scanned, and errors) between pushes and queues one pulse per real
item processed (worker's brand colour) or error (red). First sample only
sets a baseline so we never dump the whole backlog at once.
- tick() drains a couple of queued pulses per frame so bursts stagger up
the spoke; cap of 8 queued per update prevents flooding on big jumps.
- Falls back to the old ambient trickle for any orb that hasn't received a
status yet, so nothing goes dead if the socket is quiet.
Bonus perf: an idle/slow worker now emits almost nothing instead of a
constant random stream of particles.
Navigation & sidebar feedback:
- Show legacy pages optimistically on pointerdown + CSS :active so the
sidebar reacts instantly instead of waiting for the click/router cycle.
- Defer heavy per-page init via requestIdleCallback so a page becomes
scrollable before its init work runs.
Scroll smoothness:
- Cache particle canvas dimensions (no forced reflow per navigation).
- Pause particle + worker-orb canvas redraws during active scroll so the
scroll gets the full frame budget.
- content-visibility:auto on discover shelves and search/wishlist/library
list items to skip off-screen layout.
Dashboard:
- Run the independent initial loads in parallel (Promise.all) instead of
six sequential awaits, collapsing the reflow cascade.
Settings:
- Wire input listeners once instead of rescanning the ~960-node subtree
on every visit.
- Suppress auto-save while the form is programmatically populated on load,
fixing a spurious full save (4 POSTs + backend service re-init) that
fired on every Settings visit.
Reduce Visual Effects = full performance mode:
- Also halts particles, worker orbs and all filters; hides the static
sidebar aura circles that looked broken without their blur/animation.
Global search bar hidden on settings/help/issues/import pages.
Performance:
- Bake one soft glow sprite per colour into an offscreen canvas and blit
it with drawImage instead of allocating a radial gradient every frame.
This was the hot path: sparks + inbound pulses + every orb glow each
built a gradient per frame (100+/frame at 60fps). Colours quantised to
8-step buckets to bound the cache (imperceptible tint shift, keeps the
rainbow path from minting a sprite every frame).
- Cache each orb's button element at init so the 30-frame active-state
check no longer re-runs querySelector.
- Net: the pulses/glows look identical, far fewer allocations per frame.
UI:
- Enrichment manager modal topbar icon now uses the SoulSync logo
(trans2.png) instead of the helix emoji, matching the dashboard button.
- Nucleus logo now fits to the pulsing radius using the image's natural
width/height, so it no longer stretches to a square.
- Manage Workers button swaps the helix emoji for the SoulSync logo
(trans2.png) inside the existing accent badge.
The Manage Workers hub now draws /static/trans2.png (the SoulSync mark)
at its center instead of a plain colored core, scaled to the pulsing
radius and brightening slightly with energy. Energy-reactive glow, rings
and inbound pulses still surround it. Falls back to the drawn core while
the image loads.
Three upgrades to the Manage Workers nucleus:
- Energy-reactive: hub size, glow, heartbeat speed and ring count all
scale with how many workers are actually running. Calm + dim when idle,
big/bright/fast with 1-3 radiating rings when busy. The animation now
reads as a live gauge.
- Inbound pulses: active workers fire colored particles along their spokes
into the hub, so it visibly collects their output (eased to accelerate
on arrival; cleared on collapse so they don't snap).
- Orbital rotation: worker orbs get a tangential nudge around the nucleus
so the cluster slowly revolves like an atom instead of drifting randomly
(active orbs orbit a touch faster).
The 'Manage Workers' orb now acts as a central nucleus instead of just
another drifting particle:
- Settles at canvas center with strong pull + heavy damping (no jitter)
- Drawn larger and brighter with a slow breathing pulse, white core
highlight, and an expanding heartbeat ring
- Wired to every worker orb with full-length spokes (a traveling pulse
runs along each), so it visually reads as the center managing the cluster
- Other orbs repel off it, leaving a clean halo around the nucleus
The screenshot said it all — the orbs collapse into the floating particle cluster
after 7s idle, but the Manage Workers pill just sat there static. It wasn't in
worker-orbs.js's WORKER_DEFS. Added .em-manage-btn (purple) so it collapses into
a floating orb with the others and reveals on header hover — now it behaves like
the rest of the cluster instead of an out-of-place static button.
The modal opened with a plain pop — out of place next to the worker orbs. Now it
springs up from the bottom (toward the Manage Workers button) with the SAME easing
the orbs reveal with, then the worker rail assembles one-by-one: each chip springs
in staggered (scale 0.4→1) with a brief pulse of its own brand colour. Mirrors the
orb motion language AND walks your eye across every worker + its live state dot /
coverage bar as they land — cool + informative. Respects prefers-reduced-motion.
THE root cause of 'orb frozen, click does nothing visibly': when a socket is
connected, the orbs don't poll — update*Status() bails on socketConnected and
relies on server pushes. similar_artists was missing from BOTH the server emit
loop (_emit_enrichment_status_loop's workers dict) and the client dispatch
(core.js socket.on('enrichment:<id>')), so the orb never received status → never
updated. Clicks DID pause the backend (modal showed paused), but the orb visual
was frozen. Added the worker to the emit loop + the socket.on handler.
Root cause of 'click does nothing': I flip-flopped between inline onclick and
addEventListener. A cached index.html with my inline onclick + fresh JS with
addEventListener = the click fires the toggle TWICE (pause then resume) = no net
change. Now identical to AudioDB/Deezer/etc.: NO inline onclick on the button,
single addEventListener('click', toggle) in the init. One handler, one fire.
The addEventListener wiring evidently wasn't firing the toggle (orb showed
running but clicking didn't pause). Switched the button back to an inline
onclick=toggleSimilarArtistsEnrichment() — identical to the Amazon orb, which
works — and exposed the fn on window so the inline handler always resolves.
Toggle logic unchanged (active ? pause : resume).
Stop diverging — match toggleAmazonEnrichment/toggleSpotifyEnrichment verbatim:
contains('active') ? pause : resume. A paused orb isn't 'active', so a click
resumes it (same as every other worker). My earlier 'paused'-class variant was
what broke unpausing.
Class-based toggle had a hole: the orb may lack the 'paused' class even when the
backend is paused (before the first 2s status poll, or worker fallback), so a
click would PAUSE the already-paused worker (no-op) → 'clicking doesn't unpause'.
Now the toggle reads /status first and does the opposite of the real paused
state, so a paused worker always resumes on click.
The orb was excluded from worker-orbs.js's WORKER_DEFS list, so it never got the
shared 'collapse to floating orb after 7s idle / reveal on header hover'
animation (worker-orb-hidden / worker-orb-reveal) every other orb has. Added its
container (.similar-artists-enrich-button-container, purple) to the list.
- Orb wouldn't pause when the worker had finished its library: the toggle keyed
off classList.contains('active'), but a done worker sits in the green
'complete'/idle state, so clicking tried to resume (no-op). Now it pauses
unless already paused → pausable in any state.
- Switched from inline onclick to addEventListener (matches spotify/itunes/etc.,
the majority pattern) instead of the amazon/discogs inline style.
- get_stats now reports PERSISTENT counts from the DB (matched/not_found/pending
+ a progress.artists breakdown) instead of in-memory session counters, so the
dashboard orb tooltip and the Manage modal agree (was showing 0 vs 14 after a
restart) and it survives restarts — same approach as the other workers.
- Orb tooltip reads progress.artists ('Artists: 14 / 15 (93%)') like the rest.
- Worker now defaults to ON (running) instead of opt-in-paused; still honors a
saved pause across restarts. It self-paces (~3s/artist) and backs off on
MusicMap outages, so the orb spins/active like the others when there's work.
10 seam tests pass.
SoulSync standalone matches library tracks without Plex fetchItem,
reports missing counts correctly, and skips server playlist writes.
Automation re-syncs when the mirror grows; after sync finishes, starts
organize download (organize-by-playlist) or wishlist processing.
UI: Spotify URL playlist-folder controls, organize toggle layout in the
discovery modal, reload organize preference when reopening Download Missing.
Co-authored-by: Cursor <cursoragent@cursor.com>
Adds the dashboard status bubble (the small icon row) for the Similar Artists
worker, alongside the modal entry. Mirrors the per-source bubbles: MusicMap logo,
purple accent, spinner + active/complete/paused states, hover tooltip, and a 2s
status poll against /api/enrichment/similar_artists/status. Click toggles
pause/resume. Tooltip shows matched/pending (the worker has no artist/album/track
phases). 74 JS integrity tests pass.
Closes the gap where similar artists only existed for WATCHLIST artists: a new
background worker populates them for the whole LIBRARY, slotting into the
existing enrichment-worker pattern (bubble + Manage Enrichment Workers modal,
status/pause/resume, matched/not_found/pending/errors).
Per source-matched library artist → get_musicmap_similar_artists(name, 25)
(the same matcher the artist-detail page uses: fetches MusicMap names, matches
each to the user's source chain — primary + active fallbacks — returns only
matched artists) → store via add_or_update_similar_artist keyed by the artist's
metadata source id, the SAME key the watchlist scanner + artist map use, so the
two cooperate (idempotent upsert + retry_days window).
- core/similar_artists_worker.py: pure seams (pick_source_artist_id,
map_payload_to_store_kwargs, process_artist) + the threaded worker; skips
artists not yet source-matched; classifies not_found vs transient error
(retry after 30d).
- DB migration: similar_artists_match_status / _last_attempted on artists
(mirrors every other source worker's tracking columns).
- Registered in EnrichmentService + instantiated in web_server, DEFAULT-PAUSED
(opt-in) like Amazon — MusicMap is scraped/outage-prone + this is library-wide.
- SERVICE_ENTITY_SUPPORT['similar_artists']=('artist',) so the modal breakdown
('artists with / without similars') + Retry work; manual-match (inapplicable
to a relationship) is gated out via relationship:true.
- 10 seam tests; existing 80 enrichment tests still pass.
Note: keys under profile 1 (single-profile setups); multi-profile is future work.
The fixed hamburger (top:16 left:16, 44px, z9999) sat on top of the map's back
button on mobile. Push .artmap-nav-left right by 52px on <=760px so the back
button clears it.
- 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.
The persisted Standard/Enhanced preference was re-applied on every artist load
BEFORE the data came back — so for an artist not in the library (source-only, no
Enhanced view) it still flipped to Enhanced, which showed an empty Enhanced pane
and never rendered the discography.
Now the preference is applied inside loadArtistDetailData, after we know the
artist's status (data.artist.server_source). Only library artists honour a saved
'enhanced' choice; source-only artists always stay on Standard (discography).
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.