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.
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.tsand 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:
- Outer element becomes
<div role="button" tabindex="0">so a real<button>for+ queuecan nest without invalid HTML. - Optional
onPlay?: (tracks, index) => voidprop overrides the defaultplayQueue(tracks, index)behavior. Used by the search Tracks section to callplayRadio(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."
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:
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:
- Sign in. Header now has a search box between brand and the user-menu.
- From the home page, type
mil(or any string with multiple library matches). After ~250ms the URL becomes/search?q=miland three sections appear (or fewer if some facets match nothing). - Empty matches: type
zzzqqq— page shows "No matches for 'zzzqqq'". - Backspace through the input until empty: URL drops
?q=; main/searchpage shows "Start typing". - 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). - Click a track result (the row, not the
+) → radio plays (one-track queue, since it's the M6 stub). - Click the
+on an album card → all of that album's tracks are appended; current playback continues. - Click "See all 47 artists →" → overflow page loads; "Load more" pulls the next page; back link returns to
/search?q=…. - Click an artist on
/search?q=…→ navigates to/artists/:id. The header search box still shows "mil". - Browser back → returns to
/search?q=milwith cached results. - 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.
/searchpage with three sections + see-all links → Task 10.- Three overflow pages → Task 11.
/api/radiostub → 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 intypes.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 inTrackRow.svelte, search page, and tracks overflow page.SEARCH_SUMMARY_LIMIT = 10andSEARCH_FACET_PAGE_SIZE = 50— used only insidequeries.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.tsis plain.test.tsbecause the test body itself uses no rune syntax (only the imported component does);.svelte.test.tsis reserved for tests likeuseDelayedwhere the test body declares$state/$effect.rootdirectly.
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.