diff --git a/AGENTS.md b/AGENTS.md
new file mode 120000
index 00000000..681311eb
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1 @@
+CLAUDE.md
\ No newline at end of file
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 00000000..ab7bb538
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,379 @@
+# sonora
+
+A native Spotify client in Rust on [GPUI](https://github.com/zed-industries/zed) (Zed's UI
+framework), streaming through [librespot](https://github.com/librespot-org/librespot).
+Linux-only today. Cargo workspace, edition 2024, resolver 3.
+
+## Read this before writing code
+
+1. **A component probably already exists.** Check `crates/ui/src/lib.rs`, `crates/views/src/cells.rs`
+ and the tables below before writing a new element. See [Before you build a component](#before-you-build-a-component).
+2. **Never call the Spotify Web API.** There is no `reqwest` call to `api.spotify.com` anywhere and
+ there must not be one. All data comes from librespot's `spclient`. See [Backend](#backend-how-data-actually-arrives).
+3. **Never hardcode a color, radius, or size.** Everything comes from `cx.theme()`.
+ See [Theme and metrics](#theme-and-metrics).
+4. **Network work runs on the tokio runtime (`Io`), never on GPUI's executor.**
+ See [Async: two runtimes](#async-two-runtimes).
+5. **New assets must be registered in `crates/sonora/src/assets.rs`** or they silently fail to load.
+
+## Crate layout
+
+```
+crates/
+ sonora/ binary: main, window, actions, asset registry, HTTP client shim
+ views/ screens: home, library, detail, artist, search, settings, login
+ workspace/ app chrome: title bar, sidebar, player bar, filter/search field
+ state/ GPUI entities holding app state; owns all async orchestration
+ spotify/ Spotify data access over librespot spclient (no GPUI)
+ audio/ librespot playback engine + custom rodio sink (no GPUI)
+ ui/ design system: theme, metrics, and reusable elements (gpui only)
+ router/ Destination enum, navigation history, Link trait
+ input/ text input element + global actions and keybindings
+```
+
+Dependency direction is strict; do not create a back edge:
+
+```
+sonora → views → workspace → state → spotify
+ → audio
+ all ui-side crates → ui, router, input → ui → gpui
+```
+
+- `ui` depends only on `gpui` + `serde`. It must never know about `spotify`, `state`, or playback.
+- `spotify` and `audio` must never depend on `gpui`. They are plain async Rust.
+- `state` depends on `ui` only for `ThemeOverrides`, `MIN_FONT`, `MAX_FONT` (settings persistence).
+- Widgets that need app state (player bar, sidebar) live in `workspace`, not `ui`.
+
+## Building
+
+### Any platform: what the build needs
+
+The GPUI renderer is Vulkan-based, so a Vulkan ICD is a **runtime** requirement, not just a build
+one. Link-time deps: `vulkan-loader`, `wayland`, `libxkbcommon`, `libxcb`, `libx11`, `libxcursor`,
+`libxi`, `fontconfig`, `freetype`, `alsa-lib`, plus `pkg-config`.
+
+`.cargo/config.toml` passes `-fuse-ld=mold` for `x86_64-unknown-linux-gnu`, so **mold must be on
+PATH** for that target. If it isn't, either install mold or build with
+`RUSTFLAGS="" cargo build …` to drop the flag.
+
+### Nix (primary)
+
+```sh
+nix develop # or: direnv allow (.envrc runs `use flake`)
+cargo run --locked --package sonora
+nix run # build + run the packaged binary
+nix build # ./result/bin/sonora
+```
+
+The devShell supplies `rustc`, `rustfmt`, `rust-analyzer`, `mold`, `pkg-config`, `sccache` and the
+runtime libs via `LD_LIBRARY_PATH`. It does **not** ship `cargo` or `cargo-clippy` — those come from
+the ambient system profile here. If `cargo` is missing inside the shell, that's why.
+
+When `Cargo.lock` changes, `cargoHash` in `flake.nix` goes stale. Build once, take the `got:` hash
+from the failure, and paste it in.
+
+### Arch / CachyOS
+
+```sh
+sudo pacman -S --needed base-devel rust pkgconf alsa-lib fontconfig freetype2 \
+ libx11 libxcb libxcursor libxi libxkbcommon libxkbcommon-x11 wayland \
+ vulkan-icd-loader mold
+# plus a Vulkan driver: vulkan-radeon | vulkan-intel | nvidia-utils
+
+cargo run --locked --package sonora
+cargo build --release --locked --package sonora && ./target/release/sonora
+```
+
+### Debian/Ubuntu and Fedora
+
+Not exercised in this repo; package sets translated from the dependency list above.
+
+```sh
+# Debian/Ubuntu
+sudo apt install build-essential pkg-config mold libasound2-dev libfontconfig-1-dev \
+ libfreetype-dev libx11-dev libxcb1-dev libxcursor-dev libxi-dev \
+ libxkbcommon-dev libxkbcommon-x11-dev libwayland-dev libvulkan-dev mesa-vulkan-drivers
+
+# Fedora
+sudo dnf install @development-tools pkgconf-pkg-config mold alsa-lib-devel fontconfig-devel \
+ freetype-devel libX11-devel libxcb-devel libXcursor-devel libXi-devel \
+ libxkbcommon-devel libxkbcommon-x11-devel wayland-devel vulkan-loader-devel mesa-vulkan-drivers
+```
+
+### macOS / Windows
+
+Unsupported as configured. `gpui_platform` is pinned to the `x11` + `wayland` features, the flake
+only declares `x86_64-linux`/`aarch64-linux`, and the mold linker flag targets Linux. Porting means
+changing feature flags and the linker config — do not attempt it as a side effect of another task.
+
+### Checks
+
+```sh
+cargo fmt # rustfmt.toml: edition 2024, style_edition 2024
+cargo check --workspace
+cargo test --workspace
+cargo clippy --workspace # lib targets
+```
+
+The first build compiles GPUI from source; expect several minutes.
+
+`cargo clippy --workspace --all-targets` currently **fails**, on a deny-level
+`reversed_empty_ranges` from a deliberate `clamp_range("abc", &(2..1))` case in
+`crates/input/src/text.rs`. That failure predates your change; don't "fix" the test to silence it.
+The workspace also carries a handful of clippy warnings (complex types, a missing `Default`). Leave
+them unless the task is about them.
+
+### Runtime environment
+
+| | |
+| ----------------- | ------------------------------------------------------------------ |
+| Settings | `$XDG_CONFIG_HOME/sonora/settings.json` |
+| Credentials cache | `$XDG_CACHE_HOME/sonora/credentials.json` |
+| OAuth redirect | `http://127.0.0.1:8989/login`, override with `SONORA_REDIRECT_URI` |
+| Logging | `RUST_LOG`; default filter `warn,symphonia=error` |
+
+## Before you build a component
+
+Grep first. In order: `crates/ui/src/lib.rs` (exports), `crates/views/src/cells.rs` (grid cell
+renderers), `crates/workspace/src/` (chrome). Extend what's there — add a builder method to `Button`
+rather than writing `IconButton`.
+
+### `ui` — reusable elements
+
+| Item | Use for |
+| ----------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
+| `Button` | every button. Variants `.ghost() .outline() .primary() .danger()`, plus `.small() .icon() .label() .tint() .selected() .disabled()` |
+| `Card` | artwork + title + eyebrow + meta row/tile. `.art()` for tile mode, `.circle()`, `.loading()`, `.trailing()`, `.explicit()`, `.press()` |
+| `Artwork` | cover images with skeleton loading and a music-note fallback |
+| `Skeleton`, `Initials` | pulsing loading placeholder; avatar initials |
+| `Grid`, `GridSource`, `GridDelegate`, `GridState`, `ColumnSpec`, `Cell` | every table. Virtualized, sortable, filterable, hideable columns, responsive `hide_below` |
+| `Scroller` + `Scrollbar` | any scrolling region. Do not use bare `overflow_y_scroll` |
+| `Scrubber` + `ScrubberState` | any draggable 0..1 track (seek bar, volume) |
+| `Menu`, `MenuItem` | dropdowns (deferred + occluded, with `on_dismiss`) |
+| `InlineLinks`, `InlineLink` | comma-joined clickable artist lists |
+| `eyebrow()`, `heading()` | the two standard text styles |
+| `ExplicitBadge` | the "E" badge |
+| `WindowControls` | minimize/maximize/close, honoring platform decorations |
+| `clock()` | `Duration` → `m:ss` |
+| `snapped()` | round a `Pixels` to the device pixel grid |
+
+Also available: `Input` (`input` crate — full text editing, IME, selection, clipboard) and
+`Link` (`router` — makes a `Stateful
` navigate on click).
+
+### `views/src/cells.rs` — grid cell renderers
+
+`index` (play/pause/now-playing transport with hover preload), `artists`, `link`, `text`, `dim`,
+`title`, `artwork`, `blank`, `transport`, `toggle`, `artist_links`. Reuse these in any new
+`GridSource::cell`.
+
+### Element conventions
+
+New elements follow one shape — copy `ui/src/button.rs`:
+
+```rust
+#[derive(IntoElement)]
+pub struct Thing { base: Stateful
, /* … */ }
+
+impl Thing {
+ #[track_caller]
+ pub fn new(id: impl Into) -> Self { … }
+ pub fn variant(mut self) -> Self { …; self } // consuming builders
+}
+
+impl Styled for Thing { fn style(&mut self) -> &mut StyleRefinement { self.base.style() } }
+impl InteractiveElement for Thing { … }
+
+impl RenderOnce for Thing {
+ fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
+ let overrides = std::mem::take(base.style()); // caller styles win
+ let mut thing = base./* defaults */;
+ thing.style().refine(&overrides);
+ thing
+ }
+}
+```
+
+The `mem::take` / `refine` dance is deliberate: it lets a call site override any default without
+the element re-applying it afterwards. Keep it.
+
+Stateful components (`Scrollbar`, `Input`, `GridState`) are `Render` entities instead, created with
+`cx.new(…)` and held by the parent.
+
+## Theme and metrics
+
+`ui/src/theme.rs` is the single source of every color, radius, and font size.
+
+```rust
+use ui::ActiveTheme as _;
+let theme = *cx.theme(); // Theme is Copy
+theme.foreground, theme.muted_foreground, theme.secondary_hover, theme.table_row_border, …
+theme.radius
+theme.text(Text::Small) // Tiny Small Label Body Large Title Display
+theme.metrics.row / .header / .pad / .inset / .control / .field
+ .title_bar / .player_bar / .list_row / .thumb / .cover
+```
+
+- Every metric scales off the user's font size (`Metrics::new(base)`), so **never** hardcode a
+ height for a row, control, or bar — read it from `theme.metrics`.
+- A literal `px(…)` is acceptable only for a local, non-scaling detail, declared as a `const` at
+ module top (see `NUMBER`, `DATE`, `WIDE` in `views/src/cells.rs`).
+- Adding a color token means adding it to `Theme`, to every `Theme::*()` constructor, to
+ `ThemeOverrides`, and to the `apply_color!` list — all four, or overrides break.
+- Eight themes exist (`ThemeKind::ALL`). `ocean`/`rose`/`lavender`/`amber` are derived by mutating
+ `midnight()`/`dark()`; follow that pattern rather than writing a full palette.
+- Users can override any token via `settings.json`; `Theme::set` re-renders all windows.
+
+## Async: two runtimes
+
+GPUI has its own executor; librespot and `reqwest` need tokio. `state::Io` wraps a multi-thread
+tokio `Runtime` and is a GPUI global.
+
+```rust
+let io = Io::global(cx);
+self.task = Some(cx.spawn(async move |this, cx| { // GPUI executor
+ let loaded = join(io.spawn(async move { // tokio: all network work
+ client.album_tracks(&id).await
+ })).await;
+ this.update(cx, |this, cx| { /* apply, cx.notify() */ }).ok();
+}));
+```
+
+Rules:
+
+- Anything touching `SpotifyApi`, librespot, or sockets goes inside `io.spawn`.
+- Only mutate entity state inside `this.update`, and end with `cx.notify()`.
+- Store the returned `Task` in a field (`task`, `load`, `fetch`) — dropping it cancels the work,
+ which is how sign-out and navigation cancel in-flight loads. Never `.detach()` a data load.
+- `join(handle)` (crate-private in `state`) flattens `JoinHandle>`.
+- `cx.subscribe(…)` / `cx.observe(…)` **are** `.detach()`-ed, in the constructor.
+- Because `join` and `Io` plumbing live in `state`, new network-backed features belong in a `state`
+ entity — not in a view.
+
+## Backend: how data actually arrives
+
+### There is no Spotify Web API here
+
+`crates/spotify` talks to Spotify through `librespot_core::Session::spclient()` — the same internal
+endpoints the official client uses, returning protobuf or JSON. Consequences:
+
+- Do not add `reqwest` calls to `api.spotify.com`, do not add a client secret, do not add
+ `rspotify`. The only `reqwest` in the tree is `crates/sonora/src/http.rs`, a `gpui::HttpClient`
+ adapter that exists purely so GPUI can fetch cover images.
+- Auth is OAuth PKCE via `librespot-oauth` against **Spotify's own client id**
+ (`DEFAULT_CLIENT_ID` in `auth.rs`). A developer-app client id will be refused at session connect —
+ `auth::denied` documents this. Don't "fix" auth by swapping in a registered app id.
+
+### Module map
+
+| Module | Endpoint / mechanism |
+| ------------------------- | --------------------------------------------------------------------------------- |
+| `auth.rs` | OAuth login, credential cache, `restore` / `login` / `forget` |
+| `client.rs` | `SpotifyApi` trait + `LibrespotClient`; the only type views/state see |
+| `collection.rs` | saved tracks; `metadata()` — batched `get_extended_metadata` (TRACK_V4) |
+| `collection2.rs` | `/collection/v2/paging` — hand-rolled protobuf, paged, honors tombstones |
+| `albums.rs`, `artists.rs` | extended metadata for albums/artists, artist portraits |
+| `playlists.rs` | `get_playlist` → `SelectedListContent` → uri list → `collection::metadata` |
+| `search.rs` | `get_context("spotify:search:…")` → track uris → `collection::metadata` |
+| `radio.rs` | `get_radio_for_track` → a generated playlist id → `playlist_tracks` |
+| `profiles.rs` | display-name lookups, fanned out over a `JoinSet` |
+| `wire.rs` | protobuf → `models` conversion, `image_url` (file id → `i.scdn.co/image/`) |
+| `pb.rs` | minimal protobuf `Reader`/`Writer` for endpoints with no generated schema |
+| `models.rs` | `Track`, `Album`, `AlbumDetail`, `Artist`, `ArtistRef`, `Playlist`, `UserProfile` |
+
+Working notes:
+
+- Prefer generated messages from `librespot-protocol`. `pb.rs` exists only because the collection-v2
+ paging schema isn't in that crate — don't hand-roll a new one if a generated type exists.
+- The recurring pattern is **uris → `collection::metadata` → `Track`**. A new track-listing endpoint
+ should resolve uris and reuse `metadata`, not re-parse track fields.
+- `Track::id` is `Option` base62 (no `spotify:track:` prefix); the prefix is added at the
+ audio boundary. `Track::playable` is false for unavailable tracks — check it before playing.
+- New endpoints: add the method to the `SpotifyApi` trait, implement on `LibrespotClient` by
+ delegating to a focused module. The trait exists so `state` depends on an interface, not on
+ librespot directly.
+- Errors use `anyhow` with `.context("cannot …")` in lowercase.
+
+### Audio
+
+`crates/audio` owns `librespot_playback::Player` plus `BlazingSink`, a custom rodio sink with
+smooth gain ramping and flush-on-seek. `Engine::start(session, config)` returns
+`(Engine, AudioEvents)`; `AudioEvents::next()` yields the reduced `AudioEvent`
+(`Loading/Playing/Paused/Position/Ended/Unavailable`).
+
+Never drive librespot's `Player` from a view. Go through `state::Playback`, which owns the engine,
+pumps events into `PlaybackState`, and handles shuffle, repeat, skip debouncing, and the cooldown
+after an `Unavailable` track.
+
+### State entities
+
+`state::init` installs a `Sonora` global holding `session`, `library`, `playback`, `queue`,
+`settings`. Reach them with `Sonora::global(cx)`.
+
+| Entity | Responsibility |
+| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `Session` | auth lifecycle; emits `SessionEvent::{SignedIn, SignedOut}`; hands out `Arc` and the raw librespot session |
+| `Library` | saved tracks / playlists / albums; `LibraryState` is `Empty \| Loading \| Ready{..,problems} \| Failed` — partial failure is normal, surface `problems` |
+| `Playback` | engine ownership, transport, shuffle/repeat, volume, `Origin` tracking |
+| `Queue` | past / current / upcoming; `start`, `next`, `next_random`, `previous`, `rewind` |
+| `Home`, `Detail`, `ArtistDetail`, `Search` | per-screen loaders, each owning its `Task` |
+| `AppSettings` | debounced JSON persistence |
+
+Everything reacts to `SessionEvent`: signing out must clear derived state. If you add an entity that
+caches Spotify data, subscribe to `Session` and clear on `SignedOut`.
+
+## UI patterns
+
+**Navigation.** `router::Destination` is the route enum; `router::navigate(dest, cx)`, `back`,
+`forward`. `Root` subscribes to `NavigationEvent::Moved` and swaps the workspace content. For a
+clickable region, use the `Link` trait (`div().id(..).link(Destination::Album(id))`) instead of a
+manual click handler.
+
+**New screen checklist:** add a `Destination` variant → add a state entity if it loads data → add
+the view under `crates/views/src/` → construct it in `Root::new` and wire it in `Root::show` →
+add a sidebar entry in `workspace/src/sidebar.rs` if it's top-level.
+
+**Tables.** Implement `GridSource` (`columns`, `rows`, `cell`, and optionally `compare`, `matches`,
+`playing`, `is_loading`), define a `&'static [ColumnSpec]`, hold a
+`GridState` entity, render `grid(&state)`. Column widths use `Width::{Fixed, Fill, Thumb}`
+and `hide_below` for responsive dropping. Hidden columns persist through
+`AppSettings::{hidden_columns, set_hidden_columns}`.
+
+**Filtering.** Implement `workspace::Searchable` on the view; `Root` binds it to the shared `Filter`
+in the title bar. Don't build a second search box.
+
+**Actions and keys.** Declare actions in `crates/input/src/lib.rs` (`actions!` macro), bind them in
+`bindings()`, handle them with `cx.on_action` (global, in `sonora/src/actions.rs`) or
+`.on_action(cx.listener(…))` (scoped). Key contexts: `Workspace`, `Input`, `Grid`. Both `cmd-` and
+`ctrl-` bindings are registered for every shortcut.
+
+**Assets.** SVGs live in `assets/icons/`, are embedded with `include_bytes!`, and are referenced as
+`"icons/.svg"`. Adding a file is not enough — add the stem to the `ICONS` list in
+`crates/sonora/src/assets.rs`, otherwise loading logs `assets: … is not registered` and renders
+nothing. Icons are Lucide (`assets/icons/LICENSE`); the UI font is Inter.
+
+## Code style
+
+- **Comments: essentially none.** The codebase has almost zero. Name things so they don't need one.
+ If a comment is unavoidable it is at most three lowercase words, no trailing period.
+- Names are short and domain-flavored: `Look`, `Metrics`, `Grab`, `Scored`, `wire`, `pb`, `clock`,
+ `snapped`. Prefer a noun that reads at the call site over a descriptive compound.
+- `use gpui::prelude::*;` then explicit imports; traits imported anonymously (`use ui::ActiveTheme as _;`).
+- Module shape: `use` block → `const`s in SCREAMING_SNAKE → types → impls → private free helpers at
+ the bottom.
+- Prefer combinator chaining over branching in render: `.when()`, `.when_some()`, `.when_else()`,
+ `.map()`. Prefer `match` over `if/else` for two-arm boolean choices — that's the house style
+ (`match small { true => …, false => … }`).
+- Use let-else for early exits; return early rather than nesting.
+- `anyhow::Result` at boundaries, `.context("cannot …")` lowercase; log with `log::warn!`/`error!`
+ prefixed by subsystem (`"playback: …"`, `"settings: …"`, `"assets: …"`).
+- Tests are `#[cfg(test)] mod tests` at the bottom of the file they cover — see
+ `spotify/src/{collection2,radio,search}.rs` and `input/src/text.rs`. They're pure-function tests;
+ there is no UI or network test harness.
+- Dependencies go in the root `[workspace.dependencies]`, then `dep.workspace = true` in the crate.
+ `gpui`/`gpui_platform` are pinned to one git rev — bump both together or the build breaks.
+
+## Commits
+
+Conventional Commits: `type(scope): description`, imperative, lowercase, no trailing period, no body.
+Scopes in use: `player`, `playback`, `views`, `ui`. Never add a `Co-Authored-By` trailer or any
+assistant attribution.