Files
minstrel/docs/superpowers/plans/2026-04-25-web-ui-search.md
T
bvandeusen 8fd193e186 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.
2026-04-25 14:59:08 -04:00

73 KiB

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:

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
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:

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
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:

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
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:

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:

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
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
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:

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):

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:

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
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
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
<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
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:

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
<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
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."

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:

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
<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
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
<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
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:

import SearchInput from './SearchInput.svelte';

In the template, find the header section:

<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:

<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
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:

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
<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
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:

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
<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:

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
<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:

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
<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
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:

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:

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.