From 8fd193e186faf0b483c70a19249d61571fbf657d Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 25 Apr 2026 14:59:08 -0400 Subject: [PATCH] docs(plan): add web UI search implementation plan 12 tasks: server radio stub, web types, player-store enqueue/playRadio, query helpers, TrackRow div+role refactor, AlbumCard +queue overlay, SearchInput header component, SearchSkeleton, Shell wiring, /search main page, three overflow pages, final verification. --- .../plans/2026-04-25-web-ui-search.md | 2152 +++++++++++++++++ 1 file changed, 2152 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-25-web-ui-search.md diff --git a/docs/superpowers/plans/2026-04-25-web-ui-search.md b/docs/superpowers/plans/2026-04-25-web-ui-search.md new file mode 100644 index 00000000..fa48c496 --- /dev/null +++ b/docs/superpowers/plans/2026-04-25-web-ui-search.md @@ -0,0 +1,2152 @@ +# Web UI Search Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship a working search experience: persistent header search box, live debounced results across artists/albums/tracks, per-facet overflow pages, click-to-play-as-radio for tracks (via a stubbed `/api/radio`), and a `+ queue` affordance on track rows and album cards. + +**Architecture:** Backend already exposes `GET /api/search`. This slice adds a one-handler `/api/radio` stub plus a SvelteKit-only client: a `SearchInput` in the persistent `Shell` header keeps `/search?q=…` synced with debounced `goto`; a `/search/+page.svelte` reads `?q=` reactively and renders three sections reusing existing `ArtistRow` / `AlbumCard` / `TrackRow` components; three overflow routes (`/search/artists`, `/search/albums`, `/search/tracks`) each run their own `createInfiniteQuery`. The player store grows `enqueueTrack` / `enqueueTracks` / `playRadio` actions; `TrackRow` becomes a `
` so a real ` + {formatDuration(track.duration_sec)} +
+``` + +- [ ] **Step 4: Run tests, confirm all pass** + +Run: `cd web && npm test -- src/lib/components/TrackRow.test.ts` +Expected: PASS — 7 tests. + +- [ ] **Step 5: Run the album-page tests to confirm no regression** + +Run: `cd web && npm test -- 'src/routes/albums/[id]/album.test.ts'` +Expected: PASS — 5 tests. + +- [ ] **Step 6: Type-check** + +Run: `cd web && npm run check` +Expected: `0 ERRORS 0 WARNINGS`. + +- [ ] **Step 7: Commit** + +```bash +git add web/src/lib/components/TrackRow.svelte web/src/lib/components/TrackRow.test.ts +git commit -m "feat(web): TrackRow div+role button with onPlay prop and + queue button + +Outer + +``` + +- [ ] **Step 4: Run tests, confirm all pass** + +Run: `cd web && npm test -- src/lib/components/AlbumCard.test.ts` +Expected: PASS — 4 tests. + +- [ ] **Step 5: Run artist-detail page tests to confirm no regression** + +Run: `cd web && npm test -- 'src/routes/artists/[id]/artist.test.ts'` +Expected: PASS — 5 tests. + +- [ ] **Step 6: Type-check** + +Run: `cd web && npm run check` +Expected: `0 ERRORS 0 WARNINGS`. + +- [ ] **Step 7: Commit** + +```bash +git add web/src/lib/components/AlbumCard.svelte web/src/lib/components/AlbumCard.test.ts +git commit -m "feat(web): AlbumCard + queue overlay button + +Wraps the existing link in a relative container; adds an absolutely +positioned + button on click stops propagation, fetches /api/albums/:id +once, and feeds tracks into enqueueTracks." +``` + +--- + +## Task 7: `SearchInput` component (header search box) + +**Files:** +- Create: `web/src/lib/components/SearchInput.svelte` +- Create: `web/src/lib/components/SearchInput.test.ts` + +The component reads `q` from `page.url.searchParams` to pre-fill, debounces typing 250 ms, and uses `goto()` to keep the URL synced. First navigation pushes a history entry; once on `/search`, subsequent typing replaces. `Escape` clears the input. Test file uses `.svelte.test.ts` because it relies on `flushSync` + `vi.useFakeTimers` to drive the debounce. + +- [ ] **Step 1: Write the failing tests** + +Create `web/src/lib/components/SearchInput.test.ts`: + +```ts +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/svelte'; + +const state = vi.hoisted(() => ({ + pageUrl: new URL('http://localhost/') +})); + +vi.mock('$app/state', () => ({ + page: { get url() { return state.pageUrl; } } +})); + +vi.mock('$app/navigation', () => ({ + goto: vi.fn() +})); + +import SearchInput from './SearchInput.svelte'; +import { goto } from '$app/navigation'; + +beforeEach(() => { + vi.useFakeTimers(); + state.pageUrl = new URL('http://localhost/'); +}); + +afterEach(() => { + vi.clearAllMocks(); + vi.useRealTimers(); +}); + +describe('SearchInput', () => { + test('mount pre-fills value from page.url.searchParams', () => { + state.pageUrl = new URL('http://localhost/search?q=hello'); + render(SearchInput); + const input = screen.getByRole('searchbox') as HTMLInputElement; + expect(input.value).toBe('hello'); + }); + + test('typing fires goto once after 250ms debounce', async () => { + render(SearchInput); + const input = screen.getByRole('searchbox') as HTMLInputElement; + await fireEvent.input(input, { target: { value: 'h' } }); + expect(goto).not.toHaveBeenCalled(); + vi.advanceTimersByTime(250); + expect(goto).toHaveBeenCalledTimes(1); + expect(goto).toHaveBeenCalledWith('/search?q=h', { replaceState: false }); + }); + + test('rapid keystrokes debounce to a single goto', async () => { + render(SearchInput); + const input = screen.getByRole('searchbox') as HTMLInputElement; + await fireEvent.input(input, { target: { value: 'h' } }); + vi.advanceTimersByTime(100); + await fireEvent.input(input, { target: { value: 'he' } }); + vi.advanceTimersByTime(100); + await fireEvent.input(input, { target: { value: 'hel' } }); + vi.advanceTimersByTime(250); + expect(goto).toHaveBeenCalledTimes(1); + expect(goto).toHaveBeenCalledWith('/search?q=hel', { replaceState: false }); + }); + + test('typing while on /search uses replaceState=true', async () => { + state.pageUrl = new URL('http://localhost/search?q=h'); + render(SearchInput); + const input = screen.getByRole('searchbox') as HTMLInputElement; + await fireEvent.input(input, { target: { value: 'he' } }); + vi.advanceTimersByTime(250); + expect(goto).toHaveBeenCalledWith('/search?q=he', { replaceState: true }); + }); + + test('clearing input on /search calls goto("/search") (drops ?q=)', async () => { + state.pageUrl = new URL('http://localhost/search?q=h'); + render(SearchInput); + const input = screen.getByRole('searchbox') as HTMLInputElement; + await fireEvent.input(input, { target: { value: '' } }); + vi.advanceTimersByTime(250); + expect(goto).toHaveBeenCalledWith('/search', { replaceState: true }); + }); + + test('Escape clears the input value', async () => { + render(SearchInput); + const input = screen.getByRole('searchbox') as HTMLInputElement; + await fireEvent.input(input, { target: { value: 'hello' } }); + expect(input.value).toBe('hello'); + await fireEvent.keyDown(input, { key: 'Escape' }); + expect(input.value).toBe(''); + }); + + test('encodeURIComponent applied to special chars', async () => { + render(SearchInput); + const input = screen.getByRole('searchbox') as HTMLInputElement; + await fireEvent.input(input, { target: { value: 'a b&c' } }); + vi.advanceTimersByTime(250); + expect(goto).toHaveBeenCalledWith('/search?q=a%20b%26c', { replaceState: false }); + }); +}); +``` + +- [ ] **Step 2: Run tests, confirm they fail** + +Run: `cd web && npm test -- src/lib/components/SearchInput.test.ts` +Expected: FAIL — `SearchInput.svelte` doesn't exist. + +- [ ] **Step 3: Implement `web/src/lib/components/SearchInput.svelte`** + +```svelte + + + +``` + +Note on the `$effect` block: it mirrors URL → input. The opposite direction (input → URL) lives in `navigate`. The two writes don't loop because the input change handler always sets `value` *before* scheduling navigation, and the effect compares before assigning. + +- [ ] **Step 4: Run tests, confirm they pass** + +Run: `cd web && npm test -- src/lib/components/SearchInput.test.ts` +Expected: PASS — 7 tests. + +- [ ] **Step 5: Type-check** + +Run: `cd web && npm run check` +Expected: `0 ERRORS 0 WARNINGS`. + +- [ ] **Step 6: Commit** + +```bash +git add web/src/lib/components/SearchInput.svelte web/src/lib/components/SearchInput.test.ts +git commit -m "feat(web): add SearchInput header component (debounced URL sync) + +Pre-fills from page.url.searchParams.get('q'); typing debounces 250ms +then goto('/search?q=…'); first nav from elsewhere pushes, subsequent +typing on /search replaces. Empty input on /search drops the param. +Escape clears the input. URL → value re-sync via \$effect handles +external navigation (back/forward, result-link clicks)." +``` + +--- + +## Task 8: `SearchSkeleton` component + +**Files:** +- Create: `web/src/lib/components/SearchSkeleton.svelte` + +Three small placeholder sections matching the shape of the rendered results page. No tests — pure markup mirroring `LibrarySkeleton` patterns. + +- [ ] **Step 1: Create `web/src/lib/components/SearchSkeleton.svelte`** + +```svelte + + +
+
+
+
+ {#each Array.from({ length: 4 }) as _, i (i)} +
+ + + +
+ {/each} +
+
+ +
+
+
+ {#each Array.from({ length: 5 }) as _, i (i)} +
+
+
+
+
+ {/each} +
+
+ +
+
+
+ {#each Array.from({ length: 6 }) as _, i (i)} +
+ {/each} +
+
+
+``` + +- [ ] **Step 2: Type-check** + +Run: `cd web && npm run check` +Expected: `0 ERRORS 0 WARNINGS`. + +- [ ] **Step 3: Commit** + +```bash +git add web/src/lib/components/SearchSkeleton.svelte +git commit -m "feat(web): add SearchSkeleton placeholder for the search page" +``` + +--- + +## Task 9: Wire `SearchInput` into `Shell.svelte` + +**Files:** +- Modify: `web/src/lib/components/Shell.svelte` + +Existing Shell test (3 cases) must still pass. + +- [ ] **Step 1: Inspect the current Shell** + +Run: `cat web/src/lib/components/Shell.svelte` +Locate the header `
Minstrel
`. + +- [ ] **Step 2: Modify `web/src/lib/components/Shell.svelte`** + +Change two things in the script block — add the import at the top with the others: + +```ts +import SearchInput from './SearchInput.svelte'; +``` + +In the template, find the header section: + +```svelte +
+
Minstrel
+
+ +
+
+``` + +Replace with: + +```svelte +
+
Minstrel
+
+ +
+
+ +
+
+``` + +Keep the existing user-menu `
` block intact. + +- [ ] **Step 3: Run Shell tests to confirm no regression** + +Run: `cd web && npm test -- src/lib/components/Shell.test.ts` +Expected: PASS — 3 tests. + +- [ ] **Step 4: Type-check** + +Run: `cd web && npm run check` +Expected: `0 ERRORS 0 WARNINGS`. + +- [ ] **Step 5: Commit** + +```bash +git add web/src/lib/components/Shell.svelte +git commit -m "feat(web): mount SearchInput in Shell header + +Header grows a flex-1 column between the brand and the user menu. +Existing Shell tests remain green (the search input has no impact on +nav/menu behavior)." +``` + +--- + +## Task 10: `/search/+page.svelte` — main results page + +**Files:** +- Create: `web/src/routes/search/+page.svelte` (overwrites the current placeholder) +- Create: `web/src/routes/search/search.test.ts` + +The page reads `?q=` reactively, runs `createSearchQuery`, renders three sections (Artists / Albums / Tracks). Empty facets are skipped. "See all N →" link appears when `total > items.length`. Empty `?q=` shows a "Start typing" prompt and fires no query. Track click triggers `playRadio(track.id)` via the `onPlay` prop. + +- [ ] **Step 1: Write the failing tests** + +Create `web/src/routes/search/search.test.ts`: + +```ts +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import { mockQuery } from '../../test-utils/query'; +import type { ArtistRef, AlbumRef, TrackRef, SearchResponse } from '$lib/api/types'; + +const state = vi.hoisted(() => ({ + pageUrl: new URL('http://localhost/search') +})); + +vi.mock('$app/state', () => ({ + page: { get url() { return state.pageUrl; } } +})); + +vi.mock('$lib/api/queries', () => ({ + createSearchQuery: vi.fn() +})); + +vi.mock('$lib/player/store.svelte', () => ({ + playQueue: vi.fn(), + playRadio: vi.fn(), + enqueueTrack: vi.fn(), + enqueueTracks: vi.fn() +})); + +import SearchPage from './+page.svelte'; +import { createSearchQuery } from '$lib/api/queries'; + +function emptyResp(): SearchResponse { + return { + artists: { items: [], total: 0, limit: 10, offset: 0 }, + albums: { items: [], total: 0, limit: 10, offset: 0 }, + tracks: { items: [], total: 0, limit: 10, offset: 0 } + }; +} + +const artist: ArtistRef = { id: 'a1', name: 'Miles Davis', album_count: 12 }; +const album: AlbumRef = { + id: 'al1', title: 'Kind of Blue', artist_id: 'a1', artist_name: 'Miles Davis', + year: 1959, track_count: 5, duration_sec: 2630, cover_url: '/api/albums/al1/cover' +}; +const track: TrackRef = { + id: 't1', title: 'So What', + album_id: 'al1', album_title: 'Kind of Blue', + artist_id: 'a1', artist_name: 'Miles Davis', + track_number: 1, disc_number: 1, duration_sec: 545, + stream_url: '/api/tracks/t1/stream' +}; + +afterEach(() => { + vi.clearAllMocks(); + state.pageUrl = new URL('http://localhost/search'); +}); + +describe('search page', () => { + test('empty ?q= shows the "Start typing" prompt and fires no query', () => { + state.pageUrl = new URL('http://localhost/search'); + render(SearchPage); + expect(screen.getByText(/start typing/i)).toBeInTheDocument(); + expect(createSearchQuery).not.toHaveBeenCalled(); + }); + + test('all three sections render when all three facets have items', () => { + state.pageUrl = new URL('http://localhost/search?q=miles'); + const resp: SearchResponse = { + ...emptyResp(), + artists: { items: [artist], total: 1, limit: 10, offset: 0 }, + albums: { items: [album], total: 1, limit: 10, offset: 0 }, + tracks: { items: [track], total: 1, limit: 10, offset: 0 } + }; + (createSearchQuery as ReturnType).mockReturnValue(mockQuery({ data: resp })); + render(SearchPage); + expect(screen.getByRole('heading', { name: /artists/i })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: /albums/i })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: /tracks/i })).toBeInTheDocument(); + }); + + test('empty facets are hidden', () => { + state.pageUrl = new URL('http://localhost/search?q=miles'); + const resp: SearchResponse = { + ...emptyResp(), + tracks: { items: [track], total: 1, limit: 10, offset: 0 } + }; + (createSearchQuery as ReturnType).mockReturnValue(mockQuery({ data: resp })); + render(SearchPage); + expect(screen.queryByRole('heading', { name: /artists/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: /albums/i })).not.toBeInTheDocument(); + expect(screen.getByRole('heading', { name: /tracks/i })).toBeInTheDocument(); + }); + + test('"See all N →" link renders when total > items.length', () => { + state.pageUrl = new URL('http://localhost/search?q=miles'); + const resp: SearchResponse = { + ...emptyResp(), + artists: { items: [artist], total: 47, limit: 10, offset: 0 } + }; + (createSearchQuery as ReturnType).mockReturnValue(mockQuery({ data: resp })); + render(SearchPage); + const link = screen.getByRole('link', { name: /see all 47/i }); + expect(link).toHaveAttribute('href', '/search/artists?q=miles'); + }); + + test('"See all" link omitted when total === items.length', () => { + state.pageUrl = new URL('http://localhost/search?q=miles'); + const resp: SearchResponse = { + ...emptyResp(), + tracks: { items: [track], total: 1, limit: 10, offset: 0 } + }; + (createSearchQuery as ReturnType).mockReturnValue(mockQuery({ data: resp })); + render(SearchPage); + expect(screen.queryByRole('link', { name: /see all/i })).not.toBeInTheDocument(); + }); + + test('all three facets empty renders "No matches"', () => { + state.pageUrl = new URL('http://localhost/search?q=zzz'); + (createSearchQuery as ReturnType).mockReturnValue( + mockQuery({ data: emptyResp() }) + ); + render(SearchPage); + expect(screen.getByText(/no matches for/i)).toBeInTheDocument(); + expect(screen.getByText(/zzz/)).toBeInTheDocument(); + }); + + test('error state renders ApiErrorBanner', () => { + state.pageUrl = new URL('http://localhost/search?q=miles'); + (createSearchQuery as ReturnType).mockReturnValue( + mockQuery({ isError: true, error: { code: 'server_error', message: 'boom' } }) + ); + render(SearchPage); + expect(screen.getByRole('alert')).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Run tests, confirm they fail** + +Run: `cd web && npm test -- src/routes/search/search.test.ts` +Expected: FAIL — page is the placeholder; no sections, no prompt copy. + +- [ ] **Step 3: Replace `web/src/routes/search/+page.svelte`** + +```svelte + + +
+ {#if !q} +

+ Start typing to search artists, albums, and tracks. +

+ {:else if query?.isError} + + {:else if showSkeleton.value && !query?.data} + + {:else if allEmpty} +

+ No matches for '{q}'. +

+ {:else if query?.data} + {#if artists && artists.items.length > 0} +
+
+

Artists

+ {#if artists.total > artists.items.length} + + See all {artists.total} → + + {/if} +
+
+ {#each artists.items as a (a.id)} + + {/each} +
+
+ {/if} + + {#if albums && albums.items.length > 0} +
+
+

Albums

+ {#if albums.total > albums.items.length} + + See all {albums.total} → + + {/if} +
+
+ {#each albums.items as al (al.id)} + + {/each} +
+
+ {/if} + + {#if tracks && tracks.items.length > 0} +
+
+

Tracks

+ {#if tracks.total > tracks.items.length} + + See all {tracks.total} → + + {/if} +
+
+ {#each tracks.items as t, i (t.id)} + + {/each} +
+
+ {/if} + {/if} +
+``` + +- [ ] **Step 4: Run tests, confirm all pass** + +Run: `cd web && npm test -- src/routes/search/search.test.ts` +Expected: PASS — 7 tests. + +- [ ] **Step 5: Type-check** + +Run: `cd web && npm run check` +Expected: `0 ERRORS 0 WARNINGS`. + +- [ ] **Step 6: Commit** + +```bash +git add web/src/routes/search/+page.svelte web/src/routes/search/search.test.ts +git commit -m "feat(web): /search page with three sections + see-all overflow links + +Reads ?q= reactively; runs createSearchQuery (limit=10) when q is +non-empty. Renders Artists/Albums/Tracks sections reusing ArtistRow, +AlbumCard, TrackRow. Track click uses onPlay prop to call +playRadio(track.id) instead of the default playQueue. Empty facets +hide; 'See all N →' link points to overflow pages when total > items. +Empty q shows 'Start typing'; error → ApiErrorBanner; loading → +SearchSkeleton via useDelayed." +``` + +--- + +## Task 11: Three overflow pages (`/search/artists`, `/search/albums`, `/search/tracks`) + +**Files:** +- Create: `web/src/routes/search/artists/+page.svelte` +- Create: `web/src/routes/search/artists/artists.test.ts` +- Create: `web/src/routes/search/albums/+page.svelte` +- Create: `web/src/routes/search/albums/albums.test.ts` +- Create: `web/src/routes/search/tracks/+page.svelte` +- Create: `web/src/routes/search/tracks/tracks.test.ts` + +Each page is the same shape: read `?q=`, run the matching infinite query, render its facet, "Load more" → `fetchNextPage()`. Tests follow the same shape as `web/src/routes/artists.test.ts`. + +- [ ] **Step 1: Write the artists overflow tests** + +Create `web/src/routes/search/artists/artists.test.ts`: + +```ts +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/svelte'; +import { mockInfiniteQuery } from '../../../test-utils/query'; +import type { ArtistRef, Page } from '$lib/api/types'; + +const state = vi.hoisted(() => ({ pageUrl: new URL('http://localhost/search/artists?q=miles') })); + +vi.mock('$app/state', () => ({ + page: { get url() { return state.pageUrl; } } +})); + +vi.mock('$lib/api/queries', () => ({ + createSearchArtistsInfiniteQuery: vi.fn() +})); + +import ArtistsOverflow from './+page.svelte'; +import { createSearchArtistsInfiniteQuery } from '$lib/api/queries'; + +function page(items: T[], total: number, offset = 0, limit = 50): Page { + return { items, total, offset, limit }; +} + +afterEach(() => { + vi.clearAllMocks(); + state.pageUrl = new URL('http://localhost/search/artists?q=miles'); +}); + +describe('search artists overflow', () => { + test('renders a row per artist', () => { + const a1: ArtistRef = { id: 'a1', name: 'Miles Davis', album_count: 12 }; + const a2: ArtistRef = { id: 'a2', name: 'Miles Mosley', album_count: 1 }; + (createSearchArtistsInfiniteQuery as ReturnType).mockReturnValue( + mockInfiniteQuery({ pages: [page([a1, a2], 2)] }) + ); + render(ArtistsOverflow); + expect(screen.getByRole('link', { name: /Miles Davis/ })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /Miles Mosley/ })).toBeInTheDocument(); + }); + + test('Load more calls fetchNextPage when hasNextPage', async () => { + const fetchNextPage = vi.fn(); + (createSearchArtistsInfiniteQuery as ReturnType).mockReturnValue( + mockInfiniteQuery({ + pages: [page([], 100)], + hasNextPage: true, + fetchNextPage + }) + ); + render(ArtistsOverflow); + await fireEvent.click(screen.getByRole('button', { name: /load more/i })); + expect(fetchNextPage).toHaveBeenCalledTimes(1); + }); + + test('Load more is hidden when hasNextPage is false', () => { + (createSearchArtistsInfiniteQuery as ReturnType).mockReturnValue( + mockInfiniteQuery({ pages: [page([{ id: 'a', name: 'A', album_count: 1 }], 1)] }) + ); + render(ArtistsOverflow); + expect(screen.queryByRole('button', { name: /load more/i })).not.toBeInTheDocument(); + }); + + test('empty q shows a prompt and fires no query', () => { + state.pageUrl = new URL('http://localhost/search/artists'); + render(ArtistsOverflow); + expect(screen.getByText(/no query/i)).toBeInTheDocument(); + expect(createSearchArtistsInfiniteQuery).not.toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 2: Run, confirm fail** + +Run: `cd web && npm test -- 'src/routes/search/artists/artists.test.ts'` +Expected: FAIL — page doesn't exist. + +- [ ] **Step 3: Implement `web/src/routes/search/artists/+page.svelte`** + +```svelte + + +
+
+ + ← Back to search + +

Artists matching '{q}'

+ {#if !query?.isPending && !query?.isError && q} +

{total} {total === 1 ? 'artist' : 'artists'}

+ {/if} +
+ + {#if !q} +

No query — return to search to start typing.

+ {:else if query?.isError} + + {:else if showSkeleton.value && artists.length === 0} + + {:else if artists.length === 0} +

No artist matches.

+ {:else} +
+ {#each artists as a (a.id)} + + {/each} +
+ {#if query?.hasNextPage} + + {/if} + {/if} +
+``` + +- [ ] **Step 4: Run, confirm pass** + +Run: `cd web && npm test -- 'src/routes/search/artists/artists.test.ts'` +Expected: PASS — 4 tests. + +- [ ] **Step 5: Write the albums overflow tests** + +Create `web/src/routes/search/albums/albums.test.ts`: + +```ts +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/svelte'; +import { mockInfiniteQuery } from '../../../test-utils/query'; +import type { AlbumRef, Page } from '$lib/api/types'; + +const state = vi.hoisted(() => ({ pageUrl: new URL('http://localhost/search/albums?q=miles') })); + +vi.mock('$app/state', () => ({ + page: { get url() { return state.pageUrl; } } +})); + +vi.mock('$lib/api/queries', () => ({ + createSearchAlbumsInfiniteQuery: vi.fn() +})); + +vi.mock('$lib/api/client', () => ({ + api: { get: vi.fn() } +})); +vi.mock('$lib/player/store.svelte', () => ({ + enqueueTracks: vi.fn() +})); + +import AlbumsOverflow from './+page.svelte'; +import { createSearchAlbumsInfiniteQuery } from '$lib/api/queries'; + +function page(items: T[], total: number, offset = 0, limit = 50): Page { + return { items, total, offset, limit }; +} + +const album: AlbumRef = { + id: 'al1', title: 'Kind of Blue', artist_id: 'a1', artist_name: 'Miles Davis', + year: 1959, track_count: 5, duration_sec: 2630, cover_url: '/api/albums/al1/cover' +}; + +afterEach(() => { + vi.clearAllMocks(); + state.pageUrl = new URL('http://localhost/search/albums?q=miles'); +}); + +describe('search albums overflow', () => { + test('renders an AlbumCard per album', () => { + (createSearchAlbumsInfiniteQuery as ReturnType).mockReturnValue( + mockInfiniteQuery({ pages: [page([album], 1)] }) + ); + render(AlbumsOverflow); + expect(screen.getByRole('link', { name: /Kind of Blue/ })).toHaveAttribute('href', '/albums/al1'); + }); + + test('Load more calls fetchNextPage', async () => { + const fetchNextPage = vi.fn(); + (createSearchAlbumsInfiniteQuery as ReturnType).mockReturnValue( + mockInfiniteQuery({ + pages: [page([], 100)], + hasNextPage: true, + fetchNextPage + }) + ); + render(AlbumsOverflow); + await fireEvent.click(screen.getByRole('button', { name: /load more/i })); + expect(fetchNextPage).toHaveBeenCalledTimes(1); + }); + + test('empty q shows a prompt', () => { + state.pageUrl = new URL('http://localhost/search/albums'); + render(AlbumsOverflow); + expect(screen.getByText(/no query/i)).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 6: Run, confirm fail** + +Run: `cd web && npm test -- 'src/routes/search/albums/albums.test.ts'` +Expected: FAIL. + +- [ ] **Step 7: Implement `web/src/routes/search/albums/+page.svelte`** + +```svelte + + +
+
+ + ← Back to search + +

Albums matching '{q}'

+ {#if !query?.isPending && !query?.isError && q} +

{total} {total === 1 ? 'album' : 'albums'}

+ {/if} +
+ + {#if !q} +

No query — return to search to start typing.

+ {:else if query?.isError} + + {:else if showSkeleton.value && albums.length === 0} + + {:else if albums.length === 0} +

No album matches.

+ {:else} +
+ {#each albums as a (a.id)} + + {/each} +
+ {#if query?.hasNextPage} + + {/if} + {/if} +
+``` + +- [ ] **Step 8: Run, confirm pass** + +Run: `cd web && npm test -- 'src/routes/search/albums/albums.test.ts'` +Expected: PASS — 3 tests. + +- [ ] **Step 9: Write the tracks overflow tests** + +Create `web/src/routes/search/tracks/tracks.test.ts`: + +```ts +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/svelte'; +import { mockInfiniteQuery } from '../../../test-utils/query'; +import type { TrackRef, Page } from '$lib/api/types'; + +const state = vi.hoisted(() => ({ pageUrl: new URL('http://localhost/search/tracks?q=miles') })); + +vi.mock('$app/state', () => ({ + page: { get url() { return state.pageUrl; } } +})); + +vi.mock('$lib/api/queries', () => ({ + createSearchTracksInfiniteQuery: vi.fn() +})); + +vi.mock('$lib/player/store.svelte', () => ({ + playQueue: vi.fn(), + playRadio: vi.fn(), + enqueueTrack: vi.fn() +})); + +import TracksOverflow from './+page.svelte'; +import { createSearchTracksInfiniteQuery } from '$lib/api/queries'; +import { playRadio } from '$lib/player/store.svelte'; + +function page(items: T[], total: number, offset = 0, limit = 50): Page { + return { items, total, offset, limit }; +} + +const track: TrackRef = { + id: 't1', title: 'So What', + album_id: 'al1', album_title: 'Kind of Blue', + artist_id: 'a1', artist_name: 'Miles Davis', + track_number: 1, disc_number: 1, duration_sec: 545, + stream_url: '/api/tracks/t1/stream' +}; + +afterEach(() => { + vi.clearAllMocks(); + state.pageUrl = new URL('http://localhost/search/tracks?q=miles'); +}); + +describe('search tracks overflow', () => { + test('renders a TrackRow per track', () => { + (createSearchTracksInfiniteQuery as ReturnType).mockReturnValue( + mockInfiniteQuery({ pages: [page([track], 1)] }) + ); + render(TracksOverflow); + expect(screen.getByRole('button', { name: /So What/ })).toBeInTheDocument(); + }); + + test('clicking a track row triggers playRadio with the track id', async () => { + (createSearchTracksInfiniteQuery as ReturnType).mockReturnValue( + mockInfiniteQuery({ pages: [page([track], 1)] }) + ); + render(TracksOverflow); + await fireEvent.click(screen.getByRole('button', { name: /So What/ })); + expect(playRadio).toHaveBeenCalledWith('t1'); + }); + + test('Load more calls fetchNextPage', async () => { + const fetchNextPage = vi.fn(); + (createSearchTracksInfiniteQuery as ReturnType).mockReturnValue( + mockInfiniteQuery({ + pages: [page([], 100)], + hasNextPage: true, + fetchNextPage + }) + ); + render(TracksOverflow); + await fireEvent.click(screen.getByRole('button', { name: /load more/i })); + expect(fetchNextPage).toHaveBeenCalledTimes(1); + }); + + test('empty q shows a prompt', () => { + state.pageUrl = new URL('http://localhost/search/tracks'); + render(TracksOverflow); + expect(screen.getByText(/no query/i)).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 10: Run, confirm fail** + +Run: `cd web && npm test -- 'src/routes/search/tracks/tracks.test.ts'` +Expected: FAIL. + +- [ ] **Step 11: Implement `web/src/routes/search/tracks/+page.svelte`** + +```svelte + + +
+
+ + ← Back to search + +

Tracks matching '{q}'

+ {#if !query?.isPending && !query?.isError && q} +

{total} {total === 1 ? 'track' : 'tracks'}

+ {/if} +
+ + {#if !q} +

No query — return to search to start typing.

+ {:else if query?.isError} + + {:else if showSkeleton.value && tracks.length === 0} + + {:else if tracks.length === 0} +

No track matches.

+ {:else} +
+ {#each tracks as t, i (t.id)} + + {/each} +
+ {#if query?.hasNextPage} + + {/if} + {/if} +
+``` + +- [ ] **Step 12: Run, confirm pass** + +Run: `cd web && npm test -- 'src/routes/search/tracks/tracks.test.ts'` +Expected: PASS — 4 tests. + +- [ ] **Step 13: Type-check** + +Run: `cd web && npm run check` +Expected: `0 ERRORS 0 WARNINGS`. + +- [ ] **Step 14: Commit** + +```bash +git add web/src/routes/search/artists web/src/routes/search/albums web/src/routes/search/tracks +git commit -m "feat(web): add three search overflow pages + +/search/artists, /search/albums, /search/tracks each read ?q= and run +their own createSearchInfiniteQuery (limit=50). Load more pulls the +next offset; back link returns to /search?q=. Empty q shows a 'no +query' prompt and fires no request. Tracks overflow uses the onPlay +prop on TrackRow to call playRadio(track.id)." +``` + +--- + +## Task 12: Final verification + branch finish + +**Files:** none (verification only). + +- [ ] **Step 1: Full Go suite** + +Run: `go test -short -race ./...` +Expected: PASS. + +- [ ] **Step 2: Go linter** + +Run: `golangci-lint run ./...` +Expected: clean. + +- [ ] **Step 3: Web check + full tests + build** + +Run: `cd web && npm run check && npm test && npm run build` +Expected: check 0/0; all vitest files pass; build emits `web/build/`. + +Run: `git checkout -- web/build/index.html` +Expected: committed placeholder restored. + +- [ ] **Step 4: Docker build smoke** + +Run: `docker build -t minstrel:search-smoke .` +Expected: image builds. + +Run: `docker run --rm --entrypoint /bin/sh minstrel:search-smoke -c 'test -x /usr/local/bin/minstrel && echo ok'` +Expected: `ok`. + +- [ ] **Step 5: Local end-to-end manual check** + +Bring up the stack: + +```bash +docker compose up --build -d +``` + +In a browser at `localhost:4533`: + +1. Sign in. Header now has a search box between brand and the user-menu. +2. From the home page, type `mil` (or any string with multiple library matches). After ~250ms the URL becomes `/search?q=mil` and three sections appear (or fewer if some facets match nothing). +3. Empty matches: type `zzzqqq` — page shows "No matches for 'zzzqqq'". +4. Backspace through the input until empty: URL drops `?q=`; main `/search` page shows "Start typing". +5. Click the `+` on a track result → bottom-bar player updates queue length but does NOT auto-play (or, if a track was already playing, current playback continues). +6. Click a track result (the row, not the `+`) → radio plays (one-track queue, since it's the M6 stub). +7. Click the `+` on an album card → all of that album's tracks are appended; current playback continues. +8. Click "See all 47 artists →" → overflow page loads; "Load more" pulls the next page; back link returns to `/search?q=…`. +9. Click an artist on `/search?q=…` → navigates to `/artists/:id`. The header search box still shows "mil". +10. Browser back → returns to `/search?q=mil` with cached results. +11. Press Escape with the input focused → input clears and URL clears. + +Tear down: + +```bash +docker compose down +``` + +- [ ] **Step 6: Finish the branch** + +Follow `superpowers:finishing-a-development-branch`. Expected path: Option 2 — push `dev`, open a PR to `main`, wait for Forgejo CI, merge once green. + +--- + +## Self-Review Notes + +**Spec coverage:** +- Goal / non-goals → Task structure honors the non-goals (no autocomplete, no Cmd-K, no recent searches). +- Header search bar → Task 7 (component) + Task 9 (Shell wiring). +- Live debounced URL sync → Task 7 with debounce/replaceState/Escape behavior covered by tests. +- `/search` page with three sections + see-all links → Task 10. +- Three overflow pages → Task 11. +- `/api/radio` stub → Task 1 (Go handler + 5 tests covering 200/404/missing/blank/bad-uuid). +- `enqueueTrack` / `enqueueTracks` / `playRadio` → Task 3. +- TrackRow `
` refactor + `onPlay` + `+ queue` → Task 5. +- AlbumCard `+ queue` → Task 6. +- States (empty/loading/error/no-results) → covered in Tasks 10 and 11. +- Component tests → each task has its own. Manual end-to-end → Task 12 Step 5. +- Risks → addressed in spec; the TrackRow refactor risk is mitigated by re-running the album-page tests in Task 5 Step 5. + +**Type consistency:** +- `SearchResponse`, `RadioResponse`, `Page`, `ArtistRef`, `AlbumRef`, `TrackRef`, `AlbumDetail` — defined once in `types.ts`; consumed identically in queries, store, and pages. +- `playRadio(seedTrackId: string)` — same signature in spec, store, and call sites. +- `enqueueTrack(t: TrackRef)`, `enqueueTracks(ts: TrackRef[])` — consistent. +- `qk.search`, `qk.searchArtists`, `qk.searchAlbums`, `qk.searchTracks` — consistent across queries.ts and tests. +- `onPlay?: (tracks: TrackRef[], index: number) => void` — same shape in `TrackRow.svelte`, search page, and tracks overflow page. +- `SEARCH_SUMMARY_LIMIT = 10` and `SEARCH_FACET_PAGE_SIZE = 50` — used only inside `queries.ts`; not surfaced to UI tests. + +**Filename hazards:** +- No `+`-prefixed test files under route dirs. Search route tests use plain names: `search.test.ts`, `artists.test.ts`, `albums.test.ts`, `tracks.test.ts`. +- `SearchInput.test.ts` is plain `.test.ts` because the test body itself uses no rune syntax (only the imported component does); `.svelte.test.ts` is reserved for tests like `useDelayed` where the test body declares `$state`/`$effect.root` directly. + +**Placeholder scan:** no TBD/TODO/later markers. Every code block is complete; every command has expected output. + +**TrackRow refactor risk:** acknowledged; Task 5 re-runs the album page tests as Step 5 to catch any regression before commit.