# 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 `<div role="button">` so a real `<button>` can nest for `+ queue`.

**Tech Stack:** Go 1.23 + chi + sqlc on the server. SvelteKit 2 + Svelte 5 (runes) + TypeScript + TanStack Query (Svelte adapter) + Vitest + Testing Library + Tailwind on the client.

**Reference:** design spec at `docs/superpowers/specs/2026-04-25-web-ui-search-design.md`.

---

## File Structure

**New files:**

| File | Responsibility |
|---|---|
| `internal/api/radio.go` | `handleRadio` stub returning `{ tracks: [seed_track] }` |
| `internal/api/radio_test.go` | 200 / 404 / 400 cases for the stub |
| `web/src/lib/components/SearchInput.svelte` | Header search box with debounced URL sync |
| `web/src/lib/components/SearchInput.test.ts` | Debounce / pre-fill / clear tests |
| `web/src/lib/components/SearchSkeleton.svelte` | Loading placeholder for `/search` |
| `web/src/routes/search/+page.svelte` | Main results page (3 sections, top-10 each) |
| `web/src/routes/search/search.test.ts` | Page integration tests |
| `web/src/routes/search/artists/+page.svelte` | Artists overflow with infinite query |
| `web/src/routes/search/artists/artists.test.ts` | Overflow tests |
| `web/src/routes/search/albums/+page.svelte` | Albums overflow |
| `web/src/routes/search/albums/albums.test.ts` | Overflow tests |
| `web/src/routes/search/tracks/+page.svelte` | Tracks overflow |
| `web/src/routes/search/tracks/tracks.test.ts` | Overflow tests |

**Modified files:**

| File | Change |
|---|---|
| `internal/api/api.go` | Register `r.Get("/api/radio", h.handleRadio)` inside the authed group |
| `web/src/lib/api/types.ts` | Add `SearchResponse`, `RadioResponse` |
| `web/src/lib/api/queries.ts` | Add `qk.search*`, `createSearchQuery`, three infinite-query helpers |
| `web/src/lib/api/queries.test.ts` | Cover the new `qk.search*` keys |
| `web/src/lib/player/store.svelte.ts` | Add `enqueueTrack`, `enqueueTracks`, `playRadio` |
| `web/src/lib/player/store.test.ts` | Cover the three new actions |
| `web/src/lib/components/TrackRow.svelte` | Refactor outer `<button>` → `<div role="button">`, add optional `onPlay` prop, add `+ queue` button |
| `web/src/lib/components/TrackRow.test.ts` | Update for refactor; add new tests |
| `web/src/lib/components/AlbumCard.svelte` | Add `+ queue` button overlaid on the link |
| `web/src/lib/components/AlbumCard.test.ts` | Add `+ queue` click test |
| `web/src/lib/components/Shell.svelte` | Insert `<SearchInput />` between brand and user menu |

---

## Task 1: `/api/radio` stub (server)

**Files:**
- Create: `internal/api/radio.go`
- Create: `internal/api/radio_test.go`
- Modify: `internal/api/api.go`

This task ships the server-side `/api/radio?seed_track=<uuid>` endpoint as a stub: validates the param, looks up the track, returns `{ tracks: [<seed_ref>] }` using the existing `trackRefFrom` + parent-album/artist-name resolve pattern from `handleGetTrack`.

- [ ] **Step 1: Write failing tests**

Create `internal/api/radio_test.go`:

```go
package api

import (
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"testing"

	"github.com/go-chi/chi/v5"
)

func newRadioRouter(h *handlers) chi.Router {
	r := chi.NewRouter()
	r.Get("/api/radio", h.handleRadio)
	return r
}

func TestHandleRadio_Stub_ReturnsSeedOnly(t *testing.T) {
	h, pool := testHandlers(t)
	truncateLibrary(t, pool)
	artist := seedArtist(t, pool, "Beatles")
	album := seedAlbum(t, pool, artist.ID, "Abbey Road", 1969)
	track := seedTrack(t, pool, album.ID, artist.ID, "Something", 3, 183_000)

	req := httptest.NewRequest(http.MethodGet, "/api/radio?seed_track="+uuidToString(track.ID), nil)
	w := httptest.NewRecorder()
	newRadioRouter(h).ServeHTTP(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
	}
	var got struct {
		Tracks []TrackRef `json:"tracks"`
	}
	if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
		t.Fatalf("decode: %v", err)
	}
	if len(got.Tracks) != 1 {
		t.Fatalf("len=%d, want 1", len(got.Tracks))
	}
	if got.Tracks[0].Title != "Something" {
		t.Errorf("title=%q", got.Tracks[0].Title)
	}
	if got.Tracks[0].AlbumTitle != "Abbey Road" || got.Tracks[0].ArtistName != "Beatles" {
		t.Errorf("ref = %+v", got.Tracks[0])
	}
}

func TestHandleRadio_NotFound(t *testing.T) {
	h, pool := testHandlers(t)
	truncateLibrary(t, pool)

	req := httptest.NewRequest(http.MethodGet, "/api/radio?seed_track=00000000-0000-0000-0000-000000000000", nil)
	w := httptest.NewRecorder()
	newRadioRouter(h).ServeHTTP(w, req)

	if w.Code != http.StatusNotFound {
		t.Fatalf("status = %d", w.Code)
	}
}

func TestHandleRadio_MissingSeed(t *testing.T) {
	h, _ := testHandlers(t)
	req := httptest.NewRequest(http.MethodGet, "/api/radio", nil)
	w := httptest.NewRecorder()
	newRadioRouter(h).ServeHTTP(w, req)
	if w.Code != http.StatusBadRequest {
		t.Errorf("status=%d, want 400", w.Code)
	}
}

func TestHandleRadio_BlankSeed(t *testing.T) {
	h, _ := testHandlers(t)
	req := httptest.NewRequest(http.MethodGet, "/api/radio?seed_track=%20%20", nil)
	w := httptest.NewRecorder()
	newRadioRouter(h).ServeHTTP(w, req)
	if w.Code != http.StatusBadRequest {
		t.Errorf("status=%d, want 400", w.Code)
	}
}

func TestHandleRadio_BadUUID(t *testing.T) {
	h, _ := testHandlers(t)
	req := httptest.NewRequest(http.MethodGet, "/api/radio?seed_track=not-a-uuid", nil)
	w := httptest.NewRecorder()
	newRadioRouter(h).ServeHTTP(w, req)
	if w.Code != http.StatusBadRequest {
		t.Errorf("status=%d, want 400", w.Code)
	}
}
```

