docs(spec): add web UI search design

Specifies header search bar (debounced, URL-synced ?q=), /search page
with three sections of top-10 reusing library components, three
overflow routes (/search/artists, /search/albums, /search/tracks)
backed by createInfiniteQuery. Track click triggers playRadio with
seed via a new /api/radio stub that returns the seed alone (M4 fills
in real similarity-based selection later, response shape final). Adds
+queue button on TrackRow and AlbumCard via new enqueueTrack /
enqueueTracks player-store actions.
This commit is contained in:
2026-04-25 14:49:01 -04:00
parent 4264ecf538
commit 3588af83e5
@@ -0,0 +1,189 @@
# Web UI Search — Design Spec
**Status:** approved 2026-04-25
**Slice:** M6 sub-plan #5 (after Player). Backend `GET /api/search` already exists; this slice ships the SPA UI plus a small `/api/radio` stub.
## Goal
Replace the `/search` placeholder with a working search experience: a persistent header search box, live debounced results across artists/albums/tracks, per-facet overflow pages, click-to-play-as-radio for tracks, and an "add to queue" affordance on tracks and albums.
## Non-goals
- Real radio recommendations. The `/api/radio` endpoint ships as a stub returning `{ tracks: [seed] }`. The full M4 implementation (similarity-driven candidate pool + scoring + 80% queue refresh) lands later; only the request/response contract is locked here.
- Search-result pre-fetch / typeahead suggestions / autocomplete. Plain results page only.
- Mobile-specific layout polish. Header bar fits on desktop; mobile responsive pass is its own future slice.
- Keyboard shortcuts (e.g. Cmd-K to focus search). Out of scope.
- Cross-page search history / recent-searches list.
## Architecture
A persistent `SearchInput` component lives in `Shell.svelte`'s header. It binds a local `value` state, debounces 250 ms, and uses `goto()` to keep `/search?q=…` synced. First nav from elsewhere is a normal push; subsequent typing while on `/search` uses `replaceState` so back-navigation skips the typing log. Empty input on `/search` clears the param.
`/search/+page.svelte` reads `?q=` reactively and runs a TanStack Query against `GET /api/search?q=…&limit=10`, which returns three small paged facets. The page renders up to three sections (Artists / Albums / Tracks), each reusing the existing library components. Each section also renders a "See all N →" link to its overflow page when `total > items.length`. Empty facets are not rendered.
Three overflow pages — `/search/artists/`, `/search/albums/`, `/search/tracks/` — each read `?q=` and run their own `createInfiniteQuery` against the same `/api/search` endpoint with `limit=50`. They render only their own facet, ignoring the other two facets in the response (minor over-fetch, simpler than three new endpoints). "Load more" pulls the next page.
The Track click handler on the search results page calls a new player-store action `playRadio(seedTrackId)` which fetches `/api/radio?seed_track=…` and pipes the response into `playQueue(tracks, 0)`. On the album-detail page, Track clicks keep their current behavior (queue the album).
A new `+ queue` button on each track row and album card calls `enqueueTrack` / `enqueueTracks`, which append to the existing `_queue` rune state without disturbing the current playback or `_index`.
## API contracts
### `GET /api/search?q=&limit=&offset=` (existing — no change)
```ts
type SearchResponse = {
artists: Page<ArtistRef>;
albums: Page<AlbumRef>;
tracks: Page<TrackRef>;
};
type Page<T> = { items: T[]; total: number; limit: number; offset: number };
```
Server applies the shared `limit/offset` to each facet's `LIMIT/OFFSET` query independently. `q` is required; empty/whitespace is `400`. Default `limit` is bounded server-side; this slice sends `10` from the main `/search` page and `50` from each overflow page.
### `GET /api/radio?seed_track=<uuid>` (new — stub)
```ts
type RadioResponse = { tracks: TrackRef[] };
```
Stub behavior: `200` with `{ tracks: [<seed_track>] }` (one entry, the seed itself). `404 { code: 'not_found' }` if the track id doesn't exist. `400 { code: 'bad_request' }` if `seed_track` is missing or empty. Same `TrackRef` shape used everywhere else; `resolveTrackRefs` builds the response.
When M4 fills the endpoint, the response shape is final: extra entries appended after the seed, same `TrackRef` shape.
## Components & files
### New
| Path | Responsibility |
|---|---|
| `web/src/lib/components/SearchInput.svelte` | Header search box; debounced URL sync via `goto`; pre-fills from `page.url.searchParams` |
| `web/src/lib/components/SearchSkeleton.svelte` | Three-section placeholder (artist list / album grid / track list shapes) for `/search` loading state |
| `web/src/routes/search/+page.svelte` | Main results page — reads `?q=`, fires search query, renders three sections |
| `web/src/routes/search/artists/+page.svelte` | Artists overflow with `createInfiniteQuery` + Load more |
| `web/src/routes/search/albums/+page.svelte` | Albums overflow with `createInfiniteQuery` + Load more |
| `web/src/routes/search/tracks/+page.svelte` | Tracks overflow with `createInfiniteQuery` + Load more |
| `web/src/lib/components/SearchInput.test.ts` | Debounce / pre-fill / clear behavior |
| `web/src/routes/search/search.test.ts` | Page integration: 3 sections, empty hide, "See all" link, no-results, empty `?q=` prompt |
| `web/src/routes/search/artists/artists.test.ts` | Overflow tests (analogous for `albums.test.ts`, `tracks.test.ts`) |
| `internal/api/radio.go` | Stub handler |
| `internal/api/radio_test.go` | Stub tests (200/404/400) |
### Modified
| Path | Change |
|---|---|
| `web/src/lib/components/Shell.svelte` | Insert `<SearchInput />` in the header between brand and user-menu |
| `web/src/lib/components/TrackRow.svelte` | Refactor outer element from `<button>` to `<div role="button" tabindex="0">` with keyboard handlers (Enter/Space) so a real `<button>` for `+ queue` can nest inside without invalid HTML. Add optional `onPlay?: (track: TrackRef, index: number) => void` prop; default behavior remains `playQueue(tracks, index)` |
| `web/src/lib/components/TrackRow.test.ts` | Update for `role=button` keyboard activation; add `+ queue` click test; add `onPlay` override test |
| `web/src/lib/components/AlbumCard.svelte` | Wrap `<a href="/albums/:id">` around cover+title; add an absolutely-positioned `+ queue` button as a sibling. Click on the link still navigates; click on `+` calls `event.preventDefault()` + `event.stopPropagation()` then `api.get<AlbumDetail>('/api/albums/' + album_id)` (one-shot fetch, not the live `createAlbumQuery` store) and calls `enqueueTracks(detail.tracks)` |
| `web/src/lib/components/AlbumCard.test.ts` | Add `+ queue` click test |
| `web/src/lib/api/types.ts` | Add `SearchResponse`, `RadioResponse` |
| `web/src/lib/api/queries.ts` | Add `createSearchQuery(q)`, `createSearchArtistsInfiniteQuery(q)`, `createSearchAlbumsInfiniteQuery(q)`, `createSearchTracksInfiniteQuery(q)` |
| `web/src/lib/player/store.svelte.ts` | Add `enqueueTrack(t)`, `enqueueTracks(ts)`, `playRadio(seedTrackId)` actions |
| `web/src/lib/player/store.test.ts` | Cover `enqueueTrack` / `enqueueTracks` / `playRadio` |
| `internal/api/api.go` | Register `r.Get("/api/radio", h.handleRadio)` |
## URL & state behavior
- `SearchInput` debounces 250 ms before navigating. Each settled value either pushes (first nav from elsewhere) or replaces (already on `/search`) the URL.
- Empty input on `/search``goto('/search')` (drops `?q=`).
- `Enter` blurs the input; no extra nav.
- `Escape` clears `value` (which then navigates per the rules above).
- `/search/+page.svelte` reads `q` via `$derived((page.url.searchParams.get('q') ?? '').trim())`. When `q === ''`, no query is created (empty-state copy renders).
- Overflow pages read `q` the same way; they show their own empty state copy when `q === ''`.
- TanStack Query caches per-`q` results, so backspacing through previous values returns instantly.
## States
| Condition | Render |
|---|---|
| `q === ''` (main page) | "Start typing to search artists, albums, and tracks." |
| `q` non-empty AND `useDelayed(() => query.isPending) === true` AND no cached data | `SearchSkeleton` |
| `query.isError` | `ApiErrorBanner` with `onRetry={query.refetch}` |
| All three facets `items.length === 0` | "No matches for '<q>'." |
| Otherwise | Up to three sections; empty facets are skipped |
Overflow pages: same skeleton/error/empty pattern (using `LibrarySkeleton` for the per-facet variant). "Load more" button hides when `hasNextPage === false`.
## Player-store additions
```ts
export function enqueueTrack(t: TrackRef): void {
_queue = [..._queue, t];
if (_state === 'idle') _index = 0;
}
export function enqueueTracks(ts: TrackRef[]): void {
if (ts.length === 0) return;
_queue = [..._queue, ...ts];
if (_state === 'idle') _index = 0;
}
export async function playRadio(seedTrackId: string): Promise<void> {
const resp = await api.get<RadioResponse>(
`/api/radio?seed_track=${encodeURIComponent(seedTrackId)}`
);
if (resp.tracks.length > 0) playQueue(resp.tracks, 0);
}
```
`enqueueTrack`/`enqueueTracks` deliberately do NOT change `_state` — appending while playing should not pause; appending while paused should not auto-play. The `idle → 0` reset only matters when the queue was previously empty so that `_index` points at a valid position; the rest of the playback machinery (`+layout.svelte` audio effects, MediaSession glue) reacts to `_state` and `player.current` and continues to work unchanged.
## Testing
**Server (`internal/api/radio_test.go`):**
- 200 success path with valid `seed_track` (response shape, single-item array, full `TrackRef`).
- 404 on nonexistent track id.
- 400 on missing/empty `seed_track`.
**Player store (`store.test.ts`):**
- `enqueueTrack` appends; preserves `_index` when queue was non-empty; sets `_index=0` when state was `'idle'`.
- `enqueueTracks([])` no-ops; non-empty concatenates.
- `playRadio` (mocked `api.get`) hits `/api/radio?seed_track=<id>` and forwards response to `playQueue`.
**`SearchInput.test.ts`:**
- Single keystroke fires `goto` once after 250 ms (`vi.useFakeTimers` + `flushSync`).
- Burst of keystrokes in <250 ms fires `goto` once after settle.
- Empty input on `/search` calls `goto('/search')`.
- Mount pre-fills `value` from `page.url.searchParams.get('q')`.
**`search/search.test.ts`:**
- All three sections render when all three facets have items.
- Empty facets are hidden.
- "See all N →" links render when `total > items.length`.
- "No matches" copy renders when all three facets are empty.
- Empty `?q=` shows "Start typing" prompt and fires NO query.
**`search/artists/artists.test.ts`** (and analogous `albums.test.ts`, `tracks.test.ts`):
- Items render.
- "Load more" calls `fetchNextPage`.
- Button hides when `hasNextPage === false`.
**`TrackRow.test.ts` (updates):**
- `role=button` Enter/Space activation calls the play handler.
- Click on the nested `+` button calls `enqueueTrack` and does NOT trigger the row's play handler.
- `onPlay` prop overrides the default `playQueue` behavior.
**`AlbumCard.test.ts` (additions):**
- Click on `+` calls `enqueueTracks(album.tracks)` after fetching album detail; does NOT navigate.
- Click on the card area still navigates to `/albums/:id`.
**Manual end-to-end** (verified during the plan's final task):
1. From any page, type "miles" in the header bar → URL becomes `/search?q=miles`, three sections appear.
2. Click a track → radio plays (one-track queue today via the stub); the bottom bar shows the seed.
3. Click `+` on another track → appended to queue (verify queue length increments via `player.queue.length` in dev tools).
4. Click `+` on an album card → all the album's tracks append; current playback uninterrupted.
5. Click "See all 47 artists →" → overflow page loads; "Load more" pulls additional pages.
6. Click an artist → navigates to `/artists/:id`; the header search bar still shows "miles".
7. Browser back → returns to `/search?q=miles` with cached results.
8. Backspace through the input → URL clears; main page returns to "Start typing" prompt.
## Risks & mitigations
- **Radio stub looks broken.** A click on a track currently produces a one-track queue, which feels like "click did nothing meaningful." Mitigation: skeleton MVP framing; the contract is forward-compatible and the M4 task will deliver the real candidate-pool logic. The fallback playback works; the limitation is honesty about scope.
- **TrackRow refactor regresses album page.** The `<button>``<div role="button">` change touches an already-shipped surface. Mitigation: existing TrackRow tests run as part of the slice; the album-page tests (`src/routes/albums/[id]/album.test.ts`) re-run; manual click + keyboard activation verified before the slice ships.
- **Header search bar squeezes mobile layout.** Mitigation: the input gets `flex-1 min-w-0` so it shrinks gracefully; deeper mobile polish is a separate slice.
- **Per-keystroke `goto` history pollution.** Already mitigated via `replaceState` once the user is on `/search`.
- **Over-fetch on overflow pages.** Each overflow page receives all three facets in the response. Acceptable: the request is one round-trip and the unused fields are bounded by `limit=50` × 2 facets = ≤ 100 unused records. Migrating to per-facet endpoints later is a backward-compatible move if it ever becomes load-bearing.