The "Test all sources" Deezer probe called _ssJson("/api/deezer-download/test") with no opts, i.e. a
GET — but that route is POST-only, so it 405s, the probe throws, and the dot goes red even though Deezer
downloads fine. POST it with an empty body (the endpoint then tests the SAVED ARL). The other probe
endpoints (qobuz/hifi/amazon/soundcloud status) are all GET, so this was the only offender.
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.
A 0..1 range slider (Safe <-> Adventurous) in Settings -> Discovery with a live value readout. It
auto-saves via the settings page existing range-input listener, persists to config under
discover.adventurousness (matching the /api/settings "discover" section), and re-populates on load.
The listening-recs route reads that key (fixed from discovery. -> discover. to match the settings
section). Drag it up and "Based On Your Listening" leans harder toward obscure picks on the next load.
64 script-integrity tests green.
_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.
Bumps base version 2.8.1 → 2.8.2 and the docker-publish default tag. A stability + performance
release: Spotify reliability (Docker boot-hang #949, the token-cache re-auth fix, on-demand
Sync-to-Spotify), the "slow after update" password-manager fix + Max Performance mode (#948), and
large-library imports that no longer time out the import page (#947).
Updates the five release touch-points: web_server version, docker-publish default, pr_description.md,
helper.js WHATS_NEW + VERSION_MODAL_SECTIONS (current release + Earlier-in-2.8.1 summary), and the
new RELEASE_2.8.2_discord.md (truncated for Discord, 1351 chars).
Re-review caught a real bug in the phase-2 polling: it refetched only the files query, but the
album import tab uses its OWN groups query (album-import-tab.tsx). So after a large-folder scan
completed, the album tab would stay stuck on its initial {scanning} response and never populate
(the singles tab was fine — it reads the polled files query). Switched the scan poll to
invalidateImportStagingQueries (files + groups + suggestions) so every mounted staging query
refetches when the scan finishes. (Suggestions is already async/cached, so it just no-ops.)
typecheck clean, scanning test still passes.
Frontend half of the async staging scan. The endpoints now return {scanning, progress} while a
large staging folder is still being scanned in the background; the page surfaces that and fills in
automatically when it completes — no manual refresh, no timeout.
- types: ImportStagingFiles/GroupsPayload gain optional scanning + progress (additive).
- useImportStaging exposes `scanning`/`scanProgress` and, while scanning, polls via a plain
setInterval(refetch, 1500). Deliberately NOT react-query's refetchInterval — and a plain interval
that only runs while scanning leaves the normal + error query states completely untouched.
- the header shows "Scanning N of M files…" instead of a count while the scan runs.
vitest: new test asserts the scan-progress header renders from a {scanning} response; typecheck
clean. Note: -route.test.tsx's pre-existing "staging files fail to load" test fails only in the
full-file run (passes in isolation) — verified it also fails on clean dev with all my changes
stashed, so it's a pre-existing test-isolation flake, not from this change.
Double-checking the on-demand auth flow: the needs_auth handler called window.open() AFTER an
await, which breaks the user-gesture chain so browsers popup-block it — the user would see "approve
in the new tab" with no tab. Replaced with a clickable authorize link (a direct click is never
blocked).
Adds two endpoint tests via the Flask test client: Spotify export returns needs_auth + the
/auth/spotify/export url (and short-circuits before the DB) when the token lacks write scope, and
does NOT short-circuit when write scope is present. 10 service-export tests green, 64 script-integrity
green, ruff clean.
Brings back Spotify playlist export WITHOUT the regression that forced every user to re-auth.
The safety property: the global login scope (SPOTIFY_OAUTH_SCOPE) is NEVER changed, so no
existing token is invalidated. The write permission is requested only when a user actually
exports to Spotify.
- SPOTIFY_EXPORT_SCOPE = the global read scope + playlist-modify, used ONLY by the new
/auth/spotify/export route. Spotify returns a superset token; the normal /callback exchanges
and stores it unchanged (read ⊆ read+write keeps the standard auth check valid) — no callback
changes needed.
- SpotifyClient.has_write_scope() checks the cached token for playlist-modify.
- start_playlist_export_service returns {needs_auth, auth_url} for Spotify when the token lacks
write, instead of starting a doomed job. The modal opens the consent in a new tab and tells the
user to retry once approved; the "Sync to Spotify" button is back, gated on connection as before.
- Release notes (pr_description / What's New / version modal / discord) restored to Spotify &
Deezer with the one-time-permission note; discord back under 2000 chars (1983).
Tests: export scope is a strict superset of the (still read-only) global scope; has_write_scope
true/false for write/readonly/missing tokens and no-client. 275 spotify/oauth tests green, ruff
clean, 64 script-integrity green.
Follow-up to the auth hotfix (633aa82b). The Spotify playlist-write scope was reverted out of the
global OAuth scope (it was force-invalidating every user's token on upgrade), so "Sync to Spotify"
can't get write access yet — clicking it would dead-end on a misleading "reconnect Spotify". So:
- removed the "Sync to Spotify" button from the export modal (Deezer stays); the backend write
client + endpoint are left in place, dormant, for when on-demand write-auth lands
- modal copy is now Deezer-only ("Match missing tracks (Deezer)", "stored Deezer ID")
- release notes (pr_description, helper.js WHATS_NEW + version modal, RELEASE_2.8.1_discord.md)
reworded from "Spotify & Deezer" to "Deezer", with a "Spotify export coming in a follow-up" note
64 script-integrity tests green; discord file back under the 2000-char limit (1952); no stale
Sync-to-Spotify mentions remain. Deezer export (live-verified) is unaffected.
The settings info icons are role="button" spans with a text "i" glyph but no
cursor/user-select, so hovering the glyph gave the I-beam text caret on Windows
(Linux happened to resolve a pointer). Add cursor:pointer + user-select:none so
it reads as a button on every platform.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Max Performance only neutralized animation/blur/shadow globally but didn't
replicate the reduce-effects-specific display:none rules, so with reduce-
effects OFF the sidebar aura orbs (.sidebar::before/::after) survived as two
hard static circles, the dash-card cursor-glow layers stayed, and nav-button
hover kept the expensive treatment. Depended on whether reduce-effects was on
before enabling Max Performance. Extend those three rule blocks to also match
body.max-performance — flash-free since the body class is server-rendered.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Expose window.__pmSuppress.disable()/enable() on the suppression IIFE so a
before/after benchmark can reproduce the pre-fix "before" state (managers
re-attach their autofill overlay) and restore it, without a rebuild. The
app itself never calls these; suppression stays on by default.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two CPU regressions surfaced in software-rendered / no-GPU containers
(Docker), where transform/opacity and canvas radial-gradient fills
rasterize on the CPU instead of a compositor:
1. Worker-orbs canvas + decorative motion saturate a core and freeze the
UI. A new opt-in "Max Performance" mode is the nuclear low-power switch:
body.max-performance CSS kills blur/shadow/filter AND all
animation/transition (spinners go static), and JS halts every canvas
loop (orbs, particles, cursor-glow, API sparks) via window._maxPerfActive.
Reduce Visual Effects is now decoupled from the orbs — they follow their
own toggle; only Max Performance force-kills them. While Max Performance
is on, the Orbs/Particles/Reduce-Effects checkboxes lock + grey out, and
save reads the runtime flags so prefs aren't clobbered.
2. Password managers (Bitwarden et al.) rebuild their autofill overlay on
every DOM mutation; a captured trace showed Bitwarden using ~6x the CPU
of the whole app (~400 setupOverlayOnField/sec). suppressPasswordManager-
Autofill() tags non-credential inputs with data-bwignore / data-1p-ignore
/ data-lpignore / data-form-type=other so the managers skip them; real
login/PIN fields are left alone.
Wired through: web_server.py (_initial_appearance_context), index.html
(inline flag + body class + checkbox), init.js (applyMaxPerformance +
bootstrap + listener + autofill suppression), settings.js (load/save),
worker-orbs.js / particles.js / api-monitor.js (gates), style.css.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bumps base version 2.8.0 → 2.8.1 and the docker-publish default tag. Headline is the
Spotify/Deezer playlist export (#945); also the Library Reorganize rename-only mode (#875),
broader lossless handling (#941/#939), download + search fixes, the refined reduce-visual-effects
pass, and merged contributor PRs (#942/#943/#944).
Updates the five release touch-points: web_server version, docker-publish default, pr_description.md,
helper.js WHATS_NEW + VERSION_MODAL_SECTIONS (current release + Earlier-in-2.8.0 summary), and the
new RELEASE_2.8.1_discord.md.
The export modal now checks connection on open via /api/discover/your-albums/sources (cheap
token/ARL check, no live verify) and greys out + relabels any service that is not connected
("Not connected — set up X in Settings → Connections first"). Clicking a gated button nudges to
Settings instead of starting an export that would just fail with "not connected". The fetch runs
after the modal renders, so a slow/failed check never blocks the modal (buttons stay usable).
Pairs with the existing scope-403 handling: a Spotify token without playlist-modify still shows as
connected (it IS), and the writer returns the clear "Reconnect Spotify to grant playlist write
access" message — so "not connected at all" and "connected but needs reconnect for write" are both
covered. Static file, no rebuild.
Adds the third resolver stage for tracks the discovery cache + library can't resolve — a live
search of the target service, gated behind a "Match missing tracks" toggle so the API cost is opt-in.
The whole point is coverage WITHOUT the wrong-track risk, so it's a CONFIDENT match, not "search
and grab":
- search_service_track_id(artist, title, search_fn): searches the service, reranks via the existing
relevance scorer (filter_and_rerank), and returns the top hit's id ONLY if it clears
BACKFILL_MIN_SCORE (1.2 on the score_track scale). A wrong-artist hit (no 1.5x exact-artist boost,
caps ~1.0) or a karaoke/cover (x0.05) can't clear the floor → None, and the track is left out
rather than added wrong. search_fn injected → unit-testable without a live service.
- resolve_service_track_ids gains an optional search_id_fn: cache → library → search. Tallies
from_search separately.
- _run_service_export builds the search fn from the service's metadata search client only when
job['backfill'] is set; the endpoint reads `backfill` from the body; the modal adds the toggle and
the status line shows "(N matched live)".
Store-back of confident matches deferred: a mirrored-only track may have no library row to write to,
so persisting needs the track→library mapping — a follow-up, not correctness.
9 new tests incl. the safety ones: wrong-artist rejected, karaoke/cover rejected, real-over-cover
picked, fail-safe on search error, and the cache→library→search waterfall + toggle wiring (on/off).
28 export/orchestration tests green, 64 script-integrity green, ruff clean.
Adds "Sync to Spotify" and "Sync to Deezer" buttons to the mirrored-playlist export modal
(#pl-export-modal), alongside the existing ListenBrainz/JSPF options. They POST to the new
/export/service/<service> endpoint; the shared poller now reports service progress
("Pushing to Spotify…"), the matched/unmatched count, and links the created playlist on
done. ListenBrainz/JSPF paths unchanged. Static file, no rebuild.
This completes the #945 vertical: resolver → Spotify+Deezer write clients → export job +
endpoint → modal. Reverse-sync to a service is a clean export (uses the stored per-track
service IDs from enrichment), distinct from true bidirectional sync.
The Wing It pool "Fix Match" search returned "no results" for everything (even obvious
tracks). Root cause: /api/spotify/search_tracks built a Spotify field-filtered query
(track:X artist:Y) and handed it to spotify_client.search_tracks, which falls back to the
user's configured source when official Spotify isn't serving the request. The fallback
(Deezer here) got the raw Spotify `track:…artist:…` syntax it can't parse and aborted the
connection (RemoteDisconnected) — so the user's perfectly working Deezer failed ONLY on
this path, on this query format. The iTunes and Deezer search endpoints already dropped
field syntax for exactly this reason; the Spotify one was the lone holdout.
fix:
- new pure helper relevance.build_combined_search_query(track, artist, legacy) — plain,
source-agnostic query; documents WHY field syntax is wrong here. the endpoint already
reranks by expected title/artist, so precision is recovered without the brittle syntax.
- the Spotify endpoint uses it (now consistent with iTunes/Deezer).
- frontend (searchPoolFix): surface the real error (auth / 500 / upstream abort) instead
of masking everything as "No results found" — which is what made this undiagnosable.
5 helper tests incl. the regression (output must contain no 'track:'/'artist:' syntax).
654 metadata/search tests green, 64 script-integrity green, ruff clean.
Adds an "Action" selector to the reorganize modal — "Full reorganize (default)" vs
"Rename only (skip post-processing)" — with a hint explaining rename-only skips
re-tagging/quality/AcoustID, only touches files whose name changes, and that renaming
can reset media-server play counts / date-added. executeReorganize sends rename_only in
the apply POST. Default is full → existing behaviour unchanged. Static file, no rebuild.
Pairs with the previous commit (orbs now run under reduce-effects). When the user has asked
for performance (reduce-effects on) we don't need the orbs at 60fps — the slow drift and sparks
are indistinguishable at 30, and dropping every other render roughly halves the per-frame canvas
cost, keeping the "orbs under reduce-effects" experiment cheap.
The canvas still ticks at 60fps and frameCount still increments every tick, so `time` stays
real-time and the drift speed is unchanged — we just draw it half as often. Precedence: the
existing fully-asleep ~20fps throttle still wins; the 30fps cap only applies awake + reduce-effects.
Chrome users with full effects keep 60fps — no reason to dim them.
Reduce-effects used to force-kill the worker orbs (isEnabled() had && !_reduceEffectsActive),
which also made the orb toggle a dead setting whenever reduce-effects was on.
The assumption was "the orbs ARE the expensive thing." On inspection that looks wrong: the
dashboard orb glow is drawn with canvas radial gradients, not a CSS blur(28px). The genuinely
expensive blur is the SIDEBAR aura orbs + frosted glass (CSS filters), which reduce-effects
still kills via filter:none regardless. So the orb canvas's per-frame cost should be moderate,
not the blur-rasterize lag.
So decouple them: the worker-orbs toggle controls the orbs on its own; reduce-effects keeps
killing the expensive CSS rendering but no longer gates the orbs. This also fixes the dead-toggle
conflict (the orb toggle now works under reduce-effects instead of being silently overridden).
Empirical: try it and watch the dashboard CPU. If the orb canvas under reduce-effects pushes it
back up, revert is one token — re-add `&& !window._reduceEffectsActive` to isEnabled().
The blurred 60fps worker-orb canvas is the main remaining Firefox lag source after the
#935 sweep (multiple Discord lag reports). So for a FIRST-TIME user with no saved
preference, default the orbs OFF on Firefox (smooth first impression where it's needed)
and ON everywhere else (full polish where the browser handles it). An explicit saved
choice ALWAYS wins — this only picks the default when the user hasn't chosen.
Done kettui-style with a SINGLE source of truth, not the dual browser-detection I first
floated (server UA + client _isFirefox would be the same fact in two places that can
drift — exactly the server/client class #943's green-flash fix just cleaned up):
- core/ui_appearance.py (new, pure + importable): is_firefox_user_agent +
resolve_worker_orbs_default(explicit, is_firefox) — explicit wins, unset → !firefox.
- web_server: the SERVER decides (UA via _request_is_firefox, request-context-safe) and
injects initial_worker_orbs_enabled; config default flipped None so "unset" is
distinguishable from an explicit False. The client just consumes the injected value
(init.js unchanged) — no client-side re-derivation of "is Firefox".
- settings.js: the orb checkbox default now reflects the server value when unset, so
saving Settings can't silently flip a first-time Firefox user's orbs back on.
No regression: Chrome users unchanged; users with an explicit setting unchanged (it
wins regardless of browser); /api/settings returns raw config so it can't clobber the
default for an unset value. Verified end-to-end through a real Flask request context
(Firefox→off, Chrome→on, explicit wins both ways, no crash outside a request). 8 pure
seam tests pin the contract; ruff clean.
"Reduce visual effects" was a sledgehammer: body.reduce-effects * forced
animation:none + transition-duration:0s on every element. That froze every CSS loading
spinner mid-rotation — including the dash-header worker-service spinners (musicbrainz /
spotify / deezer / … .active .<svc>-spinner) — which read as BROKEN rather than "off".
It also killed cheap hover feedback like the Quick Actions buttons.
The actual lag (esp. Firefox, see the #935 sweep) is backdrop-filter / box-shadow /
filter re-rasterizing every frame — NOT the animations themselves. Transform- and
opacity-only motion (the spinners) composites for ~free.
So: keep forcing the expensive properties to none (unchanged — that's the real fix),
but drop the blanket animation/transition kills. !important author declarations outrank
animation + transition declarations in the cascade, so any keyframe/transition that
tries to set blur/shadow/filter is still neutralized even while it runs — the spinner
spins, just without the glow. Net: functional spinners stay alive, Quick Actions hover
(transform + border-colour) returns, box-shadow transitions are no-ops (shadow forced
none), and the GPU-heavy rendering that caused the lag stays gone. The worker-orb CANVAS
is unaffected (JS-gated separately) and stays off under reduce-effects, as intended.
Static guard test pins the contract: the global rule must keep the expensive-property
kills and must NOT reintroduce blanket animation:none / transition:0s.
Discord (Shdjfgatdif): the import-singles Identify search prefilled "artist title" (space), so
"Sub Focus Last Jungle" returned junk while "Sub Focus - Last Jungle" found the track. The
placeholder already hints "Search artist - title..."; the prefill just did not match it. Join with
" - " instead of " ". filter(Boolean) keeps a lone title (no artist) dash-free.