- [ ] **Step 2: Run tests, confirm they fail**

Run: `go test ./internal/api -run TestHandleRadio -v`
Expected: FAIL — `h.handleRadio undefined`.

- [ ] **Step 3: Implement `internal/api/radio.go`**

```go
package api

import (
	"errors"
	"net/http"
	"strings"

	"github.com/jackc/pgx/v5"

	"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)

// RadioResponse is the body of GET /api/radio.
type RadioResponse struct {
	Tracks []TrackRef `json:"tracks"`
}

// handleRadio implements GET /api/radio?seed_track=<uuid>.
//
// M6 stub: returns a queue containing only the seed track. The full M4
// implementation (similarity-based candidate pool + scoring) replaces the
// body without changing the request/response shape.
func (h *handlers) handleRadio(w http.ResponseWriter, r *http.Request) {
	raw := strings.TrimSpace(r.URL.Query().Get("seed_track"))
	if raw == "" {
		writeErr(w, http.StatusBadRequest, "bad_request", "seed_track is required")
		return
	}
	id, ok := parseUUID(raw)
	if !ok {
		writeErr(w, http.StatusBadRequest, "bad_request", "invalid seed_track id")
		return
	}
	q := dbq.New(h.pool)
	track, err := q.GetTrackByID(r.Context(), id)
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			writeErr(w, http.StatusNotFound, "not_found", "seed_track not found")
			return
		}
		h.logger.Error("api: get radio seed track failed", "err", err)
		writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
		return
	}
	album, err := q.GetAlbumByID(r.Context(), track.AlbumID)
	if err != nil {
		h.logger.Error("api: get radio seed album failed", "err", err)
		writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
		return
	}
	artist, err := q.GetArtistByID(r.Context(), track.ArtistID)
	if err != nil {
		h.logger.Error("api: get radio seed artist failed", "err", err)
		writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
		return
	}
	writeJSON(w, http.StatusOK, RadioResponse{
		Tracks: []TrackRef{trackRefFrom(track, album.Title, artist.Name)},
	})
}
```

- [ ] **Step 4: Register the route in `internal/api/api.go`**

Find the authed group and add `/radio` next to `/search`:

```go
authed.Get("/search", h.handleSearch)
authed.Get("/radio", h.handleRadio)
```

- [ ] **Step 5: Run tests, confirm pass**

Run: `go test ./internal/api -run TestHandleRadio -v`
Expected: PASS — 5 tests.

- [ ] **Step 6: Lint + full server tests**

Run: `golangci-lint run ./...`
Expected: clean.
Run: `go test -short -race ./...`
Expected: all packages PASS.

- [ ] **Step 7: Commit**

```bash
git add internal/api/radio.go internal/api/radio_test.go internal/api/api.go
git commit -m "feat(api): add /api/radio stub returning the seed track only

M6 stub. Validates seed_track param (400 on missing/blank/bad UUID),
looks up the track (404 on miss), returns RadioResponse with a single
TrackRef. M4 will replace the body with similarity-driven candidate
pool + scoring; the request/response shape is final."
```

---

## Task 2: Web types — `SearchResponse`, `RadioResponse`

**Files:**
- Modify: `web/src/lib/api/types.ts`

Foundation for everything downstream. No tests — pure type declarations.

- [ ] **Step 1: Open `web/src/lib/api/types.ts` and append the new types**

After the existing `AlbumDetail` declaration:

```ts
export type SearchResponse = {
  artists: Page<ArtistRef>;
  albums:  Page<AlbumRef>;
  tracks:  Page<TrackRef>;
};

export type RadioResponse = {
  tracks: TrackRef[];
};
```

- [ ] **Step 2: Type-check**

Run: `cd web && npm run check`
Expected: `0 ERRORS 0 WARNINGS`.

- [ ] **Step 3: Commit**

```bash
git add web/src/lib/api/types.ts
git commit -m "feat(web): add SearchResponse and RadioResponse types"
```

---

## Task 3: Player store — `enqueueTrack`, `enqueueTracks`, `playRadio`

**Files:**
- Modify: `web/src/lib/player/store.svelte.ts`
- Modify: `web/src/lib/player/store.test.ts`

Adds three new exports. Existing 24 tests must keep passing.

- [ ] **Step 1: Append failing tests to `web/src/lib/player/store.test.ts`**

At the bottom of the file, append:

