diff --git a/web/src/routes/search/+page.svelte b/web/src/routes/search/+page.svelte
index 576cfb9d..c5501398 100644
--- a/web/src/routes/search/+page.svelte
+++ b/web/src/routes/search/+page.svelte
@@ -1,2 +1,112 @@
-
Search lands in a subsequent plan.
+
+
+
+ {#if !q}
+
+ Start typing to search artists, albums, and tracks.
+
+ {:else if query?.isError}
+
+ {:else if showSkeleton.value && !query?.data}
+
+ {:else if allEmpty}
+
+ No matches for '{q}'.
+
+ {:else if query?.data}
+ {#if artists && artists.items.length > 0}
+
+
+
+ {#each artists.items as a (a.id)}
+
+ {/each}
+
+
+ {/if}
+
+ {#if albums && albums.items.length > 0}
+
+
+
+ {#each albums.items as al (al.id)}
+
+ {/each}
+
+
+ {/if}
+
+ {#if tracks && tracks.items.length > 0}
+
+
+
+ {#each tracks.items as t, i (t.id)}
+
+ {/each}
+
+
+ {/if}
+ {/if}
+
diff --git a/web/src/routes/search/search.test.ts b/web/src/routes/search/search.test.ts
new file mode 100644
index 00000000..8aab5c7d
--- /dev/null
+++ b/web/src/routes/search/search.test.ts
@@ -0,0 +1,135 @@
+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()
+}));
+
+vi.mock('$lib/api/client', () => ({
+ api: { get: 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