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

190 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.