```ts
import { enqueueTrack, enqueueTracks, playRadio } from './store.svelte';
import * as client from '$lib/api/client';

describe('player store — enqueue + playRadio', () => {
  test('enqueueTrack appends to a non-empty queue, _index unchanged', () => {
    playQueue([track('1'), track('2')], 1);
    enqueueTrack(track('3'));
    expect(player.queue.length).toBe(3);
    expect(player.queue[2].id).toBe('3');
    expect(player.index).toBe(1);
  });

  test('enqueueTrack on idle (empty queue) sets index=0 and _state stays idle', () => {
    playQueue([]);                  // explicit reset
    expect(player.state).toBe('idle');
    enqueueTrack(track('1'));
    expect(player.queue.length).toBe(1);
    expect(player.index).toBe(0);
    expect(player.state).toBe('idle'); // append must not auto-play
  });

  test('enqueueTracks([]) is a no-op', () => {
    playQueue([track('1')]);
    enqueueTracks([]);
    expect(player.queue.length).toBe(1);
  });

  test('enqueueTracks concatenates non-empty list', () => {
    playQueue([track('1')]);
    enqueueTracks([track('2'), track('3')]);
    expect(player.queue.map((t) => t.id)).toEqual(['1', '2', '3']);
  });

  test('playRadio fetches /api/radio and feeds tracks into playQueue', async () => {
    const apiGet = vi.spyOn(client.api, 'get').mockResolvedValueOnce({
      tracks: [track('seed'), track('next')]
    });
    await playRadio('seed');
    expect(apiGet).toHaveBeenCalledWith('/api/radio?seed_track=seed');
    expect(player.queue.length).toBe(2);
    expect(player.index).toBe(0);
    expect(player.state).toBe('loading'); // playQueue sets state='loading'
    apiGet.mockRestore();
  });

  test('playRadio with empty response leaves queue alone', async () => {
    playQueue([track('a')]);
    const apiGet = vi.spyOn(client.api, 'get').mockResolvedValueOnce({ tracks: [] });
    await playRadio('seed');
    expect(player.queue.map((t) => t.id)).toEqual(['a']);
    apiGet.mockRestore();
  });
});
```

- [ ] **Step 2: Run tests, confirm new ones fail**

Run: `cd web && npm test -- src/lib/player/store.test.ts`
Expected: FAIL — `enqueueTrack`, `enqueueTracks`, `playRadio` not exported. The 24 existing tests still pass.

- [ ] **Step 3: Append the new actions to `web/src/lib/player/store.svelte.ts`**

At the bottom, after `reportStateFromAudio`:

```ts
import { api } from '$lib/api/client';
import type { RadioResponse } from '$lib/api/types';

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);
}
```

Note: hoist the `import { api } from '$lib/api/client'` and `import type { RadioResponse } from '$lib/api/types'` to the top of the file alongside the existing imports.

- [ ] **Step 4: Run tests, confirm all pass**

Run: `cd web && npm test -- src/lib/player/store.test.ts`
Expected: PASS — 30 tests (24 existing + 6 new).

- [ ] **Step 5: Type-check**

Run: `cd web && npm run check`
Expected: `0 ERRORS 0 WARNINGS`.

- [ ] **Step 6: Commit**

```bash
git add web/src/lib/player/store.svelte.ts web/src/lib/player/store.test.ts
git commit -m "feat(web): add enqueueTrack, enqueueTracks, playRadio to player store

enqueueTrack/enqueueTracks append to the queue without disturbing
playback state (no auto-play on append, no state mutation when queue
was non-empty). playRadio fetches /api/radio?seed_track=<id> and
feeds the response into playQueue. Empty response leaves queue alone."
```

---

## Task 4: Query helpers — `createSearchQuery` + three infinite queries

**Files:**
- Modify: `web/src/lib/api/queries.ts`
- Modify: `web/src/lib/api/queries.test.ts`

