Add facade.test.ts asserting the two invariants that keep lib/api.ts safe:
no method name is defined in two slices (a silent last-wins spread override),
and the `api` object is exactly the union of the slices (nothing added inline
in the barrel, nothing lost). Verified to have teeth — an injected duplicate
key fails the collision test with a named message.
Closes R7.5: the api layer is now per-domain modules behind a mechanically
guarded spread-only façade.
Move the last four inline domains out of lib/api.ts into per-domain slices,
each owning its types + method slice. The barrel now holds ZERO endpoint logic
and zero types of its own — it only re-exports core's HTTP machinery, re-exports
each domain's types, and spreads the 15 slices into the single `api` object.
A new endpoint therefore has nowhere to land but a domain module. The `api`
shape and all call sites are unchanged (every req/beacon call byte-identical
to dev; no method dropped, no key collision).
Move feed, channels, sync, playlists, tags, notifications, saved-views and
quota out of the lib/api.ts god object into per-domain slice modules, each
owning its own types + method slice and spread into the façade. The `api`
object shape and all 54 call sites are unchanged; account/auth/setup/admin
stay inline until S2.
Reunites the interleaved tags read/CRUD that motivated the epic.
Reported: on iPad Brave a shared video's audio still stopped when backgrounded, while youtube.com
keeps playing. Root cause: routing the element's audio through createMediaElementSource pulls it out
of the normal media pipeline the browser's background-playback feature (and iOS) hooks into, and iOS
then suspends the AudioContext on background — the opposite of the goal. YouTube uses a plain media
element + Media Session and lets the browser background it. So drop the Web Audio graph entirely and
keep only the Media Session metadata + play/pause handlers, leaving native background handling in
charge. Frontend-only.
Facebook's scraper rejected our self-hosted poster as "Corrupted Image": the poster is an
ffmpeg-produced JPEG (non-standard marker order) that browsers/iOS/Twitter decode fine but FB's
stricter processor won't. For a YouTube source, point og:image at the video's stable, non-expiring
`i.ytimg.com/vi/<id>/hqdefault.jpg` (a Google-encoded JPEG FB decodes reliably) built from the id in
the stored thumbnail — instead of the stored `sqp`-signed variant, which can expire. Non-YouTube
sources keep the poster fallback (fine everywhere but FB). Declares 480×360 so FB includes it on the
first async scrape. og.py only — no schema change.
The catalog only stores the recent-backfill/RSS subset of a channel's uploads (video_count is
YouTube's raw total), so a subscribed channel looked like it was "missing" videos. Now the channel
page header shows "{stored} / {total} videos" when there's a gap, and a "Full history" action queues
the deep backfill (the same deep_requested trigger the channel manager uses) — showing a loading
state until done. Backend: channel_detail now returns deep_requested/backfill_done.
Failed downloads stored the raw yt-dlp/ffmpeg text and dumped it as a truncated red
one-liner in the queue. Classify the failure into a category (unavailable / login_required /
geo_blocked / format_unavailable / drm / postprocess / source_missing / network / unknown)
and surface it properly:
- backend: app/downloads/errors.classify maps the raw message to a category code; the worker
stores it in the new download_jobs.error_code (migration 0061) at both failure sites; resume
clears it; the serializer exposes it. Raw text kept as technical detail.
- frontend: the queue row shows a clean localized reason + a "details" link opening a modal
with the explanation, the raw text behind a disclosure, and a Retry only for transient
categories (network/postprocess/unknown).
- tests: classify() unit coverage over verbatim yt-dlp/ffmpeg message shapes.
The S3b api.ts split introduced 5 exported-but-never-imported types
(PlaylistKind/PlaylistSource, DownloadJobKind, YT internal option/data shapes).
The publish gate's knip lane flagged them (my per-step checks ran tsc/eslint/
prettier/vitest but not knip). They're used inside their own modules by the
interfaces, so make them local (consumers compare against literals, never name
the type).
- barrel re-exports domains with `export type *` (not `export *`) so the
internal downloadsApi/plexApi/messagingApi slices stay behind the façade;
plex's runtime value exports (EMPTY_PLEX_FILTERS/plexFilterCount) re-exported
explicitly
- App.tsx: drop the now-redundant `prefs as Record<string, unknown>` self-cast
(Me.preferences is already that type) and its `prefsRec` alias
- plex.ts: comment why plexSetState keeps its status union inline (reusing
VideoStatus would make an api.ts↔plex.ts type cycle)
Behaviour-identical. Gate green: typecheck, eslint 0, prettier, 74 vitest.
The E2EE direct-message types (MessageUser/Message/Conversation/MyKeyBundle/
KeySetup) + the ~9 messaging methods move into a self-contained
api/messaging.ts exporting a `messagingApi` slice (imports only core's req).
api.ts spreads the slice and re-exports the types. api.ts 882 -> 819 lines; no
runtime change. Gate green: typecheck, eslint, prettier, 74 vitest.
All Plex types (browse cards, show/season/item details, collections, native
playlists, filters/facets, watch-link) + EMPTY_PLEX_FILTERS/plexFilterCount/
appendPlexFilters + the ~40 plex methods move into a self-contained api/plex.ts
exporting a `plexApi` slice (imports only core's req/beacon). api.ts spreads the
slice and re-exports the types via `export *`. api.ts 1352 -> 882 lines (halved
from the original 1793); no runtime change. Gate green: typecheck, eslint,
prettier, 74 vitest.
The download-center types (DownloadJob/Spec/Profile/EditSpec/ShareLink/... and
the admin storage/quota shapes) + all ~30 download methods move into a
self-contained api/downloads.ts exporting a `downloadsApi` slice. api.ts spreads
the slice into the `api` façade and re-exports the types via `export *`, so the
53 call sites are unchanged. Cleanly separable — the domain's types reference
only each other and its methods only touch core's req/getActiveAccount. api.ts
1583 -> 1352 lines; no runtime change. Gate green: typecheck, eslint, prettier,
74 vitest.
First step of splitting the api.ts god module behind its façade. Move req,
HttpError, localizeDetail, the connectivity toast, ReqConfig, the per-tab
active-account plumbing (getActiveAccount/setActiveAccount/clearActiveAccount/
beacon), setupHeaders, and setUnauthorizedHandler into lib/api/core.ts. api.ts
imports the machinery it uses and re-exports the public surface, so the 53 call
sites importing from "./api" are unchanged. api.ts 1793 -> 1583 lines; no
runtime change (pure move). Gate green: typecheck, eslint, prettier, 74 vitest.
Removes all 13 remaining `any`s and flips the ESLint rule from off to error so
new ones can't creep back:
- api.ts prefs/data JSON bags: Record<string, any> -> Record<string, unknown>,
narrowed at consumers (App language guard; NotificationsPanel shapes the
notification `data` per type behind its existing typeof guards)
- catch (e: any) -> catch (e) + `e instanceof HttpError` narrowing
(SettingsPanel, SetupWizard, PlexPlayer)
- the YouTube IFrame API boundary: new lib/youtube.ts types (YTPlayer/
YTPlayerEvent/YTNamespace + a Window augmentation) replace the 6 player anys;
tsc surfaced the exact method surface to declare
- NotificationsPanel `t` props: any-options -> i18next TFunction
Gate green: typecheck (app+node+e2e), eslint 0 errors (rule now enforced),
prettier, 74 vitest.
- FeedResponse claimed a required `limit`, but /api/search/youtube never
returns it (only /api/feed does) and nothing in the app reads it. Drop the
field so searchYoutube's Promise<FeedResponse> stops lying; documented for
re-add if a consumer appears.
- pageMeta's hand-written 13-case Page->title switch now derives from the
ModuleDef registry via a new optional `titleKey` (the longer tab title, e.g.
"Channel manager"; falls back to the short nav labelKey). A new page is one
touch (add it to MODULES) instead of also editing a switch. A test pins all
13 title keys byte-identical to the old switch (suite 74).
Video.status, Playlist.kind/source (+ PlaylistDetail), DownloadJob.job_kind
were bare `string`; type them as named unions verified against the backend
(VALID_STATES={new,watched,hidden}; kind user|watch_later; source local|
youtube; job_kind download|edit). VideoStatus then propagates through the
optimistic-override map, onState, and the VideoCard/VirtualFeed/PlayerModal
props — tsc now rejects a status typo (e.g. a stale "in_progress", which is a
derived filter, never a stored status) at every call site.
Code-review follow-ups on the S2 factory:
- add queryKeys.test.ts pinning every branch (bare vs param, null-segment
preservation, single-id, variadic composite spreads) — the contract the
epic exists to protect now has a test (suite 68 -> 73)
- unify feed/feedCount/facets on the `!== undefined` guard the rest of the
factory already uses (behaviour-identical for the always-truthy FeedFilters,
removes the collapse-on-null footgun)
Two findings skipped with rationale: variadic composite-key typing (heterogeneous
object/array segments make precise tuples high-effort; root centralized + shapes
now tested) and a pre-existing thread(null) no-op (unreachable in practice).
Extend lib/queryKeys.ts to the remaining ~34 key roots and migrate every
remaining literal (queryKey: [...] AND the positional useLiveQuery keys) in
36 files. All ~200 React Query keys across the app now route through the
factory; zero raw key literals remain outside queryKeys.ts.
The heterogeneous composite plex keys (plex-library/facets/collections/
playlists carry a filters object, a ratingKeys array, or a union/group
discriminator) take a variadic pass-through — the factory centralizes the
ROOT string; single-id keys stay precisely typed. Nullable keys (thread,
playlist, ytSearch) accept null so a null segment is preserved.
Pure substitution — byte-identical key arrays, so TanStack prefix matching
is unchanged. Gate green: typecheck (app+node+e2e), eslint (0 err),
prettier, 68 vitest. Runtime-verified on a fresh dev server: Feed + Plex
modules load, feed/facets/tags/my-status/plex-library/plex-facets/
plex-collections/plex-playlists all 200, full UI renders.
Introduce lib/queryKeys.ts as the single source of truth for React Query
keys — the R7 driver was ~200 hand-written key literals across 51 files
(`["feed"]` alone in 15+), where one typo silently orphans a key so an
invalidate never matches.
S2a migrates the three highest-traffic domains (feed/video, channels,
playlists) plus the shared me/my-status/tags roots: ~90 literal sites in
18 files now call qk.*(). Pure substitution — each factory fn returns the
byte-identical array the call sites used before, so TanStack's prefix
matching is unchanged (qk.feed() still invalidates qk.feed(filters)).
Co-invalidation clusters are deliberately NOT bundled into helpers (they
differ per site), so no query's refetch scope changes.
Nullable keys (playlist(selectedId), ytSearch(q)) accept null so a null
segment is preserved (["playlist", null]) while a no-arg call is the bare
prefix key — guard is `!== undefined`.
Gate green: typecheck, eslint (0 err), prettier, 68 vitest. Smoke: app
boots, feed/facets/tags/my-status all 200, no console errors. Factory
extended to plex/messaging/admin/downloads/notifications in S2b.
- localizeDetail: don't leak FastAPI's snake_case `loc` field names into a
translated 422 toast; return a clean, fully localized validation message
(drops the loc filter/pop parsing entirely)
- me(): drop the dead `r &&` guard now that req<T> types r non-nullable
req() returned Promise<any>, so every api.* return annotation was an
unchecked assertion and the 17 action endpoints leaked `any` to callers.
Make req generic with a single audited cast at the boundary; T flows from
each method's declared return type via contextual inference, so no call
site needs a type arg except where none existed:
- me(): type the /api/me probe ({ authenticated } + Partial<Me>)
- deepAll / syncSubscriptions: real backend-verified response shapes
- HttpError.detailData, localizeDetail(d), inner detailData: any -> unknown
Also teach localizeDetail the FastAPI 422 detail-ARRAY shape (carried in
from R6 S1b review): now that malformed requests return 422, surface the
offending field via a localized errors.validation key instead of falling
through to the generic toast.
No runtime change on normal flows (req is await-identical); the 422 branch
only fires on genuinely malformed input. Gate green: typecheck, eslint,
prettier, 68 vitest. Smoke-tested localdev: /api/me + feed 200, no console
errors.
UAT feedback on the F-maximise view — three fixes:
- Own control bar in the big views (maximise + our fullscreen): previous/next +
N/M, auto-advance, loop, add-to-playlist, download, mark-watched, exit, and a
true-fullscreen toggle. Auto-hides on idle. Add/download are hidden in true
fullscreen (their popovers portal to <body>, outside the fullscreen element).
- Wheel volume works in the big views again: the centre interaction overlay is
now armed in maximise/fullscreen (it only yields to YouTube's own settings
menu), so the wheel bubbles to our handler instead of the bare iframe. F also
offers true fullscreen that WE own (Fullscreen API on the stage), so wheel/
arrow volume and our controls work inside it too — YouTube's own fullscreen
button, which we never surface, is not involved.
- Back (mouse/browser) steps out one level at a time via nested history layers
(useBackToExit): fullscreen -> maximise -> the modal -> closed, instead of
closing the whole player at once.
Verified in Chrome: wheel volume in maximise + fullscreen, the control bar, and
the full Back ladder (fullscreen -> maximise -> modal -> closed).
A mirrored Shows library whose items have no genres stored `genres` as JSON
`null` — SQLAlchemy's JSONB persists a Python None as a JSON scalar, not SQL
NULL. /facets unnested it with jsonb_array_elements_text(), which raises
"cannot extract elements from a scalar", so a single bare library 500'd the
whole Plex module on open (the grid loads, the filter sidebar's facets call dies).
- Guard the /facets genre unnest with jsonb_typeof(genres)='array', mirroring
the guard people.py already applies to the same columns.
- Persist the JSONB tag columns (genres/directors/cast_names/collection_keys on
plex_items and plex_shows) with none_as_null=True, so an empty list round-trips
as SQL NULL and self-heals on the next sync — closing the trap for any future
unnest.
Verified against the real prod row (DumaTV show, genres = JSON null): the
unguarded query errors, the guarded one returns cleanly.
The worst one was, again, the previous round's own fix.
- Round 7 stopped awaiting the HLS sweep so startup could serve immediately — which
also made it run CONCURRENTLY with playback, and `sweep_orphans` snapshotted the
live-session set BEFORE listing the root and rmtree'd outside the lock. Since
`start_session` reuses a directory path for the same (key, offset, tag), a stale
name can become a live session at any moment. Two guards now: nothing whose mtime
is newer than this process's start is ever deleted, and the live check + rmtree
happen together under the lock, one directory at a time.
- The sweep task is awaited after cancel, so shutdown can't leave it pending.
- `_rate_limiter` used a truthiness test on its timestamp, so a `now()` of exactly
0.0 read as "never ran" — unreachable in production, but `now` exists to be
injected and a test clock starting at 0 is the obvious choice.
- The RSS total-outage error now carries the first underlying error: "all N channels
failed" is also what ONE dead feed id looks like on a small instance, and a red
card the user can't act on is its own kind of dishonest.
- The WebKit fullscreen handling moved into `lib/fullscreen` and PlexPlayer uses it
too — it had the same unprefixed-only reads in three places, including an Escape
branch that would have closed the whole player instead of leaving fullscreen.
- The `--max` CSS comment claimed `inset: 0` did the sizing; Tailwind's `w-full` is
still on the element and wins. Comment now states the real mechanism.
Verified in real Chrome (the pane can't composite or do native fullscreen):
- F fills the 1920x889 viewport; the exit pill renders top-right on the letterbox
band and overlaps NO YouTube control — the collision this round suspected does not
reproduce in this embed (its own buttons sit bottom-left / bottom-right).
- Four real ArrowDown presses while maximised: persisted volume 100 -> 80, with the
level flash visible on screen.
- A real click on the exit pill un-maximises and returns focus to the card.
- lib/fullscreen driven from a real click: enter (innerHeight 889 -> 1024), the
change listener fires exactly once per transition, exit returns to 889.
Backend suite 150 -> 158; the new sweep tests are mutation-checked, and they read
their cutoff from the filesystem rather than a clock (the container VM and the
bind-mounted host disagree by enough to make a clock-based assertion flaky).
The reported bug: after F, the embed's own fullscreen button is dead. Root cause
measured in Chrome — the browser's fullscreen lock sits on OUR wrapper, so
YouTube's button would need a nested request from a cross-origin frame. Handing
the lock to the iframe fixes it, but `iframe.requestFullscreen()` only resolves
from a CLICK (from a key press the promise stays pending forever, and awaiting
it spends the activation so a fallback is denied too).
So: F keeps fullscreening our wrapper (unchanged, our overlays stay visible) and
a new toolbar button hands the lock to the iframe — verified in real Chrome:
document.fullscreenElement is the IFRAME at 1920x1024, i.e. a plain YouTube
fullscreen where its own controls work.
Round-6 review findings, all fixed:
- one _rate_limiter + one _cancel_poll shared by the yt-dlp hook and run_ffmpeg
(was three hand-rolled throttles; cancel latency differed 2s vs 1s)
- stepVolume takes {delta, fallback} — two adjacent number args were silently
transposable
- volume gestures before the player is ready are honoured instead of dropped
- normalizeVolume's fallback is typed unknown (its guard was unreachable) and
applyVolume takes a number again
- the throttle tests inject a clock instead of patching global time.monotonic
- run_ffmpeg stops the process from ONE exit path (`except BaseException`), so
an exception out of on_progress (a DB write) can no longer orphan a live
ffmpeg after a 10s join on its pipes; _stop is now a no-op on a dead process
- both run_ffmpeg callbacks are throttled to ~1s (they were a DB round-trip per
output line: ~10^5 queries on a long re-encode), mirroring the download hook
- volume arithmetic extracted to lib/playerVolume with unit tests; a corrupt
level now falls back to the LAST GOOD one instead of full blast, and the
sanitising happens where the stored value enters
- RSS is back to two except clauses (the shared tail already sits after the try)
- new tests: callback-exception cleanup, the two throttles; the fake Popen grew
poll() and its wait count is now asserted
Mutation-checked: each new group goes red when its fix is reverted.
- Feed memoizes onClose (like Playlists already did): the inline closure re-ran
PlayerModal's keydown effect on every Feed render, which since the debounce
landed also flushed the volume write and re-stole focus
- the player reads its volume with readAccountMerged again — a partial stored
object made the level undefined -> NaN -> a persisted null; applyVolume also
refuses a non-finite level outright
- that read moved into a lazy useState initializer (was running per render)
- the two RSS except branches share one tail, so a counter can't drift again
- run_ffmpeg tests cover the SIGTERM->SIGKILL escalation and the lingering-
process path; dropped an assertion that only restated the fixture
- storage.ts documents the actual { volume } shape (0 IS muted)
- the interaction overlay yields fully in fullscreen: YouTube only reveals its
control bar while the iframe itself sees the mouse, so after pressing F the
native fullscreen button was unreachable
- remember the volume per account (a YT.Player is created per video and always
starts at 100), plus ArrowUp/ArrowDown volume keys that work in fullscreen
- Ctrl/Cmd+wheel falls through to the browser's zoom instead of the volume
The decrypt-pass refactor hoisted partnerPub() above the loop and outside the
try, so a keyless partner (one who reset and hasn't re-set-up) or a fetch error
rejected the whole pass unhandled (setPlain never ran). Fetch the key lazily
inside the try and once, so an empty thread never fetches and a fetch failure
degrades to "can't decrypt" as before. Document the send-path staleness (a first
proactive send after a partner's key rotation uses the stale cached key) as a
known limitation alongside refreshPartnerPub.
- On a decrypt failure, refresh the partner's cached public key (and drop the
stale derived conversation key) once per pass and retry. A passphrase reset
rotates a keypair, so a partner who cached the old public key would otherwise
encrypt undecryptable messages until a reload; the first failed decrypt now
self-heals reads AND future sends. (pubCache was assumed set-once.)
- KeyGate reads has_password via a reactive useQuery(['me'], enabled:false)
passive observer instead of a one-shot getQueryData, so the reset password
field stays correct even if me resolves after mount.
- Document the remaining multi-device staleness (a reset on another device
leaves this one 'ready' with the dead key) as a known limitation in useKeyState.
The set-once key guard made a lost passphrase a permanent wall (409 on re-setup,
KeyGate offered only unlock/setup). Add an escape hatch:
- POST /api/messages/keys/reset drops the user's MessageKey so a new passphrase
can be set. Because every conversation key is derived from the keypair,
replacing it orphans all stored ciphertext in both directions, so the now-dead
`user` messages are deleted too (clean slate; system messages untouched).
Password accounts must re-verify (a hijacked session must not silently reset
messaging and read future messages); Google-only accounts rely on the session
+ the client danger-confirm. Rate-limited per user.
- KeyGate gains a "Forgot passphrase?" path in unlock mode → a danger reset view
(spells out the permanent history loss; a current-password field for password
accounts) → resets and returns to a fresh setup.
- e2ee.resetLocal wipes this device's key + derived conversation keys + the
IndexedDB copy; api.resetMessageKey; HU/EN strings.
- password reset also sets email_verified (the mail proves the mailbox),
rescuing an account that never clicked the verify link; is_active (admin
approval) is deliberately left untouched
- add POST /auth/verify/resend (rate-limited, off-response-path, anti-enum)
+ a "resend" mode/CTA on the verifyInvalid banner, so an expired/lost
verification link is no longer a dead end (re-register was a silent no-op)
- OAuth cancel/deny now redirects to /?login=oauth_cancelled with a friendly
banner instead of a raw 400 JSON page; pop the oauth_link markers BEFORE the
token exchange so an aborted link flow can't poison the next clean sign-in
- notifyYouTubeActionError: on any non-403 error, show the server's own reason (err.detail, e.g.
"Not enough quota left today") instead of the caller's generic fallback, which was discarded
(U-3.2.8). 403 still offers the Connect affordance.
- App boot error now offers a Retry (StateMessage) instead of a dead "Something went wrong" (U-4.2).
- deletePlaylist prefers the server's reason over a blanket "couldn't delete on YouTube" that
misleads when the failure wasn't the YouTube side.
- Already covered in earlier sprints (verified): the live-search "Load more" already surfaces the
quota error inline via err.detail, and ChannelPage subscribe/unsubscribe already route through
notifyYouTubeActionError.
Mini-epic "Downloads correctness & UX" (S1–S3), on top of the iOS-AV1 + 720p fixes.
S1 — naming & titles:
- ASCII-fold on-disk dir/file names AND the served (Content-Disposition) filename: no
spaces (underscore-joined), no accents (á→a, ő→o, ß→ss), pure ASCII — portable and free
of the `?` mojibake accented names show in non-UTF-8 tools. (Forward-only; existing files
clean up via re-download.)
- Strip a leading social engagement prefix ("1.6M views · 66K reactions | …") from titles.
S2 — universal browser playability (generalizes the iOS re-encode):
- ensure_browser_playable guarantees the stored file is H.264 + AAC/MP3 in mp4 — the only
combo every browser decodes. Re-encode just the offending stream(s) (video for AV1/VP9/HEVC,
audio for Opus/etc), or remux when only the container is wrong. Gated on the mp4 compat
profile (an explicit vcodec / non-mp4 custom profile opts out).
S3 — force re-download (preserves share links):
- POST /downloads/{job_id}/redownload keeps the job row so its public DownloadLink survives
and resolves to the freshly-downloaded file; deletes the old file and re-fetches under the
current rules. A failed re-download leaves the job in error (the intended broken-link case).
- Library "Re-download" action (icon + confirm), api method, hu/en strings.
Tests: naming/title-strip (test_naming), browser_transcode_flags matrix; existing
download_filename expectation updated for the underscore policy.
Review found two low-severity items:
- toast: armDismiss now always clears the prior timer (and arms only when duration is
truthy), so a coalesced repeat that upgrades a toast to requiresInteraction no longer
lets the original timer fire and dismiss it. Was a pre-existing gap the C-3.25 change
hadn't closed.
- overlay: documented the known limitation of useDismiss's blanket stopPropagation — a
PlexPlayer menu open during an auto-skip countdown swallows the Escape that would also
cancel the skip. Proper fix needs R8's shared layer-registry (topmost-only dispatch),
not a broad stopPropagation; accepted until then.
A coalesced repeat re-surfaced the toast and armed a new dismiss timer without
clearing the old one, so it vanished at the ORIGINAL deadline (6s not the expected
11s). Timers are now tracked in a Map<id> and cleared before re-arming (and on
manual dismiss).
Three player/overlay interaction fixes (PlexPlayer changes share a file, hence one
commit):
- U-C (Escape double-close): useDismiss's Escape now stopPropagation()s (its document
listener bubbles before a player's window listener, so one Escape closes only the
popover, not the popover AND the player). Modal exports modalCount(); both players'
Escape defers when a Modal is open above them, so Escape closes the dialog, not the
player under it and then the dialog.
- U-3.1.4: PlexPlayer's Space now defers to a focused BUTTON/A (e.g. "Skip intro")
instead of toggling playback — matching PlayerModal.
- U-3.3.12: the audio/subtitle menus used theme-adaptive glass-menu + forced
text-white (white-on-white in light theme); they now use the player's own explicit
dark panel (border-white/15 + bg-neutral-900/95), readable in both themes.
App.tsx imported CURRENT_VERSION from releaseNotes, which pulled its ~900-line
changelog into the eager App chunk and defeated the lazy ReleaseNotes split. App now
uses FRONTEND_VERSION (the built VITE_APP_VERSION, equal to RELEASE_NOTES[0].version
by release convention) from lib/version, and CURRENT_VERSION is removed. Verified in
the built bundle: the changelog text now lives only in the ReleaseNotes lazy chunk.