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.
13 KiB
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/radioendpoint 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)
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)
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
SearchInputdebounces 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=). Enterblurs the input; no extra nav.Escapeclearsvalue(which then navigates per the rules above)./search/+page.sveltereadsqvia$derived((page.url.searchParams.get('q') ?? '').trim()). Whenq === '', no query is created (empty-state copy renders).- Overflow pages read
qthe same way; they show their own empty state copy whenq === ''. - TanStack Query caches per-
qresults, 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 ''." |
| 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
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, fullTrackRef). - 404 on nonexistent track id.
- 400 on missing/empty
seed_track.
Player store (store.test.ts):
enqueueTrackappends; preserves_indexwhen queue was non-empty; sets_index=0when state was'idle'.enqueueTracks([])no-ops; non-empty concatenates.playRadio(mockedapi.get) hits/api/radio?seed_track=<id>and forwards response toplayQueue.
SearchInput.test.ts:
- Single keystroke fires
gotoonce after 250 ms (vi.useFakeTimers+flushSync). - Burst of keystrokes in <250 ms fires
gotoonce after settle. - Empty input on
/searchcallsgoto('/search'). - Mount pre-fills
valuefrompage.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=buttonEnter/Space activation calls the play handler.- Click on the nested
+button callsenqueueTrackand does NOT trigger the row's play handler. onPlayprop overrides the defaultplayQueuebehavior.
AlbumCard.test.ts (additions):
- Click on
+callsenqueueTracks(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):
- From any page, type "miles" in the header bar → URL becomes
/search?q=miles, three sections appear. - Click a track → radio plays (one-track queue today via the stub); the bottom bar shows the seed.
- Click
+on another track → appended to queue (verify queue length increments viaplayer.queue.lengthin dev tools). - Click
+on an album card → all the album's tracks append; current playback uninterrupted. - Click "See all 47 artists →" → overflow page loads; "Load more" pulls additional pages.
- Click an artist → navigates to
/artists/:id; the header search bar still shows "miles". - Browser back → returns to
/search?q=mileswith cached results. - 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-0so it shrinks gracefully; deeper mobile polish is a separate slice. - Per-keystroke
gotohistory pollution. Already mitigated viareplaceStateonce 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.