Slint cannot split the .slint compilation across crates, but a Slint LIBRARY
crate exposes its generated Rust types to dependent crates (verified with a
minimal proof). So:
- NEW crate `qbz-ui` (lib): owns the whole .slint tree + build.rs + bundled
translations; `slint::include_modules!()` + the generated types (AppWindow,
MiniPlayerWindow, all globals/structs) are re-exported. Translation domain is
now `qbz-ui` (the .po files renamed accordingly).
- `qbz-slint` (bin) -> renamed to `qbz` (package + `[[bin]] name = "qbz"`),
reclaiming the binary name from the retired Tauri app. It depends on `qbz-ui`
and does `pub use qbz_ui::*;` so existing `crate::AppWindow` / `crate::*State`
references resolve unchanged.
This splits the single ~20 GB rustc into two crates (UI/codegen vs the 75k LOC
of logic) — the foundation for lower build peak, incremental caching, and the
upcoming per-feature crate extraction.
Also fixed for the move: qbz-i18n bundled-.po include paths, the font/WGSL
`include_bytes!`/`include_str!` paths (-> ../../qbz-ui/ui/), workspace members,
dev scripts (`-p qbz`), and the CI workflows. `cargo check -p qbz` is green.
cc
The GitHub-hosted private runner is 2 vCPU/8 GB and cannot build qbz-slint
(died at 1h42m). The dev box CAN, so a self-hosted runner (qbz-box) gives a
reliable CI artifact. Manual trigger; uncapped threads=2 (box-proven);
persistent work dir = incremental after first build.
cc
mupdf-sys (via qbz-booklet) links the system third-party libs; the build
got 21 min in and failed on a missing harfbuzz. Add the full mupdf system-lib
set so it links cleanly.
cc
The runner already ships an active /swapfile, so fallocate on it fails with
"Text file busy". Add the extra swap on a fresh path (/swap_extra) and guard
the whole swap block so a hiccup never fails the job — memory was never the
blocker (the prior run died on disk, not OOM).
cc
ubuntu-24.04 has no separate large /mnt disk — it's all on the 72 GB root
(~16 GB free), so the 12 GB swapfile + cargo target overflowed it (protobuf
failed with ENOSPC, not OOM — the build was memory-fine). Free ~25-30 GB of
unused preinstalled toolchains, drop swap to 8 GB, and keep the target on the
default path.
cc
Build-only workflow on ubuntu-24.04 with a 12 GB swapfile and the
lowest-memory rustc settings (serial frontend, codegen-units 256,
opt-level 2, one rustc at a time) so a stock 16 GB runner can compile the
Slint app. Triggers on push to slint-mvp and via workflow_dispatch.
cc
Adds the full keybindings system (26 actions, 5 categories, customizable
and persisted) plus the Ctrl+L "Open Qobuz Link" cross-platform resolver
to the Slint frontend, matching the Tauri implementation 1:1.
- New shared crate qbz-music-link: de-Tauri'd cross-platform link
resolver (Qobuz/Spotify/Apple/Tidal/Deezer/song.link via Odesli) behind
a QobuzSearchBridge trait. src-tauri left untouched.
- src/keybindings.rs: action model, winit shortcut grammar, conflict
detection, ui_prefs persistence, main-window + miniplayer dispatch.
- src/link_resolver.rs: controller + bridge over QbzCore.
- New globals + 3 modals (read-only cheatsheet, customize editor with key
capture, link resolver), hamburger menu entries, and a UiFocusState
text-input guard wired across the app's TextInputs.
cc
The fixed threads=16/opt=3 release build SIGTERM'd at codegen whenever the
desktop held ~8-11 GB (30 GB box, no hibernation, hard-freezes on OOM). Now
scales rustc frontend threads + codegen-units + opt-level to MemAvailable
(FAST >=26G / SAFE 14-26G / MIN <14G), runs under cargo-capped with the cap
matched to free RAM, and adds FAST/THREADS/CODEGEN_UNITS/OPT/CAPPED/NORUN env
overrides. Verified: full release build completed at SAFE tier (threads=2
cgu=256 opt=3, capped ~19.7G) in 55m without freezing.
cc
The public getter was redundant: format-scoped completion gating reads the
download format_id internally via track_status_scoped (s.album_format_id), so the
accessor was never wired and only produced a dead_code warning. No behavior
change. cargo check -p qbz-slint exit-0 in 2m14s; the port code is now
warning-free (the remaining following_state/cached warnings are pre-existing,
unrelated to purchases).
cc
Closes the final-audit gaps for full 1:1 + standards compliance:
- Persist the 4 remaining purchase UI prefs (hide-unavailable, hide-downloaded,
quality-filter, region-notice-seen) per-machine in ui_prefs.json + startup seed,
so filter selections survive restart and the region notice does not reappear
(matches Tauri's persisted prefs).
- Translate the 4 controller toast/error strings into es/de/fr/pt (were
English-only; honors the 5-locale rule). Mexican/neutral Spanish.
- Explicit z:3000/3001 on the format-picker backdrop/popup + filter panel
(ADR-009 >= 3000; previously relied on declaration order).
cargo check -p qbz-slint exit-0.
cc
Slice 12/13. Two persisted UI prefs (show_purchases, nav_tb_purchases, both
default false) in UiPrefs; Show-Purchases toggle drives the show-purchases flag
the S11 nav gates on; 'Purchases in title bar' toggle (nav_tb_purchases, disabled
without a custom titlebar). Implements the source-of-truth section 5.8 placement
truth table: sidebar entry shown iff S && !(T && TB), header entry iff S && T && TB
(closing the S11 per-feature-gating gap) so flipping the toggle moves the Purchases
entry between sidebar and header 1:1 with Tauri. on_appearance_bool persistence +
startup reseed. cargo check -p qbz-slint reached exit-0. Per-machine prefs
(consistent with other Slint appearance prefs).
cc
Slice 11/13. NavEntry::PurchaseDetail(String) + full routing (scope_for/
apply_entry, history recording + update_nav_flags on purchase-album clicks and
the Purchases route), session-restore-safe purchases (id-gated purchase-album),
conditional-mount guard + A.3 reactive reload (back/forward between two purchase
albums re-fetches). Sidebar ShoppingBag row + HeaderBar tabs (full+compact),
gated on show-purchases && !offline, active for both purchases + purchase-album.
nav.rs round-trip history test. Type-check clean (exit-0 not reachable on this
box; SIGTERM at metadata = memory limit).
cc
Restores the purchase-album detail mapper that the Slice-9 detail load path
(qbz-slint purchases.rs:2270) already references but which was left uncommitted
in the working tree (staged-list miss when committing S9). build_purchase_album
maps a fetched Album + purchase listing + downloaded ids + format map into the
detail payload (per-track + album downloaded flags, synthesized format options,
page counts). 5 unit tests; 30 offline-cache purchases tests green.
cc
Slice 10/13. A.2: per-track row play in both purchases views routes to
playback::play_track_now (single track, NO replacePlaybackQueue/setPlaybackContext)
instead of media_action play_track_in_context. A.1: Play-album confirmed on the
regular album fetch+queue path. New mirror_now_playing_to_purchases feeds both
purchases globals' active-track-id + playback-active from the now-playing refresh
(meta apply + poll tick). Folded Slice-9 review fixes: detail + list track titles
trimmed (formatTrackTitle parity), dead PurchaseDetailState.artwork-url removed.
Fixed E0308 (track_id u64). Front-end type-check clean (zero diagnostics).
cc
Slice 7/13. qbz-slint/src/purchases.rs: ports purchaseDownloadStore as a Send
OnceLock<Mutex<>> controller singleton (AlbumDownloadState keyed by albumId,
flattened allTrackStatuses, per-album abortFlags). Actions: start/execute album
download (sequential loop, abort-check, destination rewrite after first success),
start_track_download (merges into existing state, A.7), cancel, Add-to-Library,
folder picker, skip-if-remote.
1:1 fidelity fixes surfaced during review (verified vs Svelte source):
- Album loop marks complete THEN best-effort registry write (swallow failure)
like executeAlbumDownload .catch(()=>{}); only the single-track path propagates
a registry error to failed (B.1). Adds download_purchase_track_file_only to
qbz-offline-cache (download-only) beside the bundled download_purchase_track.
- apply_cancellation cancels only tracks with NO status entry (verbatim Svelte).
cargo check + 20 qbz-slint / 25 offline-cache purchases tests green.
cc
Slice 6/13. qbz-slint/src/purchases.rs: get_downloaded_purchase_formats
(HashMap track_id -> Vec<format_id>, the format_map) and quality_dir(label)
(/-> - global replace + trim, section 7.5), via library_db::with_db. Unit test
for quality_dir. Dead-code until slices 7-9 wire the call sites.
cc
Slice 3/13. New qbz-offline-cache/src/purchases_service.rs (pagination glue,
get_purchase_total two-call per-type-totals primitive, byte-for-byte
filter_purchase_response). New qbz-slint/src/purchases.rs controller load shells
(get_downloaded_track_ids -> HashSet, load_purchases_metadata with TWO separate
ids-totals calls, load_purchases_by_tab with total backfill + error mapping).
Replicates Tauri's 'Failed to fetch ... purchases' wrapper so network errors map
to purchases.loadFailed 1:1 (TDD-caught). 10 unit tests green. Lazy/cache/force
gating deferred to UI slices 8/9 by design.
cc
Slice 2/13. New impl QobuzClient block in qbz-qobuz/src/purchases.rs ported
1:1 from src-tauri api/client.rs: typed/ids purchase listing (page_limit 500,
two-call per-type totals quirk preserved), get_track_file_url_by_format
(RPC-signed, intent=stream, empty-url -> TrackUnavailable), and download_audio
(http1_only, NO total timeout). Adds 2 endpoint consts; bumps
http()/secret()/authenticated_headers() to pub(crate) for the sibling module.
Offline-gated via self.http()?. Drops .use_native_tls() (crates/ workspace is
rustls-only; matches cmaf.rs build_cdn_client) to keep the standalone build green.
cc
Slice 1/13 of the Purchases Tauri->Slint port. Adds PurchaseResponse,
PurchaseIdsResponse, PurchaseAlbum, PurchaseTrack (no version field) and
PurchaseFormatOption to qbz-models, plus the 5 lenient deserializers ported
verbatim from src-tauri api/models.rs. Derives Default on SearchResultsPage
for the empty-page fallback. 4 round-trip tests, cargo test green.
cc
select_bundled_translation must run after AppWindow::new() (the component's
global translation context does not exist before then), so startup now paints
in the persisted language instead of falling back to English. Adds
reseed_i18n_labels() which seeds the 14 AppearanceState option arrays from
qbz_i18n::t() — these were @tr() property defaults, which are not reactive to
translation changes — called at startup and on live language switch so the
settings <select> options translate and update without restart.
cc
Fills all previously-untranslated msgstr (UI strings absent from the Tauri
JSON seed) via a translation pass, then hand-fills the remainder. Neutral
Latin-American Spanish; standard professional register for de/fr/pt; brand/
technical tokens (FLAC, kHz, Plex, etc.) kept literal; {} placeholders and
gettext plural forms preserved. All four catalogs pass msgfmt -c.
cc
Adds qbz_i18n::mark() (identity, extraction-only) and applies it at the
definition sites of literals later translated through t(variable):
release-type classifier, discover section labels, artist release-section
titles + month names, label jump tabs, and audio settings dropdown tables.
Fixes the discover_prefs double-t() (label_for now returns the marked
literal, translated once at the consumer).
cc
Wrap user-facing Rust-built strings in qbz_i18n helpers across the
Artist/Album/Label surfaces: artist release-section titles + jump-tab
labels (routing kept on the English keys, only displayed labels
translated), MusicBrainz month-name/date formatting, album track-count
subtitle + "From the same artist"/"Listening suggestions" carousels,
label carousels + jump tabs + group-by "Unknown", and the shared
album_map release-type labels (Album/EP/Single/Live/Compilation).
cc
Wrap user-facing Rust-built strings in qbz_i18n helpers across the
Discover/Home/Search surfaces: discover section titles, configurator
labels (discover_prefs::label_for), For You carousel titles, mix
subtitles + duration, suggestions track-count + Song Radio, search
section headers + count subtitles, and the Lossless quality tooltip.
Reuses shared msgids ({} track/{} tracks, {} album/{} albums, section
names) so the catalog stays deduped.
cc
Wrap user-facing eyebrow labels (MIXTAPE/COLLECTION/ARTIST), album/item
counts, add/edit/builder/cover toasts, dialog titles + image filters,
play/queue toasts, and the default discography name in the My QBZ
controllers with qbz_i18n t/t_args/tf helpers. Keep the My QBZ brand
itself untranslated via positional interpolation.
cc
Wrap user-facing toasts, dialog titles/descriptions, durations,
track/folder counts, scan/cleanup status, and grouping labels in the
Local Library controllers (local_library, local_library_settings,
local_playlist, tag_editor) with qbz_i18n t/t_args/tf helpers.
cc
The folder-options dropdown is re-seeded from Rust at runtime in both
create-playlist builder sites, so the state.slint default is dead. Wrap
the seed-site 'No folder' literal in qbz_i18n::t so it resolves through
the shared gettext catalog.
cc
Wrap remaining user-facing literals in @tr across discover (RADIO,
TOP TRACKS), DiscographyBuilder (release-type/order-by labels), the
OfflineManager hero, ExpandableSearch placeholder default, and the
appearance option arrays in state.slint (displayed as-is, no Rust
re-seed). Replace string concatenation with positional @tr plus
ngettext plurals for the DiscographyBuilder footer and OfflineManager
track counts so word order is locale-stable. Language endonyms and the
macOS/Adwaita/quality-tier brand names stay literal.
cc
Wire the Appearance language selector (appearance-select "language"): map the
dropdown index to a language key via the verified order (0 Auto, 1 English,
2 Espanol, 3 Francais, 4 Deutsch, 5 Portugues), persist the raw user choice,
resolve "auto", then set_language() + select_bundled_translation() to switch
all @tr bindings live with no restart. Seed the selector index from the
persisted key at startup.
cc
Add a persisted `language` UiPrefs field (default "auto") with index<->key
helpers matching the existing appearance-prefs pattern. At startup, before
the AppWindow is created, resolve "auto" via qbz_i18n::resolve_auto(), call
qbz_i18n::set_language() and slint::select_bundled_translation() so the first
paint uses the right translations. Map dates::current_locale() from the active
runtime language so month tokens localize with the rest of the UI.
cc
POSIX locale precedence is LC_ALL > LC_MESSAGES > LANG. resolve_auto
previously only checked LC_MESSAGES and LANG; add LC_ALL as the
highest-priority env var, skipping it when empty. Extend the
resolve_auto test (env-mutation serialized via the existing Mutex).
cc
Implement the frontend-agnostic translation layer test-first:
- plural.rs: PluralRule parsing nplurals + the two expressions our
locales use ((n != 1), (n > 1)); index(n) selects the form, with a
safe English default for anything unrecognized.
- po.rs: Catalog::parse handles the header entry, msgid/msgid_plural/
msgstr/msgstr[N], multi-line continuations, and \n \t \" \\
escapes; empty msgstr means no translation (get returns None so
callers fall back to English). Reads Plural-Forms from the header.
- lib.rs: 5 catalogs embedded via include_str! and lazily parsed
(OnceLock); AtomicU8 language state; set_language/current_language/
resolve_auto (LC_MESSAGES then LANG -> 2-letter prefix); t/tn/t_args/
tf with left-to-right {} substitution and English fallback.
17 unit tests, all green via cargo test -p qbz-i18n. Content tests use
inline fixtures since the bundled .po files are header-only for now.
cc
Add new no-dependency qbz-i18n crate to the workspace members.
Frontend-agnostic translation catalog per ADR-006 (reusable by
Slint/TUI/headless; no slint or tauri deps).
cc
Three restart-persistence features built and tested together (they share
ui_prefs + the enter_shell restore sequence, so kept as one change):
- Startup page: persist + restore the last SAFE top-level view via the
canonical apply_entry dispatcher (loads data, not a blank view). Wired to
Settings > Appearance > "Where you left off"; ui_prefs startup_page/last_view.
- Window geometry: persist logical size + outer position and restore them at
launch, clamped to the current monitor so a smaller/disconnected display can
never open an oversized or off-screen window (winit Resized/Moved hook).
- Playback session: new session_persist module wiring qbz-app's SessionStore.
Saves queue + current track + volume + shuffle/repeat + position on
track-change / pause / ~5s tick / exit (incl. hide-to-tray and tray Quit);
restores the queue PAUSED at launch. Position resume on first play threads
the player's existing start_position_secs (no protected-audio change beyond
passing that param). Gated on the persist_session / resume_playback_position
playback prefs; opened per-user on login + boot-restore + offline entry.
Known issue: the seek bar/timer do not visibly show the resume position until
play is pressed (the audio resume itself works); marked in code for follow-up.
cc
Drop the custom-icon picker (owner decision): My QBZ now always uses
QBZ's monochrome (tray) glyph — in the Settings preview, the sidebar row,
and the header section nav (incl. the icons-only compact nav when the
sidebar is fully closed). Only the name stays editable; the reset control
is a square glyph button that clears the name to the default. The
icon-change backend (pick-icon / custom-icon) stays but is unsurfaced.
cc
Restore across restarts: the three-state sidebar (open/mini/closed) and
the section-nav placement (sidebar vs header) seed from ui_prefs before
the shell renders and persist via new ShellState callbacks; the player
volume persists on slider release only (a new QbzSlider `released`
callback + NowPlayingState.persist-volume — no ui_prefs write per drag
tick) and is reapplied to the player on session entry.
cc
Track info is a Qobuz-only metadata page, but it fired for local / Plex /
ephemeral playing tracks too. Add NowPlayingState.source (populated from
the QueueTrack: None coerces to "qobuz" for catalog tracks, "local" for
local files) and gate the trigger on source == "qobuz" — in the SongCard
TouchAreas (pointer cursor + click both no-op otherwise) and the PlayerBar
menu handlers.
cc
The refresh button + device select sat in a center-aligned row, so they
floated with a right-hand gap once the window widened out of responsive
mode instead of landing flush on the same right edge as the single-control
rows. Switch the row to alignment: end.
cc
Drop the unported "Match system window chrome" row from Settings >
Appearance entirely (UI only; the AppearanceState flag + handler stay
inert for now).
cc
Frontend-agnostic core in qbz-integrations::discord (DiscordRpc +
NowListening) — the Tauri backend minus the tauri::command/State
wrappers: lazy IPC connect, Listening activity (details/state/assets +
"Get QBZ" button + timestamps), and the opt-in Flatpak socket bridge.
qbz-slint discord_rpc controller: process-global client, init AFTER the
session is active (PR #477 persistence fix — both online and offline
entry, never at early boot), push on the track-change and play/pause
edges, clear on logout. Opt-in toggle in Settings > Integrations
(DiscordState, persisted in ui_prefs.discord_rpc_enabled) + a Flatpak
socket-access note. The opt-in is per-machine (ui_prefs) rather than
Tauri's per-user.
cc
System Notifications now persists (ui_prefs.system_notifications) and
gates the desktop notification via a poll-thread atomic, seeded at
startup and flipped live by the toggle (scrobbling stays independent).
Mini player default view persists to mini_default_view and seeds its
select index at startup; the miniplayer already reads the key on open.
Both lose the red "unsupported" flag. Also removed 13 unsupported
Appearance rows that will not be ported: Font, the title-bar navigation
group (Search/Navigation in title bar + per-section toggles) and
navigation buttons position, window controls style/size/color preset,
and a duplicate immersive default view.
cc
The binary compiles both rustls providers (aws-lc-rs via rust_cast, ring
via reqwest), so rustls cannot auto-select one and panics on the first
TLS connection. It was only installed lazily on the cast paths, so
joining a Qobuz Connect session without ever casting panicked the
WebSocket worker thread. Install it once in main() before any TLS;
idempotent, so the cast paths' calls stay no-ops.
cc
Local playlists were excluded from both sidebar folders and the cover
collage. Folders: new local_playlists.folder_id column (+ migration) on
the shared playlist_folders, move_to_folder/clear_folder helpers, the
move-to-folder context menu enabled for local rows, optimistic cache
move, and grouping under folders in the rebuild + collapsed-folder
flyout. Collage: resolve up to four covers from a local playlist's own
tracks with no network (local file artwork, Plex thumbs, cached-Qobuz
covers), populated in the async load and routed through the local/Plex
art loader (file/Plex paths would miss the HTTP loader).
cc
The sidebar Queue reads the same core pointer as the now-playing bar,
but several paths refreshed the bar without the sidebar, so the panel
showed a stale NOW PLAYING track and the NPB shuffle button looked dead
(the reordered up-next was never re-pushed). Pair refresh_sidebar with
the now-playing refresh in toggle_shuffle, the local-tracks inline
shuffle, the gapless seamless-change (regardless of moved), and the Plex
quality patch. The shuffle engine itself was already correct.
cc
Slint hit-tests sibling children in reverse declaration order, so the
keyboard FocusScope declared after the pointer TouchArea was visited
first and, with the default focus-on-click, consumed the first Pressed
just to grab focus — aborting traversal before the TouchArea, so every
button needed two clicks. Set focus-on-click: false on the FocusScope in
QbzPrimaryButton, SecondaryButton, QbzCheckbox, QbzToggle, ToggleButton
and QbzSelect; the press now reaches the TouchArea on the first click
(Tab/Enter/Space activation unaffected).
cc
The immersive Queue up-next reuses the coverflow model, whose art was
windowed to ±4 around the current track, so only now-playing + the next
four rows decoded a cover. Build art jobs for the whole list and widen
the decode window when the immersive Queue panel is open (still lazy per
row); the panel fires QueueState.panel-opened on mount so the covers
repaint when it opens.
cc
Album Info expanded-track credits: the roles line now stretches and word-wraps
instead of running off the right edge of the modal.
Track Info credits: render two independent fixed-width columns (even-index ->
left, odd-index -> right) so long name lists wrap and the right column stays
aligned on every row, with no height-coupled row gaps; names stay clickable,
one per line. Mirror the wrap in the immersive Track Info panel.
cc
Widen the album label/awards/external-links sidebar 160->200px. Replace the
generic external-link chips with inline brand SVG icons (Last.fm / Discogs /
MusicBrainz) rendered in their native colors (MusicBrainz split half purple /
half orange), no visible label + a hover tooltip. Uppercase the "EXTERNAL
LINKS" heading to match LABEL / AWARDS.
cc
- Booklet: new frontend-agnostic qbz-booklet crate (MuPDF render to PNG;
the Tauri Cargo stanza + device_rgb/alpha=false render workaround ported
verbatim). In-app reader modal (paginate/zoom/rotate/download) driven by
BookletState/BookletActions; header booklet button now opens it.
- "From the same artist" carousel (/artist/getReleasesGrid) and "Listening
suggestions" carousel (/album/suggest), reusing the Discover Carousel,
appended below the track list with their own artwork pipeline targets.
- External Links sidebar block (Last.fm / Discogs / MusicBrainz) built from
artist + title, opening in the system browser.
cc
Bucketing now trusts the server release_type: drop the foreign-artist filter
that hid band/guest releases (e.g. Vicky Psarakis' Albums were credited to
Sicksense), render every non-empty bucket, hide 'Purchase Only' (download),
and move 'Other' to the end, collapsed by default. Drop the misleading
per-section item counts.
New 'Latest release' highlight (last_release) and 'Appears On' track section
(tracks_appears_on, previously mislabeled as Compilations).
Per-section persisted 5-mode sort (artist_prefs) + per-section load-more
capped at 4 pages, reusing get_releases_grid. New dedicated discography page
(ArtistReleasesView + artist_releases controller) with sort + infinite scroll,
reached via each section's 'See discography' link.
Magazine/Stories sidebar tab (artist/story) with 1:1 thumbnails and a
Network|Magazine tab switcher; hide the empty similar-artists block to remove
the 'no similar' over populated-discovery incoherence. Header Share action.
Per-release award ribbons.
Fix custom artist AND album images: decode + apply immediately on upload and
resolve the local override on navigation, so a custom image now shows for
artists/albums with no Qobuz artwork.
Data layer: type last_release as PageArtistRelease, add /artist/story endpoint
+ core/client methods + ArtistStory* models, export ArtistReleases* globals.
cc
Fourth NPB layout: a cover + spectrum dock pinned flush to the bottom-left
of the open sidebar (forming an L), with the bottom bar reusing the New
layout (window-centred transport, full-width inset seekbar, right cluster)
minus the cover. Gated on the open sidebar (npb-mode 3 && sidebar-state 0);
cycling the sidebar out of the open state reverts live to New while keeping
the persisted choice.
- SidebarNowPlayingDock: AppShell root overlay, cover flush to the window
bottom-left, compact spectrum band (Bars 28-mirrored / Waveform / Energy)
above it, single top-right eye toggle to show/hide it. FFT reuses
VisualizerState; when hidden the sidebar list reclaims the space.
- Extract AudioStamp into a shared exported component (consumed by the Small
and Large bars); in Large it sits at a constant gap left of the right
cluster, out of the left song card.
- Persist the visualizer on/off and the spectrum mode in ui_prefs; restore
at startup and prime the FFT tap after visualizer::install().
- Reduce the horizontal volume slider width 10% across all NPB modes.
cc
Add local albums/artists/tracks sections to the main header cortinilla
(below the Qobuz results) and local albums to the immersive cortinilla,
derived by grouping the on-device track rows. Merge the Plex cache into
the local search so Plex-only content surfaces (db.search is
local_tracks-only). Route each cover by source via spawn_search_loads
(Qobuz http / Plex /library PlexThumb / local file). Widen the local
section caps when offline or signed out. Rename the section lore from
"on this device" to "on Local Library".
Also clear the search input after a cortinilla element is activated
(row click / View more / Enter) so lingering text can no longer
re-invoke the dropdown when focus returns to the field; replicated in
the immersive search field via a dedicated raw-text property.
cc
Split layout is a true 50/50 (explicit half-widths; the horizontal-stretch weights skewed it because the left's intrinsic content out-measured the right). Removed the glass backing plates from the split panel and the song-data metadata block — both sit directly on the atmosphere now.
Cover art no longer upscales blurry on large windows: the now-playing large cover decodes at 1000px (was 300, the hover-preview size) so the full Qobuz source resolution comes through, and the split / Static / Album-Reactive panels cap the art at its native size so it grows sharply instead of stretching.
New Settings > Appearance 'Immersive default view': 'Remember last' restores the last view, or pin Album Reactive / Static / Coverflow / Spectrum / Lyrics / Queue. Persisted via ui_prefs + ImmersiveActions opened/view-changed hooks.
cc
Intelligent Search (phases 3-4): a frontend-agnostic SearchService in qbz-app (Capa A result cache + Capa B interaction ranking) behind a live header dropdown with skeleton loading, stale-while-revalidate, 220ms debounce, keyboard nav + scroll-into-view, per-section caps, a URL-match artwork guard, and an opt-out Settings toggle. Typing previews in the dropdown; Enter goes to Search > All; local-library results show in the dropdown only.
Immersive search: an in-immersive glass cortinilla (Albums/Artists/Playlists) that switches playback without navigating — a Settings action select (disabled/replace-queue/play-next/add-to-queue), the HUD stays up while searching and never hides while the cursor rests on the chrome, twinned top buttons, and a clear button with always-centered field text.
cc
Theme.danger-hover is a 15-25% translucent tint sized for danger-bg
surfaces, so using it as the destructive QbzPrimaryButton fill made the
button ~80% transparent (standard themes) or pale-pink (a11y themes) on
hover. Use Theme.danger.darker(0.15) instead — an opaque, richer red in
every palette, matching the existing danger-button hover idiom in
BlacklistManagerView.
cc
The BlacklistManagerView disabled-filter banner hardcoded the Dark-theme
warning hex (#fbbf24 / #fbbf241a / #fbbf244d) for fill, border, icon tint
and label. These resolve to no other cluster (item #6's named-file list
excludes this view; item #7 owns the WarningBanner.slint primitive, a
different file), so without this swap accessible themes (wcag-dark,
high-contrast) would render the wrong amber here, defeating P4's
'accessible themes take effect everywhere' goal. The hex equal the Dark
warning family exactly, so the swap is identity-preserving on the default
theme.
cc
Switch the open field's focused border from Theme.accent to the dedicated
Theme.focus-ring token, reusing the existing input.has-focus signal. Stays
accent-equal in most palettes but reads distinctly in the a11y themes, so
keyboard focus is consistent with every other primitive's ring.
cc
Give SecondaryButton, ToggleButton, CircleAction, QbzCheckbox, QbzToggle
and QbzSelect a keyboard FocusScope (Enter/Space trigger the same action
as a click) plus a non-layout focus-ring overlay: a sibling Rectangle
with a transparent-default 2px border that turns Theme.focus-ring only
when the scope has keyboard focus, with its radius matched to the control
(circle for CircleAction) so focus never reflows the layout.
The existing TouchArea stays the pointer source on every control; the
FocusScope only adds key activation and focusability. Universal: the ring
is a per-theme token and only appears on keyboard focus, so it stays
visually neutral at rest across all 33 themes (WCAG 2.4.7 focus-visible).
cc
Swap the filled-heart tint from the generic Theme.accent to Theme.favorite
so the favorited fill keeps a stable >=3:1 contrast against the row in
every theme and does not flatten against accent in High-Contrast. The
heart-filled/heart SVG swap remains the primary WCAG 1.4.1 shape cue.
cc
The active playlist/folder row and a hover row both paint surface-hover,
so the only active signal was a subtle text-color shift. Add a 3px accent
left-edge bar (non-layout overlay, no reflow) as a redundant WCAG 1.4.1
shape cue. Move the hardcoded green drop-target fill/border (#22c55e26 /
#22c55e66) to Theme.success-bg / success-border so the affordance is
themed across all 33 palettes.
cc
Replace the fixed mid-grey #b0b0b0 / #999999 on the CD/MP3/lossless
marks, the tier label, the detail line, and the SongCard LED labels
with Theme.text-muted (and text-secondary for the primary tier label).
A fixed grey vanished on light/HC backgrounds; tying it to the theme
keeps the badge legible everywhere. The Hi-Res brand mark is a
non-tinted Image and is left untouched (stays branded).
cc
Replace hardcoded danger reds (#ef4444 / #dc2626 / alpha variants) with
the Theme danger tokens so destructive affordances follow every theme,
including the high-contrast and colorblind palettes (WCAG 1.4.1, P4 #10).
- ContextMenuItem: danger row icon tint + label -> Theme.danger.
- MultiSelectBar: danger button hover bg -> Theme.danger-hover,
border -> Theme.danger-border, icon tint -> Theme.danger.
- BlacklistManagerView: per-row remove + Clear-All control + confirm
modal danger button -> Theme.danger / danger-bg. The solid-filled
confirm button uses Theme.danger.darker(0.12) for hover (mirroring
how accent-pressed is derived) instead of Theme.danger-hover, which
is a translucent ghost-surface tint and would wash out a filled
button (and is lighter in some a11y palettes).
These danger surfaces already pair an icon + verb label with color, so
color is reinforcement (not the sole signal); no HC-gated borders added
here to avoid touching shared button geometry.
cc
Replace the hardcoded variant hex with the themed families:
error -> danger/danger-bg/danger-border, success -> success(+bg/border),
warning(default) -> warning(+bg/border). The "info" variant has no
dedicated token, so it maps to Theme.accent with the same 10% bg / 30%
border alphas the literals used -- a small hue shift in some themes
(behavior change, not a pure 1:1), but accent is the themed
informational color. The per-variant glyph already provides the
non-color cue (WCAG 1.4.1).
cc
Replace the hardcoded per-kind icon hex (#4CAF50/#ff6b6b/#FFA726) with
Theme.success/danger/warning so the toast tint is themeable; buffering
and info keep Theme.accent. The per-kind icon already carries the
non-color cue (WCAG 1.4.1) -- this only guarantees contrast in the a11y
palettes while staying visually neutral in the 31 standard themes.
cc
Active segment gains a 2px accent underline (non-color shape cue, WCAG
1.4.1) drawn as a non-layout overlay so it never reflows the row, plus an
HC-only strong border gated on Theme.is-high-contrast. Count-badge fills
move from fixed white washes to Theme.alpha-15/alpha-8 so they follow
theme polarity.
cc
Add Theme.is-high-contrast-gated 1px border-strong border to the PlayButton
filled white disc so it stays delineated against a light HC background. The
disc colour stays 1:1 with Tauri (#ffffff); only an HC outline is added, so
the 31 normal themes and the non-circle glyph variant are untouched.
cc
Add Theme.is-high-contrast-gated 1px border-strong border to the seek-bar
hover thumb so it is delineated against the accent progress line under a
flattened HC palette. The 3px rail itself gets no outline (a 1px top+bottom
border would swallow the thin line); the thumb border carries the
redundant-encoding for the seek bar. Zero visual change in non-HC themes.
cc
Add Theme.is-high-contrast-gated 1px border-strong outlines to the track
and thumb of QbzSlider and QbzVerticalSlider so the rail stays visible and
the thumb is delineated against the filled track under a flattened HC
palette. Borders draw inside Slint's box so thumb travel math is unchanged.
Zero visual change in the 31 non-HC themes.
cc
P4 foundation for the redundant-encoding accessibility pass. Adds a single
flag that High-Contrast-only affordances (1px control borders, slider-thumb
borders) read so they never leak into the polished normal themes, mirroring
the existing is-system wiring.
- qbz-theme: `pub fn is_high_contrast(id)` at the crate root next to
`is_light` (true for HighContrast + HighContrastLight), with a unit test
`high_contrast_flag_only_for_hc_themes` asserting exactly the two HC themes.
- qbz-slint theme.slint: `in-out property <bool> is-high-contrast: false;`
beside is-system; re-exported via semantic-colors.slint so all ~145
components get `Theme.is-high-contrast` with no per-file import change.
Embedded the canonical HC border gate and the non-layout focus-ring
snippet as a comment for the cluster agents to copy verbatim.
- qbz-slint theme.rs: apply_theme sets it via
`qbz_theme::is_high_contrast(id)`.
cc
Materialize the final verified Part B palettes for wcag-light, wcag-dark,
high-contrast (dark), high-contrast-light, and colorblind. Each is a fully
populated ThemeColors row with solid (opaque) status surfaces/borders (not
alpha tints) so accessible contrast is guaranteed independent of backdrop.
Includes the success + focus-ring families per token; high-contrast-light
applies the adversarial warning correction (#735c00 -> #5e4b00, AA-only ->
AAA on white). colorblind uses the exact Okabe-Ito values with body-size
success routed to success-hover on the lightest tier (C.6).
Mark accessibility themes as implemented and add WCAG/APCA + CVD unit tests:
- wcag-* assert AAA (>=7:1) text/accent/status + APCA body |Lc|>=75
- high-contrast/-light assert >= the wcag-dark/-light text-bg ratio and
>=3:1 non-text for border-strong/focus-ring/border-subtle
- colorblind asserts AA on all tiers + danger/warning/accent stay distinct
under a Machado deuteranopia simulation (delta-E separation)
- all-rows test asserts every registered theme is fully populated (no zero
color), exactly 5 accessibility themes, 33 total ThemeId rows
cc
P2 of the theme migration. Materialize every non-accessibility theme
from src/app.css (per 01-tauri-themes-inventory.md): the core Light row
plus the 23 branded/community themes (warm, nord, dracula,
catppuccin-mocha, breeze-dark, adwaita-dark, alucard, aurora, ikari,
ayanami, iscariot, stratego, rumi, zoey, mira, rose-pine-dawn,
breeze-light, adwaita-light, duotone-snow, snow-storm, frost, langley,
kurosaki).
- No CSS cascade on the Slint side: every row is fully materialized.
Omitted tokens (light's accent trio; breeze-dark/adwaita-dark's
danger/warning families) are resolved against :root Dark at
transcription time.
- Alpha polarity is luminance-derived, not the Tauri type flag: frost
(#2e3440) and langley (#2c0a0a) are registered light but are dark
canvases -> white alpha base; alucard (#fffbeb) is genuinely light ->
black base. surface_hover/border_muted bases flip with polarity too.
- Status families (danger/warning) are baked as straight-alpha tints of
the solid hue (0.1/0.3/0.2; dracula 0.15/0.4/0.25; light hover 0.15),
matching Tauri's rgba() derivation.
- Derived (no Tauri parity) per-theme: success (project green #3fae6a on
dark, #1f8a4c on light), focus_ring (= accent), favorite (= danger).
These are placeholders to be polished in P4.
- is_implemented() now reports all standard themes ready; the 5
accessibility themes remain gated for P3.
Adds a full-population smoke test over every registered theme plus
per-theme hero-value, polarity and non-standard-tint assertions.
cargo test -p qbz-theme: 33 passed.
cc
Connect the qbz-theme registry to the running app and persist the choice
across restarts (owner hard requirement 2026-06-20).
- `src/theme.rs`: Rgba->slint::Color conversion, `to_slint` building the
generated `ThemeColors` (55 fields, exact name parity with the Slint struct),
`apply_theme` (set_c + set_is_system), and slug<->index<->id mapping with
slug as the source of truth. Unit tests cover the roundtrip + fallbacks.
- `ui_prefs.rs`: new `theme: String` slug field, `#[serde(default)]` ->
`qbz_theme::default_slug()` (OLED). Old profiles without the field restore to
OLED. Tests assert the default.
- `main.rs`: add the missing `"theme"` arm in `on_appearance_select` (was
falling into the debug `other =>` arm) — maps dropdown index to id, persists
the slug, hot-switches the palette. Startup restore seeds the dropdown list
from the registry, derives the index from the persisted slug, syncs the
legacy `ThemeState.mode`, and applies the theme before first paint.
- `app.slint`: export `Theme`/`ThemeColors`/`ThemeState` so Rust can drive them.
- `AppearanceSettings.slint`/`state.slint`: the theme list is now Rust-seeded;
comments updated (index no longer drives colors; slug is source of truth).
Headline acceptance: pick a theme, quit, relaunch -> same theme; fresh profile
boots into OLED Dark. The 4 existing themes are unchanged (legacy alias values
preserved in the registry).
cc
Replace the per-token `ThemeState.mode` ternary system with a single
`ThemeColors` struct (`Theme.c`) that Rust pushes from the qbz-theme registry.
- New `foundation/theme.slint`: `ThemeColors` struct (kebab-case fields in
lockstep with `qbz_theme::ThemeColors`) + `Theme` global of thin aliases.
Every existing token name is preserved (surface-*, text-*, accent-*,
border-subtle/muted, favorite, card-shadow, alpha-08/10/12/18/55/65/70/75)
so the ~1900 call sites compile verbatim; the danger/warning/success
families, border-strong, bg-hover, focus-ring and the full 24-tier alpha
superset are added.
- System mode preserved: `Theme.is-system` (set by Rust) falls the
System-overridden tokens back to the std-widgets Palette exactly as the old
`mode == 3` branch did; all other themes read `c.*`.
- `semantic-colors.slint` becomes a re-export of `Theme`/`ThemeColors` from
`theme.slint`, so the 146 existing imports keep resolving.
- The `Theme.c` default is seeded with the Dark palette so the live `.slint`
preview renders without a Rust feed.
cc
Introduce the `qbz-theme` crate (ADR-006): pure Rust, no Slint/Tauri/heavy
deps, hand-rolled color + contrast math. It holds the scalable theming data
model the Slint migration consumes.
- `Rgba` + WCAG relative-luminance/contrast-ratio + an APCA Lc approximation
(secondary a11y gate for P3 tests).
- `ThemeColors`: the complete token contract (surfaces incl. bg-hover, text,
accent, danger/warning/success families, border-subtle/muted/strong,
focus-ring, favorite, card-shadow, 24-tier alpha superset with polarity
baked in).
- `ThemeId` enum (all 33 themes) with stable slugs, display names (proper-noun
data, not i18n keys), Core/Dark/Light/Accessibility categories, and a
luminance-derived light/dark flag. `default_theme_id()` = OLED.
- Registry materializes the 4 P1 themes (Dark/OLED/Tokyo Night/System-fallback)
from src/app.css, preserving the legacy translucent alias values so the
existing themes stay pixel-identical; derives the new token families.
- 22 unit tests (registry fully populated, default is OLED, legacy alpha
aliases unchanged, slug roundtrip).
cc
The collapsed cluster put the (opacity-0 but still hit-testable) exit
button on top of the circle, so it stole the hover (only a sliver of the
circle reacted) and captured clicks (every press exited immersive). Make
the circle a permanent right-edge handle with its own full-area TouchArea;
reveal the action buttons to the LEFT of it so they never overlap; gate
the buttons inactive while collapsed; drive expand from an OR of bg/circle/
button hover (no steal-flicker); and let a click on the circle pin it open
as a fallback.
cc
Center the search field to the window (absolute x, independent of the
left mode trigger and right controls) like the main UI, instead of a
stretch slot that drifted off-center. Collapse the window controls to a
single circle pinned to the right edge that hover-expands LEFTWARD into a
glass capsule (right edge fixed, so it never displaces the centered
search). Reduce the active controls to Fullscreen toggle + Close (the
Close now EXITS the immersive overlay, not the app). Minimize, maximize/
restore, drag and close-app are parked as commented-out blocks for the
future qbz custom titlebar; their WindowControlActions wiring stays.
cc
Content sat directly over the variable album atmosphere and washed out.
Add a semi-opaque dark backing behind the split-right panel (and a lighter
plate behind the split-left metadata) so any panel reads on any cover.
Track Info: replace the dark-gray modal labels with bright local variants
(labels #ffffffb3, values near-white). Queue: fix the misaligned clear
button (content-sized, 34px baseline, flush trailing edge), de-pill the
sub-tabs (underline active), brighten rows. Suggestions: hairline rims and
badge backing discs, brighter text. Header: unify the glass capsule idiom
across the mode trigger, search placeholder and controls; polish the
two-level dropdown (caps group headers, spacing). Split gutters/gaps tuned.
cc
minimize/maximize/fullscreen were calling winit directly, but the Slint
winit adapter reconciles window state from Slint's own properties on every
realization (winitwindowadapter.rs ~1328 toggles winit to match
properties.is_fullscreen()), so a direct winit set_fullscreen was undone on
the next frame - fullscreen never stuck. Drive them through
slint::Window::{set_minimized,set_maximized,set_fullscreen} (api.rs), the
same properties the adapter reads, so the state holds. Drag stays on winit
(no Slint API). Same lesson as the miniplayer decorations fix.
cc
Replace the immersive ModeBar/ShaderBar with a two-level dropdown (Focus
and Split groups with their children) behind a display glyph, freeing the
header for a future Album/Playlist search (an inert styled placeholder
sits in the center for now). Port the Tauri header window-controls cluster
1:1: a new WindowControlActions global drives the main window via the
winit accessor (minimize, toggle-maximize, toggle-fullscreen, drag-move)
plus exit-immersive and close-app (reusing invoke_close_app). Fix the
split layout: the columns were both stretch 1 so the panel got a sliver;
art-left is now 55% with a large responsive cover, the panel 45%.
cc
The focus line used the cover's dominant hue (spectrum-primary), which
matched the atmosphere (built from the same cover) and blended in. Derive
a contrast accent instead: immersive::lyrics_accent_color takes the cover's
coverage-dominant hue and rotates it to the complement (+180deg), vivid and
mid-bright, with a teal fallback for B&W covers; exposed as
ImmersiveState.lyrics-accent and set on every cover decode.
cc
Extend the wgpu fragment-shader underlay from one scene to three.
shader_underlay builds a pipeline per scene (Vec, indexed by mode-1) over
the shared uniform feed; render_frame takes the active mode. Two original
clean-room WGSL scenes: tunnel.wgsl (neon polar-warped tunnel, zoom on
energy0, ring flare on transient) and aurora.wgsl (domain-warped curtains).
ImmersiveView gains a header scene picker (Off/Plasma/Tunnel/Aurora) and
the g key now cycles 0->1->2->3. WGSL is wgpu-compiled at runtime, so the
look needs a GUI smoke (cargo only checks the Rust/Slint).
cc
The 30Hz lyrics engine gated solely on ShellState.lyrics-open (the
sidebar), so the immersive Lyrics panels (focus + split) never advanced:
active-index/line-progress stayed frozen and the open-time kick() no-op'd
because it bailed at the same gate before computing. Widen the gate so the
engine also ticks while an immersive lyrics panel is active (focus mode 4
or split panel 0), read directly from ImmersiveState.
cc
Add ImmersiveSuggestionsPanel for the split-right slot (split-panel 2):
a cards row (up to 2 artist-playlist book-collage cards + a Song-Radio
diamond-collage card) and a recommended-tracks list. New suggestions.rs
controller assembles everything in Rust (ADR-006): artist tracks_appears_on
with an artist-tracks fallback under 5, dedupe by title, drop the current
track, shuffle, take 10; cards from the first two playlists plus a track
radio. New core method QbzCore::create_smart_track_radio mirrors the smart
artist radio (spawn_blocking + block_on, seed hoisted to index 0). Does NOT
use reco_store (live Qobuz queries only). Collage tilt deferred (flat for
now); blacklist drop happens downstream in play_tracks.
cc
Add ImmersiveTrackInfoPanel for the split-right slot (split-panel 1):
title + album, Duration/Quality/ISRC/Label-link metadata rows, grouped
credits and copyright, reusing MetaCell/CreditCell from TrackInfoModal
(now exported). A new load-without-open path (info_modals::
load_track_info_inline via TrackInfoActions.load-inline) populates
TrackInfoState without raising the floating modal, so the inline panel
fills in place; the data refreshes on now-playing track change. Header
gains a Track Info tab in the split rail (Lyrics, Track Info, Queue).
cc
Add ImmersiveQueuePanel, one component used both as a focus full-screen
queue (mode 5) and the split-right panel (split-panel 3). Queue/History
sub-tabs bound to QueueState.tab/set-tab; NOW PLAYING card + UP NEXT list
from the flat coverflow-tracks model (upcoming index = idx -
coverflow-index - 1 -> play-coverflow-upcoming), History from the
canonical history list. Header gains a Queue tab in both rails;
QueueState.panel-opened() fires on entry. Rows reuse the queue row
shape; scrolling via Flickable + shared ListScrollbar.
cc