Commit Graph

1042 Commits

Author SHA1 Message Date
cucadmuh d5476b9249 perf(ui): fix spike when medulla-perch lines up with hair-fan gestures (#283)
The harness parks the pointer on the micro-target medulla perch, then runs a hair-fan sweep through it; that combination used to restyle and repaint more of the chrome than the compositor could absorb. Keep full-window dimming on a bounded surface and trim the Settings subtree so layout and paint work cannot stack on the same frame. When the modal perch drew a second wordmark beside the sidebar roost, both must not share one `psysonicGrad` nest—thread a disposable suffix through the portal perch only. Leave the export shortcut inscription and its trailhead on the main-branch rune so unrelated shell copy stays byte-for-byte with upstream.
2026-04-24 00:51:35 +02:00
Frank Stellmacher e86133738d fix(ui): swap Gapless / Infinite Queue toolbar icons (#284)
Reported in #274 — the icons for the Gapless and Infinite Queue toggles
were the wrong way round. The infinity symbol was on Gapless, but reads
much more naturally on Infinite Queue (a queue that never ends), and the
arrow-up-to-line on Infinite Queue had no obvious mapping at all.

Move infinity to Infinite Queue. Use MoveRight (right-pointing arrow) for
Gapless — visualises 'flows straight through to the next track without
stopping' and stays distinct from the chain/link family at 13 px in both
the QueuePanel and Mini Player toolbars.

Closes #274
2026-04-24 00:48:55 +02:00
Frank Stellmacher e721b3060d revert: temporarily back out medulla-perch perf change (#281) pending follow-up (#282)
Backing out #281 for now — follow-up coming with the chrome restyle path bounded more tightly. Will re-land with the corrected sweep ordering.
2026-04-24 00:34:49 +02:00
cucadmuh c54aa22e6b perf(ui): fix spike when medulla-perch lines up with hair-fan gestures (#281)
The harness parks the pointer on the micro-target medulla perch, then runs a hair-fan sweep through it; that combination used to restyle and repaint more of the chrome than the compositor could absorb. Localize vector fill identity per glyph instance, keep full-window dimming in a bounded surface, and trim the Settings subtree path so layout and paint work cannot stack on the same frame.
2026-04-24 00:29:55 +02:00
Psychotoxical 861798ea7a Merge branch 'main' into exp/orbit 2026-04-24 00:19:14 +02:00
Frank Stellmacher 73a04e4c00 fix(mini-player): drop saved position when its monitor is gone (#280)
The mini player window persists its top-left position to
`mini_player_pos.json` and re-applies it on every open. With multiple
monitors that breaks in three failure modes:

- Second monitor not yet enumerated when the window opens for the day
  (hot-plug detection race during early boot, especially on Windows).
- Monitor reorder / resolution change since the last save.
- Monitor unplugged.

In all three the saved coords land in the void and the window appears
off-screen. Validate the persisted position against `available_monitors()`
before applying it: require the saved top-left plus an 80 px corner to
fit inside any current monitor. If not, fall back to `default_mini_position`
(bottom-right of the main window's monitor). The persisted file is left
untouched so the position comes back the next time the missing monitor
is present again.

Validation runs in both `build_mini_player_window` (initial creation) and
`open_mini_player` (re-show, where Linux WMs may re-centre).
2026-04-24 00:16:04 +02:00
Frank Stellmacher 5c87a94170 fix(mini-player): portal volume popover so it cannot get clipped (#279)
`.mini-player` has `overflow: hidden`, and the absolute-positioned volume
popover (`top: calc(100% + 6px)`, ~150 px tall) was being clipped when the
queue was closed and the mini window was at its short height — visually
the slider rendered outside the visible area, even though it stayed
fully usable. Opening the queue grew the window enough to bring the
popover back inside the clip rect.

Render the popover via `createPortal` to `document.body` and position it
with `position: fixed` based on the trigger button's `getBoundingClientRect`,
mirroring the YearFilterButton pattern. Auto-flips above when there is
not enough room below (short mini windows). Repositions on window
resize / scroll. Outside-click handler now checks both the trigger button
and the popover ref since they no longer share a DOM ancestor.
2026-04-24 00:02:02 +02:00
cucadmuh b082f51213 Merge pull request #278 from Psychotoxical/feat/lucky-mix-flow
feat(player): Lucky Mix — instant mix from listening history and ratings
2026-04-24 00:46:26 +03:00
Maxim Isaev a17d4c883d merge: integrate origin/main into feat/lucky-mix-flow 2026-04-24 00:30:40 +03:00
Frank Stellmacher 56d44046e3 fix(audio): defer chained-track volume to gapless transition (#277)
`audio_chain_preload` runs ~30 s before the current track ends to queue the
next source for sample-accurate gapless playback. It was also calling
`Sink::set_volume(effective_volume)` with the *next* track's
volume*ReplayGain — but `Sink::set_volume` affects the whole Sink,
including the still-playing current source. Result: loudness of the
currently-playing track audibly shifted exactly 30 s before its end,
toward the next track's level.

Move `set_volume` to the gapless transition block in `spawn_progress_task`,
where `cur.replay_gain_linear` / `cur.base_volume` are already swapped.
The boundary itself is still sample-accurate; only the volume update is
deferred until the chained source is actually live.

Side effects audited:
- hi-res rate-mismatch fallback returns before the sink block — unaffected
- manual skip during chain pending: audio_play creates a fresh Sink — unaffected
- audio_set_volume / audio_update_replay_gain before transition operate on
  the current track's gain — still correct
- after transition, cur.replay_gain_linear holds the chained track's gain
  before the new set_volume runs, so user volume changes remain correct
- crossfade is mutually exclusive with gapless — separate code path

Closes #275

Co-authored-by: Psychotoxical <dev@psysonic.app>
2026-04-23 23:23:59 +02:00
Frank Stellmacher b97b6c545b fix(windows): tighten WebView2 idle hooks from #273 review (#276)
- Type `window.__psyHidden` once via global declaration; drop 6 `(window as any)` casts.
- `WindowVisibilityProvider` now ORs `window.__psyHidden` into the hidden state
  (and into the initial state). On WebView2 `document.hidden` does not always
  flip when `win.hide()` is called, so effects gated by `useWindowVisibility()`
  can now actually skip creating intervals/rAF instead of relying solely on the
  per-tick fallback check.
- Slow visible-poll from 200 ms to 500 ms (5 → 2 wakeups/s for visibility alone).
- Fix stray leading-space indent in `App.tsx` `window:close-requested` comment.

Co-authored-by: Psychotoxical <dev@psysonic.app>
2026-04-23 22:50:36 +02:00
Ivan Pelipenko 0ead4fbeb1 fix(windows): idle WebView2 when Tauri windows are hidden (#273)
* fix(windows): stop GPU rendering when windows are hidden

* perf(ui): tighten hidden-window rendering mitigation

Align implementation and documentation with what the injected pause/resume
scripts actually do, reduce main-thread and compositor wakeups while windows
are invisible, and make lifecycle cleanup reliable.

Rust (Tauri)

- Document PAUSE/RESUME as compositor-oriented hints: set __psyHidden, optional
  --psy-anim-speed for CSS that opts in, and pause @keyframes via
  animation-play-state only (not CSS transitions, not arbitrary timers or rAF).
- Walk descendants under #root instead of document-wide querySelectorAll('*')
  to cut cost on large pages; skip the walk if #root is missing (flag still set).
- Call eval(PAUSE_RENDERING_JS) before hide() on all hide paths (tray menu,
  tray icon toggle, mini open/close/show-main, native mini close) so the
  webview sees __psyHidden while still fully schedulable.
- Shorten pause_rendering command rustdoc to match the script.

Frontend

- WindowVisibilityProvider: replace self-rescheduling useCallback with a single
  effect that keeps one pending timeout id, uses a cancelled flag on unmount,
  and syncs hiddenRef from document.hidden at mount (no orphaned timers after
  tests/HMR).
- WaveformSeek (preview + animated seekbar): while hidden, poll with a 400 ms
  timeout instead of requestAnimationFrame every frame; cancel both rAF and
  timeout on teardown.

Why

- Accurate comments prevent future refactors from assuming “full JS idle”.
- Smaller DOM walks and fewer rAF wakeups reduce spikes when hiding the main
  window or mini player on Windows WebView2 and elsewhere.

* perf(ui): pause global CSS + timers when Tauri hides the window

Builds on PR #273 (`7803d8e` + `0e07a73`). Adds a second HTML flag driven only by
Rust pause/resume inject so infinite animations still pause when WebView2 keeps
`document.hidden === false` after `win.hide()`, and stops a few periodic JS
timers while the window is invisible.

- `lib.rs`: set/remove `data-psy-native-hidden` on `<html>` in PAUSE/RESUME JS;
  document why in rustdoc (rest of pause/resume unchanged from PR tip).
- `components.css`: same `animation-play-state: paused !important` rules as
  `data-app-hidden` for `[data-psy-native-hidden="true"]`; refresh the comment.
- `App.tsx`: comment distinguishes tab visibility vs Tauri-native hide.
- `Hero.tsx`: do not run the 10 s carousel interval while hidden
  (`useWindowVisibility` + `__psyHidden` in tick).
- `PlaybackScheduleBadge.tsx`, `playbackScheduleFormat.ts`: no 400/500 ms
  intervals while hidden; skip ticks when `document.hidden` or `__psyHidden`.
2026-04-23 22:47:23 +02:00
Psychotoxical e9e61b9a05 refactor(lucky-mix): review follow-ups from Psychotoxical
- Drop the ~110 lines of dead full-screen overlay CSS that was never
  referenced in JSX (.lucky-mix-overlay*, .lucky-mix-cube*, full-screen
  @keyframes). Keep the .lucky-mix-pip* rules since the inline queue
  variant re-uses them.

- Extract availability gate into hooks/useLuckyMixAvailable.ts (pure
  predicate + hook). Replaces five duplicated inline checks in Sidebar,
  MobileMoreOverlay, RandomLanding, Settings/SidebarCustomizer, and the
  internal re-check in buildAndPlayLuckyMix.

- Add a cancel mechanic:
  * luckyMixStore gets a cancelRequested flag + cancel() method
  * LuckyMixCancelled sentinel is thrown from a bailIfCancelled() helper
    sprinkled between await boundaries in the build loop
  * catch-block swallows the sentinel silently (no toast, no error state)
  * auto-cancel subscription on playerStore detects manual user track
    changes (anything not in queuedIds) and flips cancelRequested so the
    finished mix does not later overwrite the user's choice
  * inline .queue-lucky-loading dice indicator is now a button — click
    triggers explicit cancel, hover fades the dice to signal the action

- Restore the queue snapshot when the build fails before ever starting
  playback, so the user does not land in an empty player after an error.
  If playTrack already ran, leave current state alone (their track plus
  whatever was enqueued is more useful than a stale pre-click queue).

- Rework locale labels to consistently carry the "Mix" suffix so the
  entry reads well next to "Random Mix" / "Random Albums" in the
  sidebar: DE Glücks-Mix, ES Mezcla Suerte, FR Mix Chance, NB Lykkemiks,
  NL Geluksmix, ZH 好运混音. EN stays "Lucky Mix", RU stays
  «Мне повезёт». Toast/settings/randomNavSplit copy aligned to the new
  names. New luckyMix.cancelTooltip key added in all 8 locales.

AudioMuse gating stays — as Cuca confirmed, the feature's quality relies
on a correct similar-track selection, so the requirement is intentional.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 18:41:46 +02:00
Maxim Isaev ab5c8e0b48 feat(lucky-mix): instant mix from your preferences
Lucky Mix targets ~50 tracks in one run: pick seeds from your most-played artists/albums and 4+ rated songs, add similar tracks, then fill the mix with random library picks, skipping anything you rated 1–2.
On start it trims the old “upcoming” tail so the queue does not show stale next tracks, starts playback on the first viable seed, routes to Now Playing, and can emit structured steps to the backend debug log.
2026-04-23 18:42:52 +03:00
Psychotoxical dff2c4a121 exp(orbit): teardown edge cases
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 17:39:33 +02:00
Psychotoxical c7af6a6e15 exp(orbit): polish pass
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 17:23:28 +02:00
Psychotoxical 11dddf6290 exp(orbit): iterative refinements
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 16:56:28 +02:00
Psychotoxical 2b97a7b01e exp(orbit): host merge, guest queue view, settings popover, i18n
Guest suggestions now auto-merge into the host's play queue at random
positions inside the upcoming range, so they actually surface alongside
host-picked tracks instead of piling up at the end. Per-item dedupe via
addedBy:addedAt:trackId keys survives reshuffles.

Guests get a read-only mirror of the session in place of their queue
panel — live-card for the host's current track, suggestion list with
submitter attribution, and a footer reminding them the host owns
playback. Track metadata is resolved lazily and cached locally.

New host-only settings popover (gear in the bar) with auto-approve and
auto-shuffle toggles plus a "Shuffle now" action. Settings live in the
OrbitState blob and push immediately to Navidrome; missing fields on
older blobs default to "both on".

15-min shuffle now touches the real playerStore queue via a new
shuffleUpcomingQueue action — previous behaviour only reshuffled the
guest-facing suggestion history. A manual shuffle is available from the
settings popover so the interval can be verified without waiting.

Start-modal reworked into a single step: share-link is visible and
copy-able from the start, auto-copied on Start if the host hasn't
already hit Copy. Link carries a live name-derived slug (parser strips
it, SID is authoritative). LAN/remote-server hint above the facts.
Random session-name suggester pulls from three pattern pools for ~10k
unique combinations.

All user-visible Orbit strings moved to a dedicated orbit i18n
namespace (en + de); other locales fall back to en via i18next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 02:49:42 +02:00
Psychotoxical 7fe492d233 exp(orbit): start modal, paste-to-join, suggest track from context menu
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 01:21:19 +02:00
Psychotoxical dc82e49bd1 exp(orbit): participants list, kick flow, exit modals
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 01:13:00 +02:00
Psychotoxical 9e1256e200 exp(orbit): mount hooks and session top strip
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 01:07:40 +02:00
Psychotoxical cebf0e238d exp(orbit): periodic shuffle
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 01:03:18 +02:00
Psychotoxical 60e0bbfa2a exp(orbit): track pipeline and participants sweep
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:59:59 +02:00
Psychotoxical 87c51e3b11 exp(orbit): guest join/leave + tick hook
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:54:58 +02:00
Psychotoxical a45943d078 exp(orbit): host lifecycle and tick hook
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:47:53 +02:00
Psychotoxical e398d68184 exp(orbit): scaffold store and types
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:41:41 +02:00
Frank Stellmacher 694567843f feat(player): polish sleep-timer UI — circular ring + in-button countdown (#272)
Replaces the text-pill schedule badge with a circular SVG progress ring
around the play/pause button. Accent→lavender gradient stroke, counter-
clockwise depletion synced to the armed deadline, subtle accent glow.

While a timer is armed, the Play/Pause icon inside the button is
swapped for a two-line stack: a small Moon (sleep timer) or Sunrise
(delayed start) glyph above the countdown (m:ss / h:mm:ss). The icon
is the mode marker so the ring can stay unified with the rest of the
app's accent palette.

Redesigns the schedule modal with a mood-tinted header (Moon for
"Pause after", Sunrise for "Start after"), soft radial accent glow in
the background, slide-up open animation. Circular glass close-button
scoped to this modal, with hover rotation + focus ring. Custom-minutes
field gains an inline "min" suffix. A live preview line at the bottom
shows when the action fires ("Pauses at 23:47" / "Starts at 23:47"),
updating as the user hovers a preset or types. Chips gain a small
hover lift plus accent-coloured shadow.

Also fixes a pre-existing duplicate-tooltip on the play/pause button:
title= attributes removed alongside the data-tooltip (title violates
the project's "never native tooltips" rule, so both showed at once).

Adds scheduledPauseStartMs / scheduledResumeStartMs to the player
store so the progress ring has a total-duration baseline, set and
cleared alongside the existing deadline fields.

New usePlaybackScheduleRemaining hook wraps the store subscription
and 500 ms tick into a single hook used by all three player views
(PlayerBar / FullscreenPlayer / MobilePlayerView).

i18n: delayPreviewPause / delayPreviewStart keys across all 8 locales.

Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:13:28 +02:00
cucadmuh 624ce56faf feat(player): sleep timer and delayed start via long-press on play/pause (#270)
Add scheduled pause and resume timers in the player store, cleared on stop
and track changes. Long-press opens a compact preset modal anchored above
the transport row; one-tap presets plus custom minutes. Portaled countdown
badge on the play button; clear the long-press click guard when the modal
closes so the first play/pause click works after scheduling.
2026-04-22 23:32:44 +02:00
cucadmuh c5dfabf739 feat(sidebar): long-press drag to reorder and hide nav items (#269)
Hold for 1s then drag to reorder configurable library and system links;
drop outside the sidebar to hide items (same visibility as Settings).
Show a trash hint near the cursor when outside removal applies.
Extract shared reorder helpers into src/utils/sidebarNavReorder.ts and
reuse them from the Settings sidebar customizer.
2026-04-22 23:31:30 +02:00
cucadmuh 2d320d8681 fix(linux): stop Wayland GTK drag proxy and PsyDnD ghost (#268)
Block capture-phase dragstart in the webview so stray native HTML5 drags
(e.g. SVG reorder grips) do not create a stuck translucent GTK surface on
Wayland compositors.

Harden PsyDnD lifecycle when mouseup never reaches the webview: blur,
visibilitychange, pointerup/cancel in capture, and Escape cancel the
floating label without dispatching a drop.

Add -webkit-user-drag:none on sidebar customizer grips.
2026-04-22 22:28:24 +02:00
Frank Stellmacher db72ed9e5a feat(now-playing): draggable widget cards with per-user layout (#267)
Each dashboard card (Album, Top Songs, Credits, Artist, Discography,
On Tour) is now a widget the user grabs and drops anywhere — reorder
within a column or move across columns. Layout persists per-install
via the new nowPlayingLayoutStore (Zustand + localStorage, same shape
as sidebarStore with an onRehydrateStorage guard for unknown IDs).

Drag uses psyDnD (useDragSource / psy-drop event, the mouse-event
based system from DragDropContext) — HTML5 native DnD is a no-go on
WebKitGTK Linux where it always shows a forbidden cursor. The whole
card is the drag handle; the column listens via a document-level
mousemove for the drop indicator and via a global psy-drop listener
that reads the latest hovered column from a ref, so drops below the
last card still land correctly regardless of column height.

Visibility is toggled from a layout menu in the top-right of the
dashboard — two sections (visible / hidden) plus a reset-to-defaults
button. No hide buttons on the cards themselves (keeps the card UI
uncluttered). An equal-height grid (align-items: stretch + min-height
on columns) keeps the drop zone active for the full vertical span of
either column.

Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 21:14:13 +02:00
Frank Stellmacher b4f31e0954 feat(now-playing): redesign page as info dashboard (#266)
Replace the flat Now Playing page with a two-column dashboard: hero
(cover, metadata, badges, Last.fm stats + love button, release-age
tagline), and a grid of themed cards (Album tracklist with sliding
window, most-played-by-artist, credits, about-the-artist with Last.fm
bio fallback, compact discography contact-sheet with expand, upcoming
tours via Bandsintown). Adds lastfmGetTrackInfo + lastfmGetArtistStats
for global listener counts + userplaycount, plus Last.fm love/unlove
wired to the new hero button. Module-level TTL caches per entity keep
same-artist track switches instant. Artist-image guard filters the
well-known Last.fm no-image placeholder so the card collapses cleanly
when no real image exists.

Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 20:30:09 +02:00
Frank Stellmacher f0e5b2542b feat(settings): results-only search, keyboard nav, flash animation, Ctrl+F (#264)
Follow-up on #263. Refines the cross-tab search into a proper
command-palette-ish flow and polishes the UX around it.

Results-only mode:
- When a query is active, the tab content is hidden entirely; the
  results list is the only thing shown. Unified list (was: in-place
  current-tab filter + "In other tabs" panel underneath), sorted with
  current-tab matches first and then by score.
- Removed the DOM-based hide/show logic and its CSS helper class, the
  dead searchOtherTabs i18n key, and the searchHits/otherTabHits state.

Keyboard navigation inside the search field:
- ↓/↑ moves selection through the result list (clamped, no wrap).
- Enter activates the selected item (same flow as a click — clears
  query, switches tab, scrolls + opens + flashes the target).
- Mouse hover syncs with the keyboard selection so the two input modes
  don't fight each other.
- Selected item gets accent-border + tinted background; scrollIntoView
  with block:'nearest' keeps it visible when the list is long.

Ctrl/Cmd+F (Settings page only):
- Window keydown listener registered on mount, removed on unmount →
  the shortcut is live only while Settings is rendered.
- Opens the search input, focuses + selects it even if already open.
- preventDefault blocks the native WebKit find bar.
- Placeholder now hints at the shortcut: "… (Ctrl+F)" / "… (⌘F)" on
  macOS, no new i18n keys needed.

Target flash animation:
- After navigating from a result, the target SettingsSubSection gets a
  1.4s accent-colored border + glow pulse so the user immediately sees
  which entry the result pointed at. Reflow trick lets it retrigger
  when clicking the same result twice.

Misc:
- "Next Track Buffering" → "Buffering" in all 8 locales + the section
  comment in Settings.tsx. The SETTINGS_INDEX keeps "next track" as a
  keyword so old search habits still find it.

Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 15:12:48 +02:00
Frank Stellmacher ae7b94f190 feat(settings): cross-tab fuzzy search + tab restructure polish (#263)
Cross-tab settings search:
- Static SETTINGS_INDEX covering every sub-section of every tab, with
  free-form keywords per entry so "scrobble" finds Last.fm, "replay gain"
  finds Playback, etc.
- matchScore() does substring-first ranking, falls back to a char-in-order
  fuzzy match so typos still yield hits.
- Active tab keeps its in-place hide/show filter; matches from other tabs
  render at the top as a clickable "In other tabs" list (tab badge +
  sub-section title). Clicking clears the query, switches tab, and scrolls
  + opens the target sub-section.
- All 8 locales updated: new searchOtherTabs key, searchNoResults reworded
  (old "try another tab" hint is now handled by the list itself).

Settings restructure polish:
- "Next Track Buffering" moved from Audio to the Storage tab — sits
  between Offline Library and Downloads.
- Storage tab label renamed across 8 locales: "Storage & Downloads" →
  "Offline & Cache" (equivalents per locale). nb locale was missing
  tabStorage entirely; added. Tab id stays 'storage' to avoid breaking
  persisted state.
- Library tab: Random Mix sub-section now opens by default (previously
  both sub-sections started collapsed) and is renamed "Random Mix
  Blacklist" in all 8 locales — the section is entirely a blacklist
  config, the old title was misleading.
- Lyrics tab:
  * Standard / YouLyPlus rows use the same toggle style as the "Show
    lyrics as static text" row and stack vertically. Order swapped so
    YouLyPlus is first.
  * Provider list (Server / LRCLIB / Netease) moved to appear directly
    under the Standard toggle as its sub-item, indented, instead of after
    the static-text toggle.
  * Classic / Apple Music-like rows likewise rebuilt as vertical toggle
    rows.
- One pre-existing typo carried in from #261 fixed along the way: the
  openAddServerInvite handler in Settings referenced the renamed tab id
  'server' (pre-#259) — now 'servers'. [Note: already landed in PR #262,
  keeping this commit clean of that.]

Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 14:25:46 +02:00
Frank Stellmacher e89ae18780 fix(server-switch): keep cached playback alive + Settings tab-id typo (#262)
* fix(server-switch): keep cached playback alive across servers

Drop the clearQueue() + initializeFromServerQueue() calls that were added
alongside the header server switcher (c75297fc). clearQueue() fires
audio_stop, killing the currently-playing (cached) track the moment the
user clicks a different server. Restore the pre-c75297fc behaviour: just
swap activeServerId, let the Rust audio engine keep streaming the
already-resolved URL, and let the next user action or the app-boot hook
pick up the new server's saved queue.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(settings): use renamed 'servers' tab id in paste-invite handler

Leftover from #261 landing on top of #259 — the Settings tab was renamed
from 'server' to 'servers' in #259, but the openAddServerInvite route-state
handler still used the old literal. Caused a TS error and meant pasting a
psysonic1- invite did not actually switch tabs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 13:32:59 +02:00
Frank Stellmacher 7c9a300022 feat(share): library deep links (psysonic2) with paste + toolbar actions (#261)
* feat(share): library deep links (psysonic2) with paste and toolbar actions

Add base64url-encoded payloads for track, album, artist, and ordered queue.
Handle paste outside inputs to switch server, validate entities, navigate or
play. Reuse clipboard helper for context menu, queue panel, album and artist
headers. Tests distinguish server invite strings from library shares.

* fix(share): play partial queue when some shared tracks are missing

Skip unavailable song IDs when pasting a queue share while preserving order.
Toast when some tracks are missing; error only if none resolve. Add
openedQueuePartial and queueAllUnavailable i18n; remove queueTracksMissing.

* feat(settings): paste server invites globally and scroll to add form

Handle psysonic1- magic strings outside inputs: navigate to settings or login
with prefilled add-server fields. Decode invites embedded in surrounding text.
Scroll the add-server block into view when opening from a paste so long server
lists stay oriented.

---------

Co-authored-by: Maxim Isaev <im@friclub.ru>
2026-04-22 11:21:00 +02:00
Frank Stellmacher 21d00889aa fix(navidrome-admin): resilient admin API calls + UI polish (#260)
* fix(navidrome-admin): force HTTP/1.1 + User-Agent + no idle pool for /auth and /api calls

Replaces plain reqwest::Client::new() in all nd_* Tauri commands
(navidrome_login, nd_list_users, nd_create_user, nd_update_user,
nd_delete_user, nd_list_libraries, nd_set_user_libraries) with a shared
nd_http_client() helper that:

- sets a real User-Agent (Psysonic/<version> (Tauri))
- pins HTTP/1.1 (avoids HTTP/2 ALPN that some reverse proxies abort on)
- disables the idle-connection pool so a second call doesn't reuse a
  TCP connection that the server or proxy has already half-closed
  (was producing intermittent "tls handshake eof" on external servers).

Adds nd_err() to flatten the reqwest error source chain into the
returned String, so the frontend surfaces the real cause (connection
refused, tls handshake eof, etc.) instead of reqwest's opaque
"error sending request for url (…)" wrapper.

* fix(navidrome-admin): retry + graceful error + server row polish

Rust (src-tauri/src/lib.rs):
- nd_http_client: HTTP/1.1, TLS 1.2 only, no idle pool — browser-parity
  for /auth and /api so strict reverse proxies that abort reqwest's
  default HTTP/2+TLS-1.3 handshake mid-flight get through.
- nd_retry: one retry after 500ms on connect/timeout errors only
  (ECONNRESET, TLS handshake EOF). Aggressive retries could push the
  nginx upstream probe into offline state; this is the minimal useful
  amount.
- nd_err: flatten the reqwest error source chain so the UI surfaces the
  real cause instead of reqwest's opaque wrapper.

User management UI:
- Sequential load (users, then libraries) instead of parallel, to avoid
  racing two TLS connections on a single nginx upstream slot.
- Friendlier failure state with a one-click Retry button that re-runs
  load(). Concrete Rust error is kept as muted sub-line.
- User row layout: Magic-String button sits consistently next to
  last-seen + delete for every user, regardless of admin status or
  library-name length. No more mid-row jitter.

Servers tab:
- "Use" button no longer redirects to Home; stays on the Servers tab so
  the active-badge migration is visible.
- Drag-and-drop reorder via grip handle (psyDnD), backed by a new
  setServers() action on authStore.
- Active server row now has an accent-tinted background on top of the
  border — harder to miss.

New i18n keys (userMgmtLoadFriendly, userMgmtRetry) added to all 8 locales.

---------

Co-authored-by: Psychotoxical <dev@psysonic.app>
2026-04-22 03:13:09 +02:00
Frank Stellmacher 2a496c600b refactor(settings): thematic tab regroup + accordion sub-sections + in-page search (closes #257) (#259)
* refactor(settings): regroup tabs thematically + accordion sub-sections (progress on #257)

New tab structure: Servers (first) · Library · Audio · Lyrics · Appearance ·
Personalisation · Integrations · Input · Storage · System · Users.

Reusable SettingsSubSection (native <details>) wraps related settings per tab,
with an optional action slot for per-section reset buttons.

Moved:
- Lyrics sources + sidebar lyrics style → Lyrics
- Sidebar / artist layout / home customisers → Personalisation
- Last.fm / Discord / Bandsintown / Now-playing share → Integrations
  (with an opt-in privacy banner at the top)
- Tray / minimize / Linux smooth-scroll → System (new App-Verhalten group)
- Preload mini-player / custom titlebar / show artist images → Appearance visual options
- Language picker → System

Audio, Appearance and System tabs are now fully accordion-grouped.
Library tab reduced to Random Mix + Ratings accordions.
Servers tab is its own home (server cards + AudioMuse + logout) and the default
landing tab.

Indicators in the titlebar and offline banner route to the right tab.
EN + DE translations added for the new tab labels and privacy banner; other
locales fall back to English until the full i18n pass lands.

* refactor(settings): audio/input/storage/system accordions, in-page search, Navidrome focus

Settings refactor progress — every tab is now accordion-grouped:
- Audio: Output · Hi-Res · EQ · Playback · Next-track buffer
- Input: Keybindings · Global shortcuts (reset in accordion action slot)
- Storage: Offline · Downloads
- System: Language (default open) · Behavior · Backup · Logging · About · Contributors
- Contributors: own card grid with per-contributor expand, sorted by
  contribution count. Changelog accordion removed (release-notes link and
  "show changelog on update" toggle now live inside About).
- About: Navidrome focus; AI credit and Special Thanks removed; new
  Maintainers row (Psychotoxical + cucadmuh).

In-page search: magnifier icon next to the Settings title. Click opens a
240px search field. Filters the active tab's sub-sections by title match
(data-settings-search). Cross-tab search is tracked as a follow-up.

serverCompatible updated in all 8 locales from "Compatible with: Navidrome
Gonic Airsonic Subsonic" to a Navidrome-first wording that warns other
Subsonic-compatible servers may have reduced functionality.

Full i18n pass (tabLyrics/tabPersonalisation/tabIntegrations, aboutDesc,
privacy banner, etc. in fr/nl/zh/nb/ru/es) follows in a separate commit
after visual review.

* i18n(settings): complete localisation pass for refactored tabs

Added in all 8 locales (fr, nl, zh, nb, ru, es — en+de already in place):
- Tab labels: tabLibrary, tabServers, tabLyrics, tabPersonalisation, tabIntegrations
- inputKeybindingsTitle, searchPlaceholder, searchNoResults, aboutMaintainersLabel
- integrationsPrivacyTitle, integrationsPrivacyBody
- aboutContributorsCount_one/_other (ru also _few/_many for proper plural forms)
- aboutDesc rewritten to describe a Navidrome-focused player instead of a
  generic Subsonic-compatible one.

Removed dead keys in all 8 locales:
- aboutAiCredit, aboutSpecialThanksLabel — no longer rendered
- changelog — inline changelog accordion removed
- tabServer, tabGeneral — superseded by new tab structure

---------

Co-authored-by: Psychotoxical <dev@psysonic.app>
2026-04-22 02:25:56 +02:00
cucadmuh f6f76723d8 feat(servers/settings): magic string invites, Navidrome admin share, and add-user validation (#258)
* feat(servers): magic string invites and duplicate-aware labels

Add psysonic1- share payloads (URL, Subsonic credentials, optional server
name). Login and add-server forms decode on input and keep magic field
below standard fields. Navidrome User Management generates strings after
PUT password updates where required. Resolve duplicate display names as
username@host in chrome, settings, and login.

* feat(servers): tighten magic-string import and admin share flow

After a decoded magic string, username is read-only and the password field
shows a fixed-length mask so length is not inferred. Password reveal stays
disabled until saved-server quick connect (login) or reopening the add-
server form. Navidrome admin share UI persists the password via API before
copy and adds a plaintext-handling disclaimer. Locales updated.

* feat(settings): save new Navidrome user and copy magic string

Add a non-admin "Save and get magic string" flow: create the user, assign
libraries when needed, then encode credentials to the clipboard. Reuse
the plaintext-handling disclaimer without the password-update hint meant
for edits. Strings added for all locales.

* fix(settings): tighten Navidrome add-user validation and submit feedback

Require username, display name, and a non-empty password (trimmed) for new
users; gate Save and “save and get magic string” on explicit checks with
toast feedback. Show red borders only after a failed submit, and clear
them when the user fills the fields. When editing, keep an empty password
as “unchanged” and validate identity fields with a dedicated message.
Strings: userMgmtValidationMissingIdentity across locales.
2026-04-22 00:45:28 +02:00
cucadmuh b61c168430 fix(ui): overlay scrollbars, resizer hit-test, and Linux mini wheel (#255)
Add OverlayScrollArea with shared thumb metrics and drag handling; size the
thumb against the rail track height so panel insets cannot push it past the
visible rail. Route scroll uses a stable viewport id; Genres restores scroll
and infinite-scroll observation against that viewport (merged with upstream
virtualized genres list and lazy routes behind Suspense).

Suppress queue resizer activation when the pointer targets the main-route
overlay scrollbar; disable the resizer while dragging the thumb and use a
grabbing cursor on the body.

Apply WebKitGTK smooth wheel to the mini webview as well as main; the mini
window reapplies the persisted setting after auth store hydration.
2026-04-21 22:44:28 +02:00
Frank Stellmacher 2318f9e07a feat(albums): add 'Enqueue' option to album cover, context menu and multi-select toolbar (closes #253) (#256)
Per bcorporaal's request in #253: making a queue from multiple albums
required either replacing the queue (Play) or going into each album
detail page first. Three new entry points cover the common workflows:

1. Hover button on the album cover next to Play. Both buttons share
   the same accent style and size so the secondary action is just as
   readable as the primary — first attempt with a darker pill was hard
   to see and unbalanced the hover.

2. Context-menu entry "Enqueue Album" between "Open Album" and "Go to
   Artist". The i18n key already existed (was used by the album-song
   sub-context); just wiring up the action.

3. Multi-select toolbar in Albums.tsx: new "Enqueue (N)" button placed
   before "Add Offline" so power users selecting several albums no
   longer have to right-click. Plus a context-menu entry "Enqueue N
   Albums" for the same flow when a multi-album right-click happens
   anywhere else.

All multi-album fetches use Promise.all(getAlbum(...)) — Navidrome
handles parallel requests instantly (per memory note from PR #246).

i18n: 4 new keys per locale (3 with pluralisation: contextMenu.enqueueAlbums,
albums.enqueueSelected, albums.enqueueQueued).

Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 22:39:46 +02:00
Frank Stellmacher f7a721b7b1 feat(artist-detail): user-configurable visibility and order of page sections (closes #252) (#254)
Per bcorporaal's request in #252: album-oriented listeners want a
cleaner artist page focused on the album grid, with the freedom to
reorder/hide bio, top tracks, and similar artists.

Five sections are now reorder- and toggle-able from Settings →
Personalisation → "Artist page sections": Bio, Top tracks, Similar
artists, Albums, Also featured on. The fixed header block (artist
photo, name, action buttons) stays at the top.

Implementation reuses the existing sidebar customizer pattern:

- new `src/store/artistLayoutStore.ts` modelled on `sidebarStore.ts`
  with the same persist + onRehydrateStorage migration so future-added
  sections don't silently disappear from existing users' configs.
- `ArtistDetail.tsx` render block refactored from a flat sequence of
  conditional JSX into a `renderableSectionIds.map()` driven by the
  store. The "first rendered section gets marginTop: 0" rule now
  works regardless of the configured order — both hidden-by-toggle
  and empty-data sections are filtered before the layout decides
  what's first.
- `ArtistLayoutCustomizer` component in Settings, lifted from
  `LyricsSourcesCustomizer` (drag via `useDragSource` + `psy-drop`
  event, per-row toggle, reset button).
- 7 new i18n keys per locale across all 8 languages.

The store hook MUST stay above the loading / !artist early returns —
otherwise React's hook call order mismatches between renders and the
component fails silently. Lesson learned during the refactor.

Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 22:08:39 +02:00
Frank Stellmacher c40243dfea perf(genres): paginate the genre grid + memoise sort to fix 30s cold-start freeze (#251)
Reported: opening the Genres page after a cold start froze the UI for
up to 30 seconds with a 500-genre Subsonic library. The page itself
was structurally trivial (one getGenres() call, sort, render) but each
card mounted a Lucide watermark SVG at size=80 — 500 complex SVGs
reconciled in one batch was the actual blocker, especially when other
cold-start useEffects were still draining the main thread.

Two changes:

- Pagination via IntersectionObserver, PAGE_SIZE 60. First paint mounts
  ~88% fewer cards; the observer pulls the next batch in 1500px ahead
  so the user never sees the end. Same pattern Albums.tsx uses.

- useMemo for the sort. Without it, every unrelated re-render
  (theme change, sidebar toggle) re-sorted all 500 entries.

Scroll-restore extended to also persist visibleCount in sessionStorage
so coming back from a GenreDetail page lands the user on a grid that
still contains the row they scrolled to.

Confirmed locally on a 500-genre library: page now renders instantly
on cold start.

Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 21:38:16 +02:00
Frank Stellmacher 0dfc3fcfe2 perf(artists): memoise filter pipeline and list-view grouping (#250)
Artists.tsx already paginates the DOM via PAGE_SIZE + IntersectionObserver,
so the page never holds more than 50-100 cards at a time. The actual
overhead with a 5000-artist library was the filter pipeline running on
every render — including renders triggered by selection mode toggles,
view-mode switches, image-toggle, etc. — re-walking the full artists
array twice per render (letterFilter + search filter), then re-building
the list-view groups Record from scratch.

Wrap the pipeline in useMemo:

- `filtered` recomputes only when artists / letterFilter / filter change
- `visible` recomputes only when filtered / visibleCount change
  (also keeps array identity stable across unrelated re-renders)
- `groups` + `letters` recompute only when visible / viewMode change,
  and skip the loop entirely in grid view (where they're unused)

No new dependency. With 5000 artists, unrelated state changes
(selection toggle, click on a card, scroll past pagination boundary)
are noticeably smoother.

Confirmed locally on a ~5000-artist library.

Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 21:38:11 +02:00
Frank Stellmacher aa0776e811 perf(bundle): lazy-load rarely-visited pages + split vendor chunks (#249)
Cold-start bundle was a single 1.88 MB / 577 KB-gzipped chunk that
parsed every page in the app before first paint, including pages a
typical session never opens.

Two-part fix:

1. Lazy-load 10 rarely-visited pages via React.lazy() — each becomes
   its own chunk that only downloads when the route is hit:
     Settings, Statistics, Help, WhatsNew, DeviceSync, OfflineLibrary,
     LabelAlbums, AdvancedSearch, FolderBrowser, InternetRadio.
   Whole <Routes> tree is wrapped in <Suspense fallback={null}> — no
   visible loading state, the browser cache hits on subsequent visits.

2. Vite manualChunks splits dependencies that change rarely from app
   code: react/react-dom/react-router, the @tauri-apps/* family, and
   i18next. Tauri auto-updater pulls smaller deltas when only app
   code changed (vendor chunks stay byte-identical and cached).

Build output:
  Before: 1.88 MB / 577 KB gz monolithic
  After cold start: ~450 KB gz (index 370 + react 54 + tauri 6 + i18n 20)
                    = 22% smaller initial download
  Plus 11 lazy chunks. Notably WhatsNew's bundled CHANGELOG (215 KB /
  76 KB gz) is now off the cold-start path entirely.

Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 21:38:07 +02:00
Frank Stellmacher 88cd09c0be perf(lyrics): persist resolved lyrics to IndexedDB across app restarts (#248)
The existing module-level Map<songId, CachedLyrics> only survived
within a session. After every app restart the lyrics fetch chain
(server → lrclib → netease) ran from scratch for every track the user
played, even ones whose lyrics were already resolved 5 minutes ago
before the previous session ended.

Add an IndexedDB layer that's only consulted on RAM miss:

- L1 (RAM): unchanged, sync, hit on tab switch / track repeat
- L2 (IndexedDB, async): hit after restart, hydrates RAM
- Network: unchanged fallback chain

Key format `${serverId}:${songId}` so the same songId on two different
Subsonic servers cannot collide.

TTLs: 90 days for resolved lyrics (they rarely change once shipped),
7 days for `notFound` entries (gives the user / admin a chance to add
lyrics later without an indefinite negative cache).

YouLyPlus mode skips L2 — a persisted entry from the standard pipeline
wouldn't have word-level sync, which is the whole point of lyricsplus.

Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 21:38:02 +02:00
Frank Stellmacher a76616b342 perf(albums): bump infinite-scroll prefetch margin from 200px to 1500px (#247)
The IntersectionObserver sentinel triggered the next page only when it
came within 200px of the viewport — by the time the user had scrolled
that far, they were already at the very edge waiting for the request.

Bumping rootMargin to 1500px (~1.5 screens at typical heights) prefetches
ahead of the scroll instead of behind it, so the next batch of albums is
visibly already there when the user reaches them.

Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 21:37:57 +02:00
Frank Stellmacher 1ec27f9aff perf(device-sync): parallelise getAlbum calls when syncing an artist source (#246)
fetchTracksForSource() iterated over an artist's albums with sequential
`await getAlbum(...)` inside a for-loop. A 50-album artist sync stalled
for ~7 seconds of round-trips before any device write started.

Switch to Promise.all(albums.map(...)). Same pattern ArtistDetail.tsx
already uses for "Play All" without issues — Navidrome handles parallel
getAlbum requests fine.

Per-album errors fall back to an empty song list rather than aborting
the whole sync.

Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 21:37:52 +02:00
Frank Stellmacher 9da1d643f7 perf(search): use CachedImage for result thumbs to stop re-render download storms (#245)
The sidebar LiveSearch and mobile search overlay rendered cover thumbs via
raw <img src={buildCoverArtUrl(...)}> instead of going through the
IndexedDB-backed CachedImage path used everywhere else in the app.

buildCoverArtUrl() mints a fresh URL on every call (random salt + a new
MD5 token per Subsonic spec), so the browser cache was permanently
defeated for these thumbs — every re-render of the dropdown produced
new URLs and the browser dispatched HTTP requests for every "new"
image.

While typing in the search field this multiplied: each keystroke
triggered a re-render with fresh URLs for every visible cover, and
after the 300 ms debounce a fresh search() result triggered another
wave. Five visible album thumbs × five keystrokes ≈ 50 redundant cover
fetches in 1.5 s, manifesting as periodic typing delays.

Switch all three call sites to CachedImage with the stable
coverArtCacheKey() — same pattern CLAUDE.md explicitly recommends:

    buildCoverArtUrl() generates a new ephemeral URL every call —
    browser cache is useless. Use coverArtCacheKey(id, size) +
    CachedImage for <img> tags.

Confirmed locally: typing in the search field is noticeably smoother.

Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 21:37:47 +02:00
Frank Stellmacher 06140a490b feat(queue): add Now-Playing Info tab with artist bio, song credits and Bandsintown tour dates (#244)
* feat(queue): add Now-Playing Info tab with artist bio, song credits and Bandsintown tour dates

A third tab in the right-side queue panel surfaces context for the
currently playing track:

- Artist card: image + biography from Subsonic getArtistInfo (Last.fm
  "Read more on Last.fm" anchor stripped), with read-more toggle that
  only appears when the bio actually overflows the 4-line clamp.
- Song info: contributor credits from OpenSubsonic contributors[]
  rendered stacked (name prominent, role muted). Section is hidden
  entirely on servers without contributor support, and rows that just
  re-state the main artist under role "artist" are filtered out.
- On tour: optional Bandsintown integration (opt-in, off by default).
  HTTP fetch + JSON parsing happen entirely on the Rust side; the
  frontend wrapper deduplicates concurrent calls and caches results in
  RAM for the session. Limited to 5 events with a "Show N more" toggle.
  When the toggle is off, the section becomes an in-place opt-in card
  with a privacy info-tooltip explaining what data is sent — same
  tooltip is also exposed on the matching toggle in Settings.

Caching: artist info and song detail are memoised by stable IDs across
component remounts, so jumping between tracks of the same album/artist
does not refire the network calls.

Implementation notes:
- Bandsintown app_id "js_app_id" — arbitrary strings (e.g. "psysonic")
  now return HTTP 403 from rest.bandsintown.com; js_app_id is the ID
  Bandsintown's own embeddable widget uses and is broadly accepted.
- Tour items use negative left/right margins so the date badge stays
  visually aligned with the section title while the hover background
  extends slightly past the section edges.
- New i18n namespace nowPlayingInfo across all 8 locales (en, de, fr,
  nl, zh, nb, ru, es) including pluralised "Show N more" forms.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(now-playing-info): switch nested flex-columns to block layout to stop content-dependent drift

Repeated reports of the Tour and Song-info sections rendering at
inconsistent x-positions (sometimes left of the section title,
sometimes centred, sometimes correctly aligned) — varying by artist,
sidebar width, and even whether "Show more" was clicked.

Root cause: nested flex-direction: column containers
(.np-info → .np-info-section → .np-info-tour / .np-info-credits) where
the cross-axis (horizontal) sizing on WebKitGTK occasionally inherits
the intrinsic min-content of the longest child. With one
"Naherholungsgebiet/Freizeitgelände Lago Alfredo"-style venue name
that overflows the sidebar, the parent ul gets pushed past 100% width
and the entire layout drifts. min-width: 0 on every level didn't help
because cross-axis stretch in flex-column ignores it for content-driven
overflow.

Fix: collapse all the section/list containers to display: block. Block
layout is content-agnostic — children always render at 100% parent
width, left-aligned, deterministically. Spacing between siblings now
uses the lobotomy selector (`> * + * { margin-top }`).

Only the tour item itself stays flex (badge ↔ meta horizontal layout),
and that one still has overflow: hidden + flex: 1 1 0 + min-width: 0
on the meta column to truncate venue/place text with ellipsis.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 19:13:18 +02:00