diff --git a/web/src/routes/playlists/+page.svelte b/web/src/routes/playlists/+page.svelte
index c31cbf02..559233ae 100644
--- a/web/src/routes/playlists/+page.svelte
+++ b/web/src/routes/playlists/+page.svelte
@@ -1,2 +1,118 @@
-
Playlists
-Playlists land in a subsequent plan.
+
+
+
+
+ Playlists
+
+
+
+ {#if creating}
+
+
+ {#if createError}
+
{createError}
+ {/if}
+
+
+
+
+
+ {/if}
+
+ {#if playlistsQuery?.isPending}
+
Loading playlists…
+ {:else if playlistsQuery?.isError}
+
playlistsQuery.refetch()} />
+ {:else if playlistsQuery?.data}
+
+ Your playlists
+ {#if playlistsQuery.data.owned.length === 0}
+ No playlists yet. Click "New playlist" to start one.
+ {:else}
+
+ {#each playlistsQuery.data.owned as p (p.id)}
+
+ {/each}
+
+ {/if}
+
+
+ {#if playlistsQuery.data.public.length > 0}
+
+ From other users
+
+ {#each playlistsQuery.data.public as p (p.id)}
+
+ {/each}
+
+
+ {/if}
+ {/if}
+
diff --git a/web/src/routes/playlists/playlists.test.ts b/web/src/routes/playlists/playlists.test.ts
new file mode 100644
index 00000000..73efcb61
--- /dev/null
+++ b/web/src/routes/playlists/playlists.test.ts
@@ -0,0 +1,75 @@
+import { describe, expect, test, vi } from 'vitest';
+import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
+import { mockQuery } from '../../test-utils/query';
+import type { Playlist } from '$lib/api/types';
+
+vi.mock('$lib/api/playlists', () => ({
+ createPlaylistsQuery: vi.fn(),
+ createPlaylist: vi.fn().mockResolvedValue({ id: 'p-new', name: 'X' })
+}));
+
+vi.mock('@tanstack/svelte-query', async (orig) => {
+ const actual = (await orig()) as Record;
+ return {
+ ...actual,
+ useQueryClient: () => ({ invalidateQueries: vi.fn().mockResolvedValue(undefined) })
+ };
+});
+
+vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
+
+import PlaylistsPage from './+page.svelte';
+import { createPlaylistsQuery, createPlaylist } from '$lib/api/playlists';
+
+const mockedCreatePlaylistsQuery = createPlaylistsQuery as ReturnType;
+const mockedCreatePlaylist = createPlaylist as ReturnType;
+
+function p(over: Partial): Playlist {
+ return {
+ id: 'p1', user_id: 'u-self', owner_username: 'me', name: 'A',
+ description: '', is_public: false, cover_url: '', track_count: 0, duration_sec: 0,
+ created_at: '', updated_at: '', ...over
+ };
+}
+
+describe('Playlists index page', () => {
+ test('renders own + public sections', () => {
+ mockedCreatePlaylistsQuery.mockReturnValue(mockQuery({
+ data: {
+ owned: [p({ id: 'p1', name: 'Mine' })],
+ public: [p({ id: 'p2', name: 'Theirs', user_id: 'u-other', owner_username: 'bob' })]
+ }
+ }));
+ render(PlaylistsPage);
+ expect(screen.getByText('Mine')).toBeInTheDocument();
+ expect(screen.getByText('Theirs')).toBeInTheDocument();
+ expect(screen.getByText(/your playlists/i)).toBeInTheDocument();
+ expect(screen.getByText(/from other users/i)).toBeInTheDocument();
+ });
+
+ test('empty-state copy when operator has no playlists', () => {
+ mockedCreatePlaylistsQuery.mockReturnValue(mockQuery({
+ data: { owned: [], public: [] }
+ }));
+ render(PlaylistsPage);
+ expect(screen.getByText(/no playlists yet/i)).toBeInTheDocument();
+ });
+
+ test('hides "From other users" section when empty', () => {
+ mockedCreatePlaylistsQuery.mockReturnValue(mockQuery({
+ data: { owned: [p({ id: 'p1', name: 'Mine' })], public: [] }
+ }));
+ render(PlaylistsPage);
+ expect(screen.queryByText(/from other users/i)).not.toBeInTheDocument();
+ });
+
+ test('Create button reveals inline form; submit creates and navigates', async () => {
+ mockedCreatePlaylistsQuery.mockReturnValue(mockQuery({ data: { owned: [], public: [] } }));
+ render(PlaylistsPage);
+ await fireEvent.click(screen.getByRole('button', { name: /new playlist/i }));
+ const input = screen.getByPlaceholderText(/saturday morning/i);
+ await fireEvent.input(input, { target: { value: 'Test' } });
+ await fireEvent.click(screen.getByRole('button', { name: /^create$/i }));
+ await waitFor(() => expect(mockedCreatePlaylist).toHaveBeenCalledWith({ name: 'Test' }));
+ });
+});