Files
minstrel/docs/superpowers/specs/2026-04-25-web-ui-search-design.md
T
bvandeusen 3588af83e5 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.
2026-04-25 14:49:01 -04:00

13 KiB
Raw Blame History

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)

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

  • 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 /searchgoto('/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 ''."
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, 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.