Adds `qk.search*` keys and four query factories. Tests cover only the keys (TanStack's own behavior is already trusted).

- [ ] **Step 1: Append failing tests to `web/src/lib/api/queries.test.ts`**

```ts
import { qk } from './queries';

describe('qk search keys', () => {
  test('search summary key includes q', () => {
    expect(qk.search('foo')).toEqual(['search', { q: 'foo' }]);
  });
  test('searchArtists key includes q', () => {
    expect(qk.searchArtists('foo')).toEqual(['searchArtists', { q: 'foo' }]);
  });
  test('searchAlbums key includes q', () => {
    expect(qk.searchAlbums('foo')).toEqual(['searchAlbums', { q: 'foo' }]);
  });
  test('searchTracks key includes q', () => {
    expect(qk.searchTracks('foo')).toEqual(['searchTracks', { q: 'foo' }]);
  });
});
```

- [ ] **Step 2: Run tests, confirm new ones fail**

Run: `cd web && npm test -- src/lib/api/queries.test.ts`
Expected: FAIL — `qk.search` undefined.

- [ ] **Step 3: Extend `web/src/lib/api/queries.ts`**

Append these imports + symbols:

```ts
import type { SearchResponse } from './types';

export const SEARCH_SUMMARY_LIMIT = 10;
export const SEARCH_FACET_PAGE_SIZE = 50;
```

Extend the `qk` object with the four new keys (place them after the existing entries):

```ts
export const qk = {
  artists: (sort: ArtistSort) => ['artists', { sort }] as const,
  artist:  (id: string)       => ['artist', id] as const,
  album:   (id: string)       => ['album', id] as const,
  search:        (q: string) => ['search', { q }] as const,
  searchArtists: (q: string) => ['searchArtists', { q }] as const,
  searchAlbums:  (q: string) => ['searchAlbums',  { q }] as const,
  searchTracks:  (q: string) => ['searchTracks',  { q }] as const,
};
```

Append the query factories after `createAlbumQuery`:

```ts
export function createSearchQuery(q: string) {
  return createQuery({
    queryKey: qk.search(q),
    queryFn: () =>
      api.get<SearchResponse>(
        `/api/search?q=${encodeURIComponent(q)}&limit=${SEARCH_SUMMARY_LIMIT}`
      ),
    enabled: q.length > 0
  });
}

export function createSearchArtistsInfiniteQuery(q: string) {
  return createInfiniteQuery({
    queryKey: qk.searchArtists(q),
    queryFn: ({ pageParam = 0 }) =>
      api.get<SearchResponse>(
        `/api/search?q=${encodeURIComponent(q)}&limit=${SEARCH_FACET_PAGE_SIZE}&offset=${pageParam}`
      ).then((r) => r.artists),
    initialPageParam: 0,
    getNextPageParam: (last) => {
      const loaded = last.offset + last.items.length;
      return loaded >= last.total ? undefined : loaded;
    },
    enabled: q.length > 0
  });
}

export function createSearchAlbumsInfiniteQuery(q: string) {
  return createInfiniteQuery({
    queryKey: qk.searchAlbums(q),
    queryFn: ({ pageParam = 0 }) =>
      api.get<SearchResponse>(
        `/api/search?q=${encodeURIComponent(q)}&limit=${SEARCH_FACET_PAGE_SIZE}&offset=${pageParam}`
      ).then((r) => r.albums),
    initialPageParam: 0,
    getNextPageParam: (last) => {
      const loaded = last.offset + last.items.length;
      return loaded >= last.total ? undefined : loaded;
    },
    enabled: q.length > 0
  });
}

export function createSearchTracksInfiniteQuery(q: string) {
  return createInfiniteQuery({
    queryKey: qk.searchTracks(q),
    queryFn: ({ pageParam = 0 }) =>
      api.get<SearchResponse>(
        `/api/search?q=${encodeURIComponent(q)}&limit=${SEARCH_FACET_PAGE_SIZE}&offset=${pageParam}`
      ).then((r) => r.tracks),
    initialPageParam: 0,
    getNextPageParam: (last) => {
      const loaded = last.offset + last.items.length;
      return loaded >= last.total ? undefined : loaded;
    },
    enabled: q.length > 0
  });
}
```

- [ ] **Step 4: Run tests, confirm all pass**

Run: `cd web && npm test -- src/lib/api/queries.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/api/queries.ts web/src/lib/api/queries.test.ts
git commit -m "feat(web): add search query helpers (summary + 3 facet infinite queries)

createSearchQuery hits /api/search with limit=10 (the main /search
page summary). createSearchArtistsInfiniteQuery /
createSearchAlbumsInfiniteQuery / createSearchTracksInfiniteQuery each
hit /api/search with limit=50 and project to their own facet, used by
the per-facet overflow pages. All four are gated by q.length > 0 so
empty queries don't fire."
```

---

## Task 5: `TrackRow` refactor — `<div role="button">` + `onPlay` + `+ queue`

**Files:**
- Modify: `web/src/lib/components/TrackRow.svelte`
- Modify: `web/src/lib/components/TrackRow.test.ts`

Two structural changes:
1. Outer element becomes `<div role="button" tabindex="0">` so a real `<button>` for `+ queue` can nest without invalid HTML.
2. Optional `onPlay?: (tracks, index) => void` prop overrides the default `playQueue(tracks, index)` behavior. Used by the search Tracks section to call `playRadio(track.id)` instead.

Existing album-page tests must keep passing.

- [ ] **Step 1: Replace `web/src/lib/components/TrackRow.test.ts`**

```ts
import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import type { TrackRef } from '$lib/api/types';

vi.mock('$lib/player/store.svelte', () => ({
  playQueue: vi.fn(),
  enqueueTrack: vi.fn()
}));

import TrackRow from './TrackRow.svelte';
import { playQueue, enqueueTrack } from '$lib/player/store.svelte';

const tracks: TrackRef[] = [
  {
    id: 't1', title: 'So What',
    album_id: 'xyz', album_title: 'Kind of Blue',
    artist_id: 'm-davis', artist_name: 'Miles Davis',
    track_number: 1, disc_number: 1,
    duration_sec: 545, stream_url: '/api/tracks/t1/stream'
  },
  {
    id: 't2', title: 'Freddie Freeloader',
    album_id: 'xyz', album_title: 'Kind of Blue',
    artist_id: 'm-davis', artist_name: 'Miles Davis',
    track_number: 2, disc_number: 1,
    duration_sec: 565, stream_url: '/api/tracks/t2/stream'
  }
];

afterEach(() => vi.clearAllMocks());

describe('TrackRow', () => {
  test('renders track number, title, formatted duration', () => {
    render(TrackRow, { props: { tracks, index: 0 } });
    expect(screen.getByText('1')).toBeInTheDocument();
    expect(screen.getByText('So What')).toBeInTheDocument();
    expect(screen.getByText('9:05')).toBeInTheDocument();
  });

  test('track number falls back to dash when missing', () => {
    const noNum = [{ ...tracks[0], track_number: undefined }];
    render(TrackRow, { props: { tracks: noNum, index: 0 } });
    expect(screen.getByText('—')).toBeInTheDocument();
  });

  test('clicking the row calls playQueue(tracks, index)', async () => {
    render(TrackRow, { props: { tracks, index: 1 } });
    // The row is a div with role="button"; the +queue is a real <button>.
    // Pick by name: the row's accessible name is the track title.
    await fireEvent.click(screen.getByRole('button', { name: /Freddie Freeloader/ }));
    expect(playQueue).toHaveBeenCalledWith(tracks, 1);
  });

  test('Enter on the row activates play', async () => {
    render(TrackRow, { props: { tracks, index: 0 } });
    await fireEvent.keyDown(screen.getByRole('button', { name: /So What/ }), { key: 'Enter' });
    expect(playQueue).toHaveBeenCalledWith(tracks, 0);
  });

  test('Space on the row activates play', async () => {
    render(TrackRow, { props: { tracks, index: 0 } });
    await fireEvent.keyDown(screen.getByRole('button', { name: /So What/ }), { key: ' ' });
    expect(playQueue).toHaveBeenCalledWith(tracks, 0);
  });

  test('onPlay prop overrides default playQueue', async () => {
    const onPlay = vi.fn();
    render(TrackRow, { props: { tracks, index: 1, onPlay } });
    await fireEvent.click(screen.getByRole('button', { name: /Freddie Freeloader/ }));
    expect(onPlay).toHaveBeenCalledWith(tracks, 1);
    expect(playQueue).not.toHaveBeenCalled();
  });

  test('+ queue button calls enqueueTrack and does NOT trigger row play', async () => {
    render(TrackRow, { props: { tracks, index: 0 } });
    await fireEvent.click(screen.getByRole('button', { name: /add to queue/i }));
    expect(enqueueTrack).toHaveBeenCalledWith(tracks[0]);
    expect(playQueue).not.toHaveBeenCalled();
  });
});
```

- [ ] **Step 2: Run tests, confirm they fail**

Run: `cd web && npm test -- src/lib/components/TrackRow.test.ts`
Expected: FAIL — TrackRow still uses `<button>`, has no `+ queue`, no `onPlay` prop.

- [ ] **Step 3: Replace `web/src/lib/components/TrackRow.svelte`**

```svelte
<script lang="ts">
  import type { TrackRef } from '$lib/api/types';
  import { formatDuration } from '$lib/media/duration';
  import { playQueue, enqueueTrack } from '$lib/player/store.svelte';

  type PlayHandler = (tracks: TrackRef[], index: number) => void;

  let {
    tracks,
    index,
    onPlay
  }: { tracks: TrackRef[]; index: number; onPlay?: PlayHandler } = $props();

  const track = $derived(tracks[index]);

  function activate() {
    if (onPlay) onPlay(tracks, index);
    else playQueue(tracks, index);
  }

  function onRowClick() {
    activate();
  }

  function onRowKey(e: KeyboardEvent) {
    if (e.key === 'Enter' || e.key === ' ') {
      e.preventDefault();
      activate();
    }
  }

  function onAddClick(e: MouseEvent) {
    e.stopPropagation();
    enqueueTrack(track);
  }
</script>

<div
  role="button"
  tabindex="0"
  aria-label={track.title}
  onclick={onRowClick}
  onkeydown={onRowKey}
  class="grid w-full cursor-pointer grid-cols-[32px_1fr_auto_auto] items-center gap-4 px-3 py-2 text-left text-sm odd:bg-surface/50 hover:bg-surface focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
>
  <span class="text-right tabular-nums text-text-secondary">
    {track.track_number ?? '—'}
  </span>
  <span class="truncate">{track.title}</span>
  <button
    type="button"
    aria-label="Add to queue"
    onclick={onAddClick}
    class="rounded p-1 text-text-secondary hover:text-text-primary"
  >+</button>
  <span class="tabular-nums text-text-secondary">{formatDuration(track.duration_sec)}</span>
</div>
```

- [ ] **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 <button> becomes <div role=\"button\" tabindex=\"0\"> with
keyboard handlers (Enter/Space) so a real <button> for + queue can
nest without invalid HTML. New optional onPlay prop overrides the
default playQueue(tracks, index); used by the search Tracks section
to call playRadio(track.id) instead. + queue button calls
enqueueTrack and stops propagation so the row click doesn't fire."
```

---

## Task 6: `AlbumCard` — `+ queue` overlay button

**Files:**
- Modify: `web/src/lib/components/AlbumCard.svelte`
- Modify: `web/src/lib/components/AlbumCard.test.ts`

The card's primary `<a>` link to `/albums/:id` stays. A `+` button is positioned absolutely over the cover; on click it does a one-shot `api.get<AlbumDetail>(...)` and calls `enqueueTracks(detail.tracks)`. `event.preventDefault()` + `event.stopPropagation()` keep navigation from firing.

- [ ] **Step 1: Append the new test to `web/src/lib/components/AlbumCard.test.ts`**

Replace the file contents:

```ts
import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import type { AlbumRef, AlbumDetail, TrackRef } from '$lib/api/types';
import { FALLBACK_COVER } from '$lib/media/covers';

vi.mock('$lib/api/client', () => ({
  api: { get: vi.fn() }
}));
vi.mock('$lib/player/store.svelte', () => ({
  enqueueTracks: vi.fn()
}));

import AlbumCard from './AlbumCard.svelte';
import { api } from '$lib/api/client';
import { enqueueTracks } from '$lib/player/store.svelte';

const album: AlbumRef = {
  id: 'xyz',
  title: 'Kind of Blue',
  artist_id: 'm-davis',
  artist_name: 'Miles Davis',
  year: 1959,
  track_count: 5,
  duration_sec: 2630,
  cover_url: '/api/albums/xyz/cover',
};

afterEach(() => vi.clearAllMocks());

describe('AlbumCard', () => {
  test('renders cover, title, year inside a link to /albums/:id', () => {
    const { container } = render(AlbumCard, { props: { album } });
    const link = screen.getByRole('link');
    expect(link).toHaveAttribute('href', '/albums/xyz');
    expect(link).toHaveTextContent('Kind of Blue');
    expect(link).toHaveTextContent('1959');

    const img = container.querySelector('img') as HTMLImageElement;
    expect(img.src).toContain('/api/albums/xyz/cover');
  });

  test('year is omitted when not present', () => {
    render(AlbumCard, { props: { album: { ...album, year: undefined } } });
    expect(screen.queryByText(/1959/)).not.toBeInTheDocument();
  });

  test('broken cover falls back to FALLBACK_COVER data URL', async () => {
    const { container } = render(AlbumCard, { props: { album } });
    const img = container.querySelector('img') as HTMLImageElement;
    await fireEvent.error(img);
    expect(img.src).toBe(FALLBACK_COVER);
  });

  test('+ queue button fetches album detail and calls enqueueTracks', async () => {
    const tracks: TrackRef[] = [
      {
        id: 't1', title: 'So What',
        album_id: 'xyz', album_title: 'Kind of Blue',
        artist_id: 'm-davis', artist_name: 'Miles Davis',
        track_number: 1, disc_number: 1, duration_sec: 545,
        stream_url: '/api/tracks/t1/stream'
      }
    ];
    const detail: AlbumDetail = { ...album, tracks };
    (api.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce(detail);

    render(AlbumCard, { props: { album } });
    await fireEvent.click(screen.getByRole('button', { name: /add to queue/i }));

    expect(api.get).toHaveBeenCalledWith('/api/albums/xyz');
    // wait a microtask for the promise chain to resolve
    await Promise.resolve();
    await Promise.resolve();
    expect(enqueueTracks).toHaveBeenCalledWith(tracks);
  });
});
```

- [ ] **Step 2: Run tests, confirm the + queue test fails**

Run: `cd web && npm test -- src/lib/components/AlbumCard.test.ts`
Expected: FAIL — `getByRole('button', { name: /add to queue/i })` returns nothing.

- [ ] **Step 3: Replace `web/src/lib/components/AlbumCard.svelte`**

```svelte
<script lang="ts">
  import type { AlbumRef, AlbumDetail } from '$lib/api/types';
  import { FALLBACK_COVER } from '$lib/media/covers';
  import { api } from '$lib/api/client';
  import { enqueueTracks } from '$lib/player/store.svelte';

  let { album }: { album: AlbumRef } = $props();

  function onImgError(e: Event) {
    (e.currentTarget as HTMLImageElement).src = FALLBACK_COVER;
  }

  async function onAddClick(e: MouseEvent) {
    e.preventDefault();
    e.stopPropagation();
    const detail = await api.get<AlbumDetail>(`/api/albums/${album.id}`);
    enqueueTracks(detail.tracks);
  }
</script>

<div class="relative">
  <a
    href={`/albums/${album.id}`}
    class="group block rounded focus-visible:ring-2 focus-visible:ring-accent"
  >
    <img
      src={album.cover_url}
      alt=""
      class="aspect-square w-full rounded object-cover transition-transform group-hover:scale-[1.03]"
      loading="lazy"
      onerror={onImgError}
    />
    <div class="mt-2 truncate text-sm font-medium">{album.title}</div>
    {#if album.year}
      <div class="text-xs text-text-secondary">{album.year}</div>
    {/if}
  </a>
  <button
    type="button"
    aria-label="Add to queue"
    onclick={onAddClick}
    class="absolute right-2 top-2 rounded-full bg-surface/80 px-2 py-1 text-sm text-text-secondary hover:text-text-primary focus-visible:ring-2 focus-visible:ring-accent"
  >+</button>
</div>
```

- [ ] **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
<script lang="ts">
  import { page } from '$app/state';
  import { goto } from '$app/navigation';

  let value = $state(page.url.searchParams.get('q') ?? '');
  let timeout: ReturnType<typeof setTimeout> | null = null;

  // Re-sync local value if the URL changes from outside (e.g. clicking a
  // search-result link that preserves ?q= or browser back/forward).
  $effect(() => {
    const fromUrl = page.url.searchParams.get('q') ?? '';
    if (fromUrl !== value) value = fromUrl;
  });

  function navigate(next: string) {
    const onSearchPage = page.url.pathname.startsWith('/search');
    const trimmed = next.trim();
    if (trimmed === '') {
      if (onSearchPage) goto('/search', { replaceState: true });
      return;
    }
    goto(`/search?q=${encodeURIComponent(trimmed)}`, { replaceState: onSearchPage });
  }

  function onInput(e: Event) {
    value = (e.currentTarget as HTMLInputElement).value;
    if (timeout) clearTimeout(timeout);
    timeout = setTimeout(() => {
      timeout = null;
      navigate(value);
    }, 250);
  }

  function onKey(e: KeyboardEvent) {
    if (e.key === 'Escape') {
      e.preventDefault();
      value = '';
      if (timeout) {
        clearTimeout(timeout);
        timeout = null;
      }
      navigate('');
    }
  }
</script>

<input
  type="search"
  role="searchbox"
  placeholder="Search artists, albums, tracks…"
  aria-label="Search"
  {value}
  oninput={onInput}
  onkeydown={onKey}
  class="w-full max-w-md rounded border border-border bg-surface px-3 py-1 text-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
/>
```

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
<script lang="ts">
  const shimmer = 'animate-pulse bg-surface';
</script>

<div data-testid="search-skeleton" class="space-y-6">
  <section>
    <div class="mb-2 h-5 w-24 rounded {shimmer}"></div>
    <div class="divide-y divide-border">
      {#each Array.from({ length: 4 }) as _, i (i)}
        <div class="flex items-center gap-3 px-3 py-3">
          <span class="h-10 w-10 rounded-full {shimmer}"></span>
          <span class="h-4 flex-1 rounded {shimmer}"></span>
          <span class="h-3 w-20 rounded {shimmer}"></span>
        </div>
      {/each}
    </div>
  </section>

  <section>
    <div class="mb-2 h-5 w-24 rounded {shimmer}"></div>
    <div class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
      {#each Array.from({ length: 5 }) as _, i (i)}
        <div>
          <div class="aspect-square w-full rounded {shimmer}"></div>
          <div class="mt-2 h-4 w-3/4 rounded {shimmer}"></div>
          <div class="mt-1 h-3 w-1/3 rounded {shimmer}"></div>
        </div>
      {/each}
    </div>
  </section>

  <section>
    <div class="mb-2 h-5 w-24 rounded {shimmer}"></div>
    <div class="space-y-2">
      {#each Array.from({ length: 6 }) as _, i (i)}
        <div class="h-6 w-full rounded {shimmer}"></div>
      {/each}
    </div>
  </section>
</div>
```

- [ ] **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 `<div class="font-semibold">Minstrel</div>`.

- [ ] **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
<header class="col-span-2 flex items-center justify-between border-b border-border bg-surface px-4 py-3">
  <div class="font-semibold">Minstrel</div>
  <div class="relative">
    <!-- existing user menu -->
  </div>
</header>
```

Replace with:

```svelte
<header class="col-span-2 flex items-center gap-4 border-b border-border bg-surface px-4 py-3">
  <div class="font-semibold">Minstrel</div>
  <div class="flex-1">
    <SearchInput />
  </div>
  <div class="relative">
    <!-- existing user menu unchanged -->
  </div>
</header>
```

Keep the existing user-menu `<div class="relative">…</div>` 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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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
<script lang="ts">
  import { page } from '$app/state';
  import { createSearchQuery } from '$lib/api/queries';
  import ArtistRow from '$lib/components/ArtistRow.svelte';
  import AlbumCard from '$lib/components/AlbumCard.svelte';
  import TrackRow from '$lib/components/TrackRow.svelte';
  import SearchSkeleton from '$lib/components/SearchSkeleton.svelte';
  import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte';
  import { useDelayed } from '$lib/utils/useDelayed.svelte';
  import { playRadio } from '$lib/player/store.svelte';
  import type { TrackRef } from '$lib/api/types';

  const q = $derived((page.url.searchParams.get('q') ?? '').trim());
  const queryStore = $derived(q ? createSearchQuery(q) : null);
  const query = $derived(queryStore ? $queryStore : null);

  const artists = $derived(query?.data?.artists);
  const albums  = $derived(query?.data?.albums);
  const tracks  = $derived(query?.data?.tracks);

  const allEmpty = $derived(
    !!query?.data &&
    artists?.items.length === 0 &&
    albums?.items.length === 0 &&
    tracks?.items.length === 0
  );

  const showSkeleton = useDelayed(() => !!query?.isPending);

  function onTrackPlay(_tracks: TrackRef[], index: number) {
    playRadio(_tracks[index].id);
  }
</script>

<div class="space-y-6">
  {#if !q}
    <p class="text-text-secondary">
      Start typing to search artists, albums, and tracks.
    </p>
  {:else if query?.isError}
    <ApiErrorBanner error={query.error} onRetry={query.refetch} />
  {:else if showSkeleton.value && !query?.data}
    <SearchSkeleton />
  {:else if allEmpty}
    <p class="text-text-secondary">
      No matches for <span class="font-medium">'{q}'</span>.
    </p>
  {:else if query?.data}
    {#if artists && artists.items.length > 0}
      <section>
        <header class="mb-2 flex items-baseline justify-between">
          <h2 class="text-lg font-semibold">Artists</h2>
          {#if artists.total > artists.items.length}
            <a
              href={`/search/artists?q=${encodeURIComponent(q)}`}
              class="text-sm text-accent hover:underline"
            >
              See all {artists.total} →
            </a>
          {/if}
        </header>
        <div>
          {#each artists.items as a (a.id)}
            <ArtistRow artist={a} />
          {/each}
        </div>
      </section>
    {/if}

    {#if albums && albums.items.length > 0}
      <section>
        <header class="mb-2 flex items-baseline justify-between">
          <h2 class="text-lg font-semibold">Albums</h2>
          {#if albums.total > albums.items.length}
            <a
              href={`/search/albums?q=${encodeURIComponent(q)}`}
              class="text-sm text-accent hover:underline"
            >
              See all {albums.total} →
            </a>
          {/if}
        </header>
        <div class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
          {#each albums.items as al (al.id)}
            <AlbumCard album={al} />
          {/each}
        </div>
      </section>
    {/if}

    {#if tracks && tracks.items.length > 0}
      <section>
        <header class="mb-2 flex items-baseline justify-between">
          <h2 class="text-lg font-semibold">Tracks</h2>
          {#if tracks.total > tracks.items.length}
            <a
              href={`/search/tracks?q=${encodeURIComponent(q)}`}
              class="text-sm text-accent hover:underline"
            >
              See all {tracks.total} →
            </a>
          {/if}
        </header>
        <div class="overflow-hidden rounded border border-border">
          {#each tracks.items as t, i (t.id)}
            <TrackRow tracks={tracks.items} index={i} onPlay={onTrackPlay} />
          {/each}
        </div>
      </section>
    {/if}
  {/if}
</div>
```

- [ ] **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<T>(items: T[], total: number, offset = 0, limit = 50): Page<T> {
  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<typeof vi.fn>).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<typeof vi.fn>).mockReturnValue(
      mockInfiniteQuery({
        pages: [page<ArtistRef>([], 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<typeof vi.fn>).mockReturnValue(
      mockInfiniteQuery({ pages: [page<ArtistRef>([{ 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
<script lang="ts">
  import { page } from '$app/state';
  import { createSearchArtistsInfiniteQuery } from '$lib/api/queries';
  import ArtistRow from '$lib/components/ArtistRow.svelte';
  import LibrarySkeleton from '$lib/components/LibrarySkeleton.svelte';
  import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte';
  import { useDelayed } from '$lib/utils/useDelayed.svelte';

  const q = $derived((page.url.searchParams.get('q') ?? '').trim());
  const queryStore = $derived(q ? createSearchArtistsInfiniteQuery(q) : null);
  const query = $derived(queryStore ? $queryStore : null);

  const artists = $derived(query?.data?.pages?.flatMap((p) => p.items) ?? []);
  const total = $derived(query?.data?.pages?.[0]?.total ?? 0);
  const showSkeleton = useDelayed(() => !!query?.isPending);
</script>

<div class="space-y-4">
  <header>
    <a href={`/search?q=${encodeURIComponent(q)}`} class="text-sm text-text-secondary hover:text-text-primary">
      ← Back to search
    </a>
    <h1 class="mt-2 text-2xl font-semibold">Artists matching '{q}'</h1>
    {#if !query?.isPending && !query?.isError && q}
      <p class="text-sm text-text-secondary">{total} {total === 1 ? 'artist' : 'artists'}</p>
    {/if}
  </header>

  {#if !q}
    <p class="text-text-secondary">No query — return to search to start typing.</p>
  {:else if query?.isError}
    <ApiErrorBanner error={query.error} onRetry={query.refetch} />
  {:else if showSkeleton.value && artists.length === 0}
    <LibrarySkeleton variant="list" count={12} />
  {:else if artists.length === 0}
    <p class="text-text-secondary">No artist matches.</p>
  {:else}
    <div>
      {#each artists as a (a.id)}
        <ArtistRow artist={a} />
      {/each}
    </div>
    {#if query?.hasNextPage}
      <button
        type="button"
        class="w-full rounded bg-surface py-2 text-sm hover:bg-surface-hover disabled:opacity-60"
        disabled={query.isFetchingNextPage}
        onclick={() => query.fetchNextPage()}
      >
        {query.isFetchingNextPage ? 'Loading…' : 'Load more'}
      </button>
    {/if}
  {/if}
</div>
```

- [ ] **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<T>(items: T[], total: number, offset = 0, limit = 50): Page<T> {
  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<typeof vi.fn>).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<typeof vi.fn>).mockReturnValue(
      mockInfiniteQuery({
        pages: [page<AlbumRef>([], 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
<script lang="ts">
  import { page } from '$app/state';
  import { createSearchAlbumsInfiniteQuery } from '$lib/api/queries';
  import AlbumCard from '$lib/components/AlbumCard.svelte';
  import LibrarySkeleton from '$lib/components/LibrarySkeleton.svelte';
  import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte';
  import { useDelayed } from '$lib/utils/useDelayed.svelte';

  const q = $derived((page.url.searchParams.get('q') ?? '').trim());
  const queryStore = $derived(q ? createSearchAlbumsInfiniteQuery(q) : null);
  const query = $derived(queryStore ? $queryStore : null);

  const albums = $derived(query?.data?.pages?.flatMap((p) => p.items) ?? []);
  const total = $derived(query?.data?.pages?.[0]?.total ?? 0);
  const showSkeleton = useDelayed(() => !!query?.isPending);
</script>

<div class="space-y-4">
  <header>
    <a href={`/search?q=${encodeURIComponent(q)}`} class="text-sm text-text-secondary hover:text-text-primary">
      ← Back to search
    </a>
    <h1 class="mt-2 text-2xl font-semibold">Albums matching '{q}'</h1>
    {#if !query?.isPending && !query?.isError && q}
      <p class="text-sm text-text-secondary">{total} {total === 1 ? 'album' : 'albums'}</p>
    {/if}
  </header>

  {#if !q}
    <p class="text-text-secondary">No query — return to search to start typing.</p>
  {:else if query?.isError}
    <ApiErrorBanner error={query.error} onRetry={query.refetch} />
  {:else if showSkeleton.value && albums.length === 0}
    <LibrarySkeleton variant="grid" count={12} />
  {:else if albums.length === 0}
    <p class="text-text-secondary">No album matches.</p>
  {:else}
    <div class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
      {#each albums as a (a.id)}
        <AlbumCard album={a} />
      {/each}
    </div>
    {#if query?.hasNextPage}
      <button
        type="button"
        class="w-full rounded bg-surface py-2 text-sm hover:bg-surface-hover disabled:opacity-60"
        disabled={query.isFetchingNextPage}
        onclick={() => query.fetchNextPage()}
      >
        {query.isFetchingNextPage ? 'Loading…' : 'Load more'}
      </button>
    {/if}
  {/if}
</div>
```

- [ ] **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<T>(items: T[], total: number, offset = 0, limit = 50): Page<T> {
  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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockReturnValue(
      mockInfiniteQuery({
        pages: [page<TrackRef>([], 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
<script lang="ts">
  import { page } from '$app/state';
  import { createSearchTracksInfiniteQuery } from '$lib/api/queries';
  import TrackRow from '$lib/components/TrackRow.svelte';
  import LibrarySkeleton from '$lib/components/LibrarySkeleton.svelte';
  import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte';
  import { useDelayed } from '$lib/utils/useDelayed.svelte';
  import { playRadio } from '$lib/player/store.svelte';
  import type { TrackRef } from '$lib/api/types';

  const q = $derived((page.url.searchParams.get('q') ?? '').trim());
  const queryStore = $derived(q ? createSearchTracksInfiniteQuery(q) : null);
  const query = $derived(queryStore ? $queryStore : null);

  const tracks = $derived(query?.data?.pages?.flatMap((p) => p.items) ?? []);
  const total = $derived(query?.data?.pages?.[0]?.total ?? 0);
  const showSkeleton = useDelayed(() => !!query?.isPending);

  function onTrackPlay(_tracks: TrackRef[], index: number) {
    playRadio(_tracks[index].id);
  }
</script>

<div class="space-y-4">
  <header>
    <a href={`/search?q=${encodeURIComponent(q)}`} class="text-sm text-text-secondary hover:text-text-primary">
      ← Back to search
    </a>
    <h1 class="mt-2 text-2xl font-semibold">Tracks matching '{q}'</h1>
    {#if !query?.isPending && !query?.isError && q}
      <p class="text-sm text-text-secondary">{total} {total === 1 ? 'track' : 'tracks'}</p>
    {/if}
  </header>

  {#if !q}
    <p class="text-text-secondary">No query — return to search to start typing.</p>
  {:else if query?.isError}
    <ApiErrorBanner error={query.error} onRetry={query.refetch} />
  {:else if showSkeleton.value && tracks.length === 0}
    <LibrarySkeleton variant="list" count={12} />
  {:else if tracks.length === 0}
    <p class="text-text-secondary">No track matches.</p>
  {:else}
    <div class="overflow-hidden rounded border border-border">
      {#each tracks as t, i (t.id)}
        <TrackRow tracks={tracks} index={i} onPlay={onTrackPlay} />
      {/each}
    </div>
    {#if query?.hasNextPage}
      <button
        type="button"
        class="w-full rounded bg-surface py-2 text-sm hover:bg-surface-hover disabled:opacity-60"
        disabled={query.isFetchingNextPage}
        onclick={() => query.fetchNextPage()}
      >
        {query.isFetchingNextPage ? 'Loading…' : 'Load more'}
      </button>
    {/if}
  {/if}
</div>
```

- [ ] **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 `<div role=button>` 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<T>`, `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.
