# 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; albums: Page; tracks: Page; }; type Page = { 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=` (new — stub) ```ts type RadioResponse = { tracks: TrackRef[] }; ``` Stub behavior: `200` with `{ tracks: [] }` (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 `` in the header between brand and user-menu | | `web/src/lib/components/TrackRow.svelte` | Refactor outer element from `