* feat(favorites): virtualize songs tracklist + memoize rows
10k+ starred songs no longer mount every row into the DOM. Same fix
shape as playlist virtualization: @tanstack/react-virtual windowing,
memoized FavoriteSongRow with stable callback bundle, scrollMargin
ResizeObserver anchored to .content-body. visibleTracks memoized once
per visibleSongs ref to avoid O(n) songToTrack on every click.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(favorites): review follow-ups for virtualization PR #805
Add maintainer comments for scrollMargin layout coupling and bulk-bar
useLayoutEffect dep. Record PR #805 in CHANGELOG and settings credits.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(playback): Lucky Mix after switching Subsonic servers
Hand off unpinned legacy queues to the browsed server, start the mix lock
before async work, skip cross-server enqueue toasts while rolling, bind
queue server before batch enqueues, and restore queueServerId on failed builds.
* chore(release): CHANGELOG and credits for PR #785
* fix(ui): stable play/enqueue hover on album rails and mainstage cards
Horizontal Home rails (Discover): drop content-visibility:auto and scroll-snap,
pin controls to cover hover, dim overlay via background. Grids and song cards:
pointer-events on overlays so WebKitGTK/Wayland GPU does not lose :hover.
* chore: CHANGELOG and credits for PR #787
* fix(ui): in-page browse virtual lists and cover load priority
Align virtualizer scrollMargin with the in-page scroll viewport so
artist/album rows do not vanish on deep scroll after #731. Resolve
CachedImage IntersectionObserver root to the nearest scrolling pane so
network fetch slots favor visible covers; throttle Artists infinite scroll
to one page per visibleCount update.
* chore(release): CHANGELOG and credits for PR #783
* feat(linux): session GDK defaults, nvidia-quirk, optional x11-legacy wrap
Ship PSYSONIC_ALLOW_NATIVE_GDK from Nix/AUR instead of pinning WEBKIT_DISABLE_*
and GDK x11. Add flake psysonic-x11-legacy for the old wrap; alias gdk-session
to psysonic. Startup uses webkit2gtk-nvidia-quirk and Wayland-aware compositing;
refresh Help (a45) and nixos-install docs.
* fix(linux): session GDK and nvidia-quirk only; drop wrapper env heuristics
Remove PSYSONIC_ALLOW_NATIVE_GDK and devShell GDK/WEBKIT exports; stop
synthesizing GDK/WebKit vars in main.rs. Update Nix/AUR wrappers, install
docs, CHANGELOG, and help FAQ with practical user-facing workarounds.
* fix(linux): X11-pinned GDK uses DMABUF quirk path, not Wayland explicit-sync
When GDK_BACKEND is forced to x11 on a wayland user session, webkit2gtk-nvidia-quirk
would still apply __NV_DISABLE_EXPLICIT_SYNC and gray out the webview. Map that case
to WEBKIT_DISABLE_DMABUF_RENDERER like native X11.
* fix(ui): stabilize WebKitGTK/Wayland hover paint for nav and media cards
Sidebar nav links avoid transition:all and promote icons with translateZ(0).
Artist rows and album/artist/song cards use compositing hints; card shadows
and borders no longer interpolate so cover zoom can stay smooth without jitter.
* fix(ui): isolate artist/album card text and cover paint on WebKitGTK
Promote cover blocks with contain/paint and text stacks with translateZ(0);
use artist-card-info on the artists grid for the same layout as other cards.
* feat(artists): in-page overlay scroll and locked main viewport
Move list/grid into an inner OverlayScrollArea, stop sticky toolbar from
owning the route scroll, align the rail with the main panel edge, and skip
the main-route overlay thumb when the viewport cannot scroll vertically.
* feat(browse): extend in-page overlay scroll to more library routes
Reuse the locked main viewport pattern from Artists for Albums, Composers,
Lossless albums, and New releases; wire VirtualCardGrid and scroll chrome
to the matching in-page viewport ids.
* fix(linux): improve Wayland GPU compositing text clarity in WebKitGTK
Use on-demand hardware acceleration on main and mini webviews when the
session is Wayland and compositing stays on; gate subpixel body AA on the
same conditions via new Tauri probes. Document PSYSONIC_SKIP_WAYLAND_FONT_TUNING
for opt-out and changelog.
* fix(rust): satisfy clippy needless_return in Linux webkit helpers
* fix(linux): tune Wayland text rendering with HW policy env and CSS
Allow PSYSONIC_WEBKIT_WAYLAND_HW_POLICY to select WebKit hardware
acceleration policy (never/always vs default on-demand). Extend Wayland
font CSS to #root with geometricPrecision and text-size-adjust on html.
* feat(linux): Wayland text presets in settings, safe WebKit apply, CPU default
Persist profile to app config; apply WebKit policy at startup/mini only to
avoid WebKitGTK hangs on live toggles. UI + CSS preview stays live; default
preset is sharp (CPU-friendly).
* fix(linux): map Wayland sharp preset to OnDemand WebKit policy
HardwareAccelerationPolicy::Never at startup broke main-viewport wheel
scrolling on WebKitGTK+Wayland; sharp vs balanced remains a CSS AA path.
Use PSYSONIC_WEBKIT_WAYLAND_HW_POLICY for a true Never policy.
* fix(rust): gate Linux-only Wayland WebKit helpers for Windows builds
Re-export startup helpers only under cfg(linux) and drop non-Linux stubs so
Windows compiles without unused-import and dead-code warnings.
* chore(release): CHANGELOG + credits for Linux session/WebKit work (PR #731)
Consolidate scattered incremental changelog notes into two [1.47.0]
entries with PR link; remove duplicate Linux blocks from [1.46.0] Fixed.
Append settings credit line for cucadmuh.
* experiment(zoom): allow setting webview zoom via core capability
* experiment(zoom): drive uiScale through Tauri's native webview zoom
Replace the CSS `zoom: uiScale` on `.main-content-zoom` (which only
scaled the main content column, leaving the sidebar, queue panel,
player bar and portaled overlays at 1.0) with a `setZoom` call on the
current webview. That scales everything inside the window the same way
Ctrl+/− does in a browser, including portals and the queue panel.
Effect runs whenever `uiScale` changes and once on mount, so the
persisted setting is reapplied on launch.
* docs: changelog + credits for interface scale (#781)
* feat(server-scan): plumbing for triggering library scans
Adds `startScan` / `getScanStatus` against the Subsonic API
(`fullScan=true` is Navidrome's extension), a small per-server scan
store, and a global polling hook (2 s cadence) that emits a toast when
each scan finishes. Scans can run on any configured server, including
inactive ones, by reusing `apiForServer`.
UI surfaces follow in the next commit.
* feat(server-scan): expose Quick / Full Scan in switcher + settings cards
Adds a `ServerScanActions` component with two variants (compact for the
server-switcher dropdown, card for the Settings server cards) backed by
the scan store from the previous commit. Full Scan requires a second
click within 3 s to confirm, matching the playlist-delete pattern.
Status slot shows a spinner with running track count while scanning, a
green check when finished, and a red icon on error.
The switcher row is converted from a single button to a flex container
so per-server scan controls don't hijack the server-switch click.
i18n added across all 9 locales.
* fix(server-scan): reorder switcher row to check / name / scan actions
Moves the check / spinner slot from the right edge to the left so the
spinner pop-in on server switch doesn't sit next to the scan icons.
Removes the layout shift that briefly hovered the Quick scan button
when the row re-rendered.
* feat(servers): edit existing server profiles in Settings → Servers
* Pencil-button on each server card opens an inline edit form that
replaces the card (prefilled name / URL / username / password).
* `AddServerForm` reused with an `editingServer` prop — title flips to
"Edit Server", submit label to "Save", magic-string field hidden (the
edit scope is manual fields; magic-string remains an add-time invite
shortcut).
* Edit saves unconditionally — ping runs post-save as a status indicator
(analog to the existing Test button) instead of gating the save. Lets
users update a profile when the server is currently unreachable.
* Translations across all 9 locales (`editServer`, `editServerTitle`).
* fix(servers): submit Add/Edit Server form on Enter
Wrapped the form body in a real <form>, made the submit button
type="submit", marked Cancel as type="button" so Enter no longer
cancels. Add-Mode now also responds to Enter — same flow, consistent
across both modes.
* fix(servers): collapse card action buttons to icon-only on narrow screens
* Quick-Scan / Full-Scan / Test buttons in each server card hide their
text label below 1100px viewport via the .server-card-btn-label class
and a single media query in connection-indicator.css.
* Labels remain accessible via data-tooltip and aria-label so screen
readers + hover both keep working in the collapsed state.
* No content reflow above the breakpoint — pure additive CSS.
* fix(servers): include Use button in icon-only narrow-screen collapse
The Use ("Verwenden") button on inactive server cards lacked the
.server-card-btn-label wrapper, so its text stayed visible at narrow
viewports and pushed Edit/Delete off-screen. Added a Power icon and
wrapped the label so it collapses alongside the other action buttons.
* docs(changelog,credits): #780 server scan + edit
* feat(settings): dim disabled toggle rows + rename Clear → Clear queue
* Settings toggle rows (`.settings-toggle-row`, `.sidebar-customizer-row`) dim
their non-toggle content to 0.6 opacity when the switch is off. Driven by a
single `:has(.toggle-switch input:not(:checked):not(:disabled))` rule so it
applies across every Settings tab without per-row markup. Mutex-disabled
toggles (Crossfade/Gapless) are excluded to avoid stacking with the existing
inline 0.45 dim on their row.
* Queue toolbar "Clear" button now reads "Clear queue" in all 9 locales for
parity with "Shuffle queue" and to distinguish from playlist clear/delete.
Adopted from @kveld9's PR #558 — cherry-picked the spirit, rewrote the dim as
a single global selector instead of three inline-styled customizer rows, and
extended the rename to the Romanian locale that landed after #558 was opened.
Co-Authored-By: kveld9 <179108235+kveld9@users.noreply.github.com>
* docs(changelog,credits): credit @kveld9 for #778
Co-Authored-By: kveld9 <179108235+kveld9@users.noreply.github.com>
---------
Co-authored-by: kveld9 <179108235+kveld9@users.noreply.github.com>
* fix(playback): keep browsed server when queue plays elsewhere
Lucky Mix on a non-playback server now clears the old queue and pins
the active server before building. Now Playing metadata uses
apiForServer against the queue server instead of forcing
ensurePlaybackServerActive on every activeServerId change.
* docs: note PR #768 in CHANGELOG and settings credits
* fix(now-playing): gate AudioMuse similar artists on playback server
Match getArtistInfoForServer credentials: when browsing another server
while the queue plays elsewhere, similar-artist count follows the queue
server's AudioMuse flag, not the browsed server.
Add a second entry to zz5zz's contributor block in settingsCredits.ts
that covers the steady stream of Discord bug reports alongside the
existing Norwegian (Bokmål) translation credit. Also add a "Special
thanks" banner at the top of the 1.46.0 CHANGELOG section linking to
the Psysonic Discord, since several of this release's polish fixes
landed directly off the back of those reports.
Playing/shuffling a big playlist set `queue` to ~10k `Track` objects.
zustand `persist` then `JSON.stringify`'d the whole queue into `localStorage`
on every persisted `set` → `QuotaExceededError` storm: playback never started
and the main thread stalled on each write.
`partialize` now persists only a ±250-track window around `queueIndex`
(remapping the index into the slice) instead of the entire queue.
The authoritative full queue lives server-side — synced via Subsonic
`savePlayQueue` and restored through `getPlayQueue`. localStorage is only a
fast local cache, so bounding it doesn't lose the queue; it just restores a
±250 window instantly while the full queue rehydrates from the server.
Controlled before/after on a 10,509-track playlist:
- before: QuotaExceededError ×9, play/shuffle dead
- after: QuotaExceeded = 0, playback works
* fix(stream): defer M4A probe until moov tail or fast-start prefix
Ranged moov-at-end M4A started Symphonia format probe as soon as ~384 KiB
linear data arrived, before the parallel tail prefetch filled the moov
atom — probe hit end of stream and skipped the track. Wait for tail_ready
or detect fast-start moov in the prefix; do not arm playback from linear
bytes alone when tail prefetch is active.
* fix(isomp4): skip EOF-spanning mdat after moov-at-end is parsed
Second-pass header scan with moov already loaded still tried to read
through mdat→EOF on RangedHttpSource holes, causing format probe
end of stream. Re-check play generation after moov wait.
* fix(isomp4): fix AtomIterator overread after seek in patched demuxer
`AtomIterator::new_root(reader, len)` treats `len` as bytes available
from the current `reader.pos()`, not the absolute file length. After
`mss.seek(resume_at)` we were passing the absolute `total_len`, so the
iterator thought there were `total_len` bytes left and tried to read
past EOF on the next iteration, returning "end of stream".
Fix: pass `total_len.map(|tl| tl.saturating_sub(resume_at))` (remaining
bytes from the new position) in both branches:
- `moov.is_none()` (moov-at-end layout, seek to moov offset)
- `moov.is_some()` (fast-start layout, skip bounded mdat body)
This caused Symphonia to fail probing moov-at-end M4A files read from
local disk (hot-cache) and from in-memory buffers — every decode attempt
returned "end of stream", analysis fell back to `byte_envelope_no_ebu`
(no EBU R128 loudness), and rodio produced distorted audio.
Also in this commit:
- `resolve_playback_format_hint()` helper to resolve hint from URL,
stream suffix, Content-Disposition, or byte sniff
- ISO-BMFF diagnostic helpers (`isobmff_buffer_looks_complete`,
`log_isobmff_buffer_diagnostic`, `mp4_suspect_zero_holes`)
- Probe-fallback path for ranged-stream failures now uses these helpers
to decide whether to refetch or wait for the in-flight download
* chore(audio): remove redundant hint recomputation and add missing blank line
`bytes_hint_for_wait` in the ranged-stream fallback path was an exact
duplicate of `effective_hint` already in scope — reuse the existing binding.
Also add missing blank line after `wait_for_ranged_mp4_probe_ready` in mod.rs.
* chore(release): CHANGELOG and credits for PR #757
Add Fixed entry for M4A moov-at-end probe fix and credits line in
settingsCredits.ts under existing cucadmuh contributions.
* fix(audio): extract BuildSourceArgs to fix clippy::too_many_arguments
`build_playback_source_with_probe_fallback` had 12 parameters, exceeding
the clippy limit of 7. Group url/gen/hints/fade/hi-res/duration into
`BuildSourceArgs` so the function signature stays at 4 arguments.
* feat(settings): clock format setting (Auto / 24h / 12h)
Reported on the Psysonic Discord — the Queue side panel's ETA label
and the sleep-timer preview both render via `formatClockTime`, which
just calls `toLocaleTimeString` and so follows the user's system
locale. On en-US that means AM/PM, with no in-app way out.
Add a tri-state **Clock Format** setting under
**Settings → System → App Behavior**:
* `auto` (default) — keep the existing locale-driven behaviour, so
bestehende installs are unaffected on first launch.
* `24h` — force 24-hour wall-clock output everywhere
`formatClockTime` is used.
* `12h` — force AM/PM output.
Wired through `authStore` (`clockFormat`, `setClockFormat`), exposed
via `CustomSelect` in `SystemTab`, and threaded into the two
consumers (`QueueHeader`, `PlaybackDelayModal`) so they re-render on
change. `formatClockTime` itself stays a pure helper — it accepts the
setting as an optional second argument and maps it to `hour12`.
Locale coverage: all nine bundled locales (en, de, es, fr, nl, nb,
ru, zh, ro) get the four new settings strings. Pin tests added for
the `setClockFormat` setter and the `hour12` mapping in
`formatClockTime`.
* docs(changelog): clock format setting + contributors (PR #742)
* feat(playback): stream buffering UI, ranged M4A tail prefetch, demuxer fix
Defer seekbar/progress until HTTP stream is armed for both legacy and
RangedHttpSource; show buffering overlay on cover art. Add MP4 tail
prefetch and Symphonia isomp4 bounded-mdat/moov-at-EOF probing so
moov-at-end M4A can start without reading the full mdat.
* feat(hot-cache): spill large ranged streams to disk for promote
When a ranged HTTP download completes above the 64 MiB RAM promote cap,
write the existing buffer once to app-data stream-spill/ and register it
for hot-cache promote (rename) and replay via fetch_data. Analysis seeds
from the spill file up to the local-file cap (512 MiB).
* fix(ui): stream buffering — grayscale cover and static clock icon
Desaturate player and queue cover art while isPlaybackBuffering; keep a
non-animated clock overlay for visibility without the spinning animation.
* fix(playback): review follow-up — tests, i18n, spill cleanup, changelog
Clippy and test layout fixes; stream spill orphan cleanup on startup;
buffering flag guard in progress handler; bufferingStream in all player
locales; CHANGELOG and contributor credits for stream/M4A work.
* docs: attribute stream buffering and M4A streaming to PR #737
* test(audio): avoid create_engine in stream spill unit test
CI runners have no audio output device; test spill take/consume via
the Mutex slot only, matching install_stream_completed_spill tests.
* feat(queue): persist header duration mode (#625)
The queue header chip cycles total / remaining / ETA, but the choice
lived in QueuePanel `useState` and reset to 'total' on every app launch.
Move it into `authStore` (persisted under `psysonic-auth`) so the chosen
mode survives restarts like other UI preferences.
- `DurationMode` consolidated in `authStoreTypes` (re-exported from
`queuePanelHelpers` so existing component imports stay valid).
- New `queueDurationDisplayMode` field + `setQueueDurationDisplayMode`
setter wired through `createUiAppearanceActions`, default `'total'`.
- `computeAuthStoreRehydration` validates the persisted value against
the three known modes (pattern matches the existing seekbarStyle
block) — garbage, null, undefined, or a missing key map back to
`'total'` so the chip never receives an unknown mode.
- `QueuePanel` reads / writes via `useAuthStore` selectors instead of
local `useState`.
- Tests: trivial-setter coverage in `authStore.settings.test.ts` and a
new `authStoreRehydrate.test.ts` covering corrupt, missing, and
valid rehydration cases.
Reuses kveld9's design from PR #625; not merged because the 1.46
refactor split locales and reshuffled queue-helper exports. Credited
via Co-Authored-By trailer.
Co-Authored-By: Kveld. <kveld912@proton.me>
* docs(changelog): queue header duration mode persistence (#724)
Adds the CHANGELOG entry + kveld9 credits line referencing the real PR
numbers (no #TBD placeholder) — feedback.md §2.7+§2.8 workflow: commit
docs as a second push onto the open PR.
---------
Co-authored-by: Kveld. <kveld912@proton.me>
Adds a new sub-section under Settings → Personalisation (Advanced) that
hides individual controls in the player bar: Star rating, Favorite
(heart), Last.fm love, Equalizer, Mini player. Last.fm love still only
renders when a Last.fm session exists; the overflow row in the player
collapses when both Equalizer and Mini player are hidden.
- New `playerBarLayoutStore` (Zustand + persist, items[{id, visible}] +
rehydrate sanitize) following the queueToolbar / playlistLayout
pattern; defaults to all visible.
- New `PlayerBarLayoutCustomizer` reuses the same row + toggle pattern
as the other personalisation customisers.
- Gates threaded through `PlayerTrackInfo` (3 controls), `PlayerBar`
(EQ + Mini buttons), and `PlayerOverflowMenu` (EQ + Mini in the
overflow row, with row-level conditional).
- `PersonalisationTab`: added as the last advanced sub-section so it
only appears when the global Advanced Mode toggle is on.
- Settings search index gets entries for both Playlist page layout and
Player bar (playlist row was missing).
- New i18n keys `settings.playerBar*` in all 9 locales.
Reuses kveld9's design from PR #627; not merged because the locale
split and the Advanced Mode refactor landed afterwards. Credited via
Co-Authored-By trailer + a new line in settingsCredits.ts under the
existing kveld9 entry.
Co-authored-by: Kveld. <kveld912@proton.me>
Adds a per-element visibility toggle for the playlist detail page (Add
Songs, Import CSV, Download ZIP, Cache Offline, Suggestions) and reworks
the way uncommon options are surfaced: instead of a per-tab collapsible
group, a global "Advanced" toggle in the Settings header reveals all
`advanced` sub-sections across every tab and marks each one with a small
badge. Sets the pattern up so any future advanced option lives in its
natural tab, gated by the same switch.
- New `advancedSettingsEnabled` boolean on `authStore`
(UiAppearance slice, persisted with the rest of the store).
- `SettingsSubSection` gains an `advanced?: boolean` prop. Hidden when
the toggle is off; renders an "Advanced" pill in the header when on.
- Settings header gets a Toggle-Switch next to the search lupe.
- `PersonalisationTab` flattens — Sidebar + Home stay always visible;
Artist sections, Queue Toolbar, and the new Playlist layout get
`advanced` and disappear by default. `PersonalisationAdvancedGroup`
component + CSS removed.
- New `playlistLayoutStore` (Zustand + persist, items[{id,visible}] +
rehydrate sanitize) following the queueToolbarStore pattern.
- `PlaylistHero` and `PlaylistSuggestions` gate the four toolbar buttons
and the suggestions rail on the store directly.
- One-time migration in MainApp on mount: if the user had opened the
old per-tab Advanced group (`psysonic_personalisation_advanced_open
=== 'true'`) OR already customised any of the three sub-sections,
Advanced Mode auto-enables on first launch. Idempotent via a
localStorage flag; legacy key removed afterwards.
- New i18n keys `settings.advancedMode`, `settings.advancedModeTooltip`,
`settings.advancedBadge`, `settings.playlistLayout*` in all 9 locales.
Reuses kveld9's design from PR #556; not merged because the locale split
landed afterwards. Credited under the existing kveld9 entry in
settingsCredits.ts.
Co-authored-by: Kveld. <kveld912@proton.me>
* fix(playback): pin queue streams, cover art, and library links to queue server
When the active server changes while a queue from another server is playing,
keep streams and UI on queueServerId; switch back for artist/album links and
queue or player-bar context menus.
* fix(playback): switch to queue server when opening Now Playing
Ensure active server matches queueServerId before Subsonic fetches on the
Now Playing page, mobile player route, and queue info panel; scope caches
by server id.
* docs(credits): mention Now Playing in PR #717 contribution line
* fix(playback): route scrobble and queue sync to queue server
Address PR review: apiForServer for scrobble/now-playing/savePlayQueue,
clear queueServerId on server removal, mini-player queueServerId sync,
block cross-server enqueue with toast, and regression tests.
Global queue paste opens the preview modal before play; overlay scrollbar,
context-menu suppression, changelog/credits for PR #716 and DanielWTE (#551).
* fix(ui): use stable list keys on Now Playing dashboard cards
Subsonic payloads can repeat the same id in similar artists, album
track rows, and top songs. Keys now combine id with list index so
React reconciliation stays stable and duplicate-key warnings stop.
* docs: changelog and credits for Now Playing list keys (PR #703)
Document the dashboard list key fix in CHANGELOG and Settings contributors.
* fix(settings): sort the contributors list chronologically
The Settings → System contributors list rendered the array in raw
insertion order, so Psychotoxical (since v1.0.0) showed up last and
hand-maintained ordering drifted over time.
Sort on export instead: ascending by the `since` app version (reusing
isNewer), tie-broken by the first-contribution PR number. The list
stays correctly ordered regardless of where new entries are inserted.
* docs(changelog): add entry for contributors list sort fix (#700)
* fix(ui): split OpenSubsonic album and track artists in header and player
Album detail header uses albumArtists from album or child songs; player bar,
mobile player, and mini player use structured track artists with per-id links.
Adds deriveAlbumHeaderArtistRefs helper and OpenArtistRefInline.
Fixes#552
* docs: changelog and credits for OpenSubsonic artist links (PR #696)
* fix(player): align cached cover URL with cacheKey on track change
Prevents a one-frame stale blob src (and broken image in the player bar)
when switching tracks; reset CachedImage load state in useLayoutEffect.
* docs: changelog + credits for cover-art track-switch fix (PR #695)
Pull pure helpers, the contributors/maintainers data list and the
tab type/search index out of `Settings.tsx` so the main component
only has tab-section render logic + the orchestrating state left:
- `utils/audioDeviceLabels.ts` — five ALSA-device label helpers
(formatAudioDeviceLabel + duplicate-disambiguation + sort +
select-option builder).
- `utils/formatBytes.ts` — formatBytes + snapHotCacheMb.
- `components/settings/LoudnessLufsButtonGroup.tsx` — small chip
group used by the audio tab.
- `components/settings/settingsTabs.ts` — `Tab` type + legacy alias
map + `resolveTab` + `SearchIndexEntry` + `SETTINGS_INDEX` + the
`matchScore` substring-with-fuzzy-fallback scorer.
- `config/settingsCredits.ts` — `CONTRIBUTORS` (~270 LOC of static
contributor history) + `MAINTAINERS`.
Pure code-move. Settings.tsx: 3552 → 3037 LOC (−515). Settings/G
journey now 5298 → 3037 LOC (~43% reduction).