# Web UI Library Views 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:** Wire up the three library browse routes — artists list at `/`, artist detail at `/artists/:id`, album detail at `/albums/:id` — consuming the existing `/api/artists*`, `/api/albums/:id`, and `/api/albums/:id/cover` endpoints, so an authenticated user can navigate the music library entirely in the SPA. **Architecture:** TanStack Query handles all fetches and caching via a thin `lib/api/queries.ts` helper. Each route is a tiny `+page.svelte` that imports a `create*Query` helper and delegates repeating visual units to small shared components (`ArtistRow`, `AlbumCard`, `TrackRow`, `LibrarySkeleton`, `ApiErrorBanner`). Loading/error/empty states follow the same pattern on all three routes. **Tech Stack:** SvelteKit 2 + Svelte 5 (runes), TypeScript, TanStack Query 5 (`@tanstack/svelte-query`), Vitest + jsdom + Testing Library, Tailwind. **Reference:** design spec at `docs/superpowers/specs/2026-04-23-web-ui-library-views-design.md`. --- ## File Structure **New files under `web/`:** | File | Responsibility | |---|---| | `src/lib/api/types.ts` | Mirrors backend `internal/api/types.go` — `ArtistRef`, `AlbumRef`, `TrackRef`, `ArtistDetail`, `AlbumDetail`, `Page` | | `src/lib/api/queries.ts` | `qk.*` key helpers and `createArtistsQuery`/`createArtistQuery`/`createAlbumQuery` wrappers | | `src/lib/api/queries.test.ts` | Tests the pure `qk` key generators | | `src/lib/media/covers.ts` | `FALLBACK_COVER` data URL + `coverUrl(id)` helper | | `src/lib/media/duration.ts` | `formatDuration(seconds)` returning `mm:ss` or `h:mm:ss` | | `src/lib/media/duration.test.ts` | Unit tests for `formatDuration` | | `src/lib/utils/useDelayed.svelte.ts` | `useDelayed(getValue, delayMs)` rune helper for skeleton suppression | | `src/lib/utils/useDelayed.svelte.test.ts` | Unit tests for `useDelayed` | | `src/lib/components/ArtistRow.svelte` | Artists list row — avatar initial, name, album count, chevron, wrapping `` | | `src/lib/components/ArtistRow.test.ts` | Component test | | `src/lib/components/AlbumCard.svelte` | Album grid card — cover, title, year, wrapping `` | | `src/lib/components/AlbumCard.test.ts` | Component test | | `src/lib/components/TrackRow.svelte` | Album track row — number, title, duration | | `src/lib/components/TrackRow.test.ts` | Component test | | `src/lib/components/LibrarySkeleton.svelte` | Skeleton shimmer with variants `list`/`grid`/`album` | | `src/lib/components/LibrarySkeleton.test.ts` | Component test | | `src/lib/components/ApiErrorBanner.svelte` | Retry-capable error card | | `src/lib/components/ApiErrorBanner.test.ts` | Component test | | `src/test-utils/query.ts` | Shared `mockQuery` / `mockInfiniteQuery` factories for page tests | | `src/routes/artists/[id]/+page.svelte` | Artist detail page | | `src/routes/artists/[id]/artist.test.ts` | Page test (NOT `+page.test.ts` — route walker collision) | | `src/routes/albums/[id]/+page.svelte` | Album detail page | | `src/routes/albums/[id]/album.test.ts` | Page test | | `src/routes/artists.test.ts` | Page test for the artists list (lives at `src/routes/`, not route-colocated in a `+` file) | **Modified files:** | File | Change | |---|---| | `src/routes/+page.svelte` | Replace the "Library" placeholder with the real artists list | --- ## Task 1: API types mirroring `internal/api/types.go` **Files:** - Create: `web/src/lib/api/types.ts` - [ ] **Step 1: Write `web/src/lib/api/types.ts`** ```ts // Mirrors internal/api/types.go. Kept minimal — only the shapes the SPA // actually reads today (no LoginRequest/LoginResponse here; those live in // client.ts alongside the auth plumbing). export type Page = { items: T[]; total: number; limit: number; offset: number; }; export type ArtistRef = { id: string; name: string; album_count: number; }; export type AlbumRef = { id: string; title: string; artist_id: string; artist_name: string; year?: number; track_count: number; duration_sec: number; cover_url: string; }; export type TrackRef = { id: string; title: string; album_id: string; album_title: string; artist_id: string; artist_name: string; track_number?: number; disc_number?: number; duration_sec: number; stream_url: string; }; export type ArtistDetail = ArtistRef & { albums: AlbumRef[]; }; export type AlbumDetail = AlbumRef & { tracks: TrackRef[]; }; ``` - [ ] **Step 2: Type-check** Run: `cd web && npm run check` Expected: `0 ERRORS 0 WARNINGS`. - [ ] **Step 3: Commit** ```bash git add web/src/lib/api/types.ts git commit -m "feat(web): add library API types mirroring internal/api/types.go" ``` --- ## Task 2: `formatDuration` helper + tests **Files:** - Create: `web/src/lib/media/duration.ts` - Create: `web/src/lib/media/duration.test.ts` - [ ] **Step 1: Write the failing tests** Create `web/src/lib/media/duration.test.ts`: ```ts import { describe, expect, test } from 'vitest'; import { formatDuration } from './duration'; describe('formatDuration', () => { test('under a minute renders 0:SS', () => { expect(formatDuration(42)).toBe('0:42'); expect(formatDuration(5)).toBe('0:05'); }); test('under an hour renders M:SS', () => { expect(formatDuration(242)).toBe('4:02'); expect(formatDuration(600)).toBe('10:00'); }); test('at or above an hour renders H:MM:SS', () => { expect(formatDuration(3600)).toBe('1:00:00'); expect(formatDuration(3723)).toBe('1:02:03'); expect(formatDuration(36000)).toBe('10:00:00'); }); test('zero renders 0:00', () => { expect(formatDuration(0)).toBe('0:00'); }); test('negative values clamp to 0:00', () => { expect(formatDuration(-1)).toBe('0:00'); expect(formatDuration(-100)).toBe('0:00'); }); test('fractional seconds floor', () => { expect(formatDuration(61.9)).toBe('1:01'); }); }); ``` - [ ] **Step 2: Run tests, confirm they fail** Run: `cd web && npm test -- src/lib/media/duration.test.ts` Expected: FAIL — `formatDuration` not exported. - [ ] **Step 3: Implement `web/src/lib/media/duration.ts`** ```ts // Formats a non-negative number of seconds as `mm:ss` when under an hour, // `h:mm:ss` otherwise. Negative values clamp to zero (callers shouldn't // pass them, but the UI shouldn't crash if they do). export function formatDuration(seconds: number): string { const total = Math.max(0, Math.floor(seconds)); const h = Math.floor(total / 3600); const m = Math.floor((total % 3600) / 60); const s = total % 60; const ss = String(s).padStart(2, '0'); if (h > 0) { const mm = String(m).padStart(2, '0'); return `${h}:${mm}:${ss}`; } return `${m}:${ss}`; } ``` - [ ] **Step 4: Run tests, confirm they pass** Run: `cd web && npm test -- src/lib/media/duration.test.ts` Expected: PASS — 6 tests. - [ ] **Step 5: Commit** ```bash git add web/src/lib/media/duration.ts web/src/lib/media/duration.test.ts git commit -m "feat(web): add formatDuration helper Renders seconds as mm:ss or h:mm:ss. Negative inputs clamp to 0:00 so a bad backend value doesn't crash the UI." ``` --- ## Task 3: `covers.ts` helper **Files:** - Create: `web/src/lib/media/covers.ts` - [ ] **Step 1: Write `web/src/lib/media/covers.ts`** ```ts // Fallback cover — an inline SVG data URL showing a music-note glyph on the // surface color. Used by AlbumCard's onerror handler when the real cover // fails (e.g. scanner didn't pick up an embedded image and there's no // sidecar). Data URL avoids an extra HTTP round-trip. export const FALLBACK_COVER = 'data:image/svg+xml;utf8,' + encodeURIComponent( ` ` ); export function coverUrl(albumId: string): string { return `/api/albums/${albumId}/cover`; } ``` - [ ] **Step 2: Type-check** Run: `cd web && npm run check` Expected: `0 ERRORS 0 WARNINGS`. - [ ] **Step 3: Commit** ```bash git add web/src/lib/media/covers.ts git commit -m "feat(web): add cover URL helper + fallback SVG data URL" ``` --- ## Task 4: `useDelayed` rune helper **Files:** - Create: `web/src/lib/utils/useDelayed.svelte.ts` - Create: `web/src/lib/utils/useDelayed.svelte.test.ts` - [ ] **Step 1: Write the failing tests** Create `web/src/lib/utils/useDelayed.svelte.test.ts`: ```ts import { describe, expect, test, vi } from 'vitest'; import { flushSync } from 'svelte'; import { useDelayed } from './useDelayed.svelte'; describe('useDelayed', () => { test('value stays false until delay elapses while source is true', () => { vi.useFakeTimers(); try { let source = $state(false); let delayed!: { readonly value: boolean }; const cleanup = $effect.root(() => { delayed = useDelayed(() => source, 100); }); expect(delayed.value).toBe(false); source = true; flushSync(); expect(delayed.value).toBe(false); vi.advanceTimersByTime(50); expect(delayed.value).toBe(false); vi.advanceTimersByTime(60); expect(delayed.value).toBe(true); cleanup(); } finally { vi.useRealTimers(); } }); test('source flipping false before delay cancels the pending timeout', () => { vi.useFakeTimers(); try { let source = $state(false); let delayed!: { readonly value: boolean }; const cleanup = $effect.root(() => { delayed = useDelayed(() => source, 100); }); source = true; flushSync(); vi.advanceTimersByTime(40); source = false; flushSync(); vi.advanceTimersByTime(200); expect(delayed.value).toBe(false); cleanup(); } finally { vi.useRealTimers(); } }); test('value resets to false immediately when source becomes false', () => { vi.useFakeTimers(); try { let source = $state(true); let delayed!: { readonly value: boolean }; const cleanup = $effect.root(() => { delayed = useDelayed(() => source, 100); }); flushSync(); // run the initial $effect so the setTimeout is scheduled vi.advanceTimersByTime(200); expect(delayed.value).toBe(true); source = false; flushSync(); expect(delayed.value).toBe(false); cleanup(); } finally { vi.useRealTimers(); } }); }); ``` - [ ] **Step 2: Run tests, confirm they fail** Run: `cd web && npm test -- src/lib/utils/useDelayed.svelte.test.ts` Expected: FAIL — module not found. - [ ] **Step 3: Implement `web/src/lib/utils/useDelayed.svelte.ts`** ```ts // Returns a rune-wrapped boolean that mirrors the source getter, but only // flips to true after the source has been true for delayMs continuously. // Flips back to false immediately when the source becomes false. // // Used to suppress flash-of-skeleton on cache-hit navigation: skeletons // only render for loads that take longer than ~100ms. export function useDelayed( source: () => boolean, delayMs = 100 ): { readonly value: boolean } { let delayed = $state(false); let timeout: ReturnType | null = null; $effect(() => { const v = source(); if (timeout) { clearTimeout(timeout); timeout = null; } if (v) { timeout = setTimeout(() => { delayed = true; timeout = null; }, delayMs); } else { delayed = false; } return () => { if (timeout) { clearTimeout(timeout); timeout = null; } }; }); return { get value() { return delayed; } }; } ``` - [ ] **Step 4: Run tests, confirm they pass** Run: `cd web && npm test -- src/lib/utils/useDelayed.svelte.test.ts` Expected: PASS — 3 tests. - [ ] **Step 5: Commit** ```bash git add web/src/lib/utils/useDelayed.svelte.ts web/src/lib/utils/useDelayed.svelte.test.ts git commit -m "feat(web): add useDelayed rune helper Boolean mirror that flips true only after the source stays true for delayMs. Used to hide the skeleton on sub-100ms cache hits so back-navigation feels instant." ``` --- ## Task 5: `queries.ts` + key-generator tests **Files:** - Create: `web/src/lib/api/queries.ts` - Create: `web/src/lib/api/queries.test.ts` - [ ] **Step 1: Write the failing tests** Create `web/src/lib/api/queries.test.ts`: ```ts import { describe, expect, test } from 'vitest'; import { qk } from './queries'; describe('qk key generators', () => { test('artists key includes sort discriminator', () => { expect(qk.artists('alpha')).toEqual(['artists', { sort: 'alpha' }]); expect(qk.artists('newest')).toEqual(['artists', { sort: 'newest' }]); }); test('artist key tuple', () => { expect(qk.artist('abc')).toEqual(['artist', 'abc']); }); test('album key tuple', () => { expect(qk.album('xyz')).toEqual(['album', 'xyz']); }); }); ``` - [ ] **Step 2: Run tests, confirm they fail** Run: `cd web && npm test -- src/lib/api/queries.test.ts` Expected: FAIL — module not found. - [ ] **Step 3: Implement `web/src/lib/api/queries.ts`** ```ts import { createQuery, createInfiniteQuery } from '@tanstack/svelte-query'; import { api } from './client'; import type { ArtistRef, ArtistDetail, AlbumDetail, Page } from './types'; export type ArtistSort = 'alpha' | 'newest'; export const ARTIST_PAGE_SIZE = 50; export const qk = { artists: (sort: ArtistSort) => ['artists', { sort }] as const, artist: (id: string) => ['artist', id] as const, album: (id: string) => ['album', id] as const, }; export function createArtistsQuery(sort: ArtistSort) { return createInfiniteQuery({ queryKey: qk.artists(sort), queryFn: ({ pageParam = 0 }) => api.get>( `/api/artists?sort=${sort}&limit=${ARTIST_PAGE_SIZE}&offset=${pageParam}` ), initialPageParam: 0, getNextPageParam: (last) => { const loaded = last.offset + last.items.length; return loaded >= last.total ? undefined : loaded; }, }); } export function createArtistQuery(id: string) { return createQuery({ queryKey: qk.artist(id), queryFn: () => api.get(`/api/artists/${id}`), }); } export function createAlbumQuery(id: string) { return createQuery({ queryKey: qk.album(id), queryFn: () => api.get(`/api/albums/${id}`), }); } ``` - [ ] **Step 4: Run tests, confirm they pass** Run: `cd web && npm test -- src/lib/api/queries.test.ts` Expected: PASS — 3 tests. - [ ] **Step 5: Type-check** Run: `cd web && npm run check` Expected: `0 ERRORS 0 WARNINGS`. - [ ] **Step 6: Commit** ```bash git add web/src/lib/api/queries.ts web/src/lib/api/queries.test.ts git commit -m "feat(web): add TanStack Query helpers for library endpoints qk.{artists,artist,album} key generators and create*Query wrappers with shared page size, sort-aware keys, and infinite-query plumbing for /api/artists." ``` --- ## Task 6: `ArtistRow` component **Files:** - Create: `web/src/lib/components/ArtistRow.svelte` - Create: `web/src/lib/components/ArtistRow.test.ts` - [ ] **Step 1: Write the failing tests** Create `web/src/lib/components/ArtistRow.test.ts`: ```ts import { describe, expect, test } from 'vitest'; import { render, screen } from '@testing-library/svelte'; import ArtistRow from './ArtistRow.svelte'; import type { ArtistRef } from '$lib/api/types'; const artist: ArtistRef = { id: 'abc', name: 'alice', album_count: 12 }; describe('ArtistRow', () => { test('renders initial, name, album count inside a link to /artists/:id', () => { render(ArtistRow, { props: { artist } }); const link = screen.getByRole('link'); expect(link).toHaveAttribute('href', '/artists/abc'); expect(link).toHaveTextContent('alice'); expect(link).toHaveTextContent('12 albums'); expect(link).toHaveTextContent('A'); // initial letter }); test('singular "1 album" when album_count is 1', () => { render(ArtistRow, { props: { artist: { ...artist, album_count: 1 } } }); expect(screen.getByRole('link')).toHaveTextContent('1 album'); }); test('empty name still renders a safe initial', () => { render(ArtistRow, { props: { artist: { ...artist, name: '' } } }); // No crash; initial container present but empty or a placeholder char. expect(screen.getByRole('link')).toBeInTheDocument(); }); }); ``` - [ ] **Step 2: Run tests, confirm they fail** Run: `cd web && npm test -- src/lib/components/ArtistRow.test.ts` Expected: FAIL — component not found. - [ ] **Step 3: Implement `web/src/lib/components/ArtistRow.svelte`** ```svelte {artist.name} {countLabel} ``` - [ ] **Step 4: Run tests, confirm they pass** Run: `cd web && npm test -- src/lib/components/ArtistRow.test.ts` Expected: PASS — 3 tests. - [ ] **Step 5: Commit** ```bash git add web/src/lib/components/ArtistRow.svelte web/src/lib/components/ArtistRow.test.ts git commit -m "feat(web): add ArtistRow component One row in the artists list: avatar initial, name, album count, chevron. Entire row is an for click + keyboard activation." ``` --- ## Task 7: `AlbumCard` component **Files:** - Create: `web/src/lib/components/AlbumCard.svelte` - Create: `web/src/lib/components/AlbumCard.test.ts` - [ ] **Step 1: Write the failing tests** Create `web/src/lib/components/AlbumCard.test.ts`: ```ts import { describe, expect, test } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/svelte'; import AlbumCard from './AlbumCard.svelte'; import type { AlbumRef } from '$lib/api/types'; import { FALLBACK_COVER } from '$lib/media/covers'; 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', }; describe('AlbumCard', () => { test('renders cover, title, year inside a link to /albums/:id', () => { 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 = screen.getByRole('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 } } }); // No crash; no year text. expect(screen.queryByText(/1959/)).not.toBeInTheDocument(); }); test('broken cover falls back to FALLBACK_COVER data URL', async () => { render(AlbumCard, { props: { album } }); const img = screen.getByRole('img') as HTMLImageElement; await fireEvent.error(img); expect(img.src).toBe(FALLBACK_COVER); }); }); ``` - [ ] **Step 2: Run tests, confirm they fail** Run: `cd web && npm test -- src/lib/components/AlbumCard.test.ts` Expected: FAIL — component not found. - [ ] **Step 3: Implement `web/src/lib/components/AlbumCard.svelte`** ```svelte
{album.title}
{#if album.year}
{album.year}
{/if}
``` - [ ] **Step 4: Run tests, confirm they pass** Run: `cd web && npm test -- src/lib/components/AlbumCard.test.ts` Expected: PASS — 3 tests. - [ ] **Step 5: Commit** ```bash git add web/src/lib/components/AlbumCard.svelte web/src/lib/components/AlbumCard.test.ts git commit -m "feat(web): add AlbumCard component Grid card used on the artist detail page. Cover image, title, year. Broken covers swap to FALLBACK_COVER via onerror." ``` --- ## Task 8: `TrackRow` component **Files:** - Create: `web/src/lib/components/TrackRow.svelte` - Create: `web/src/lib/components/TrackRow.test.ts` - [ ] **Step 1: Write the failing tests** Create `web/src/lib/components/TrackRow.test.ts`: ```ts import { describe, expect, test } from 'vitest'; import { render, screen } from '@testing-library/svelte'; import TrackRow from './TrackRow.svelte'; import type { TrackRef } from '$lib/api/types'; const track: 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', }; describe('TrackRow', () => { test('renders track number, title, formatted duration', () => { render(TrackRow, { props: { track } }); 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', () => { render(TrackRow, { props: { track: { ...track, track_number: undefined } } }); expect(screen.getByText('—')).toBeInTheDocument(); }); }); ``` - [ ] **Step 2: Run tests, confirm they fail** Run: `cd web && npm test -- src/lib/components/TrackRow.test.ts` Expected: FAIL — component not found. - [ ] **Step 3: Implement `web/src/lib/components/TrackRow.svelte`** ```svelte
{track.track_number ?? '—'} {track.title} {formatDuration(track.duration_sec)}
``` - [ ] **Step 4: Run tests, confirm they pass** Run: `cd web && npm test -- src/lib/components/TrackRow.test.ts` Expected: PASS — 2 tests. - [ ] **Step 5: Commit** ```bash git add web/src/lib/components/TrackRow.svelte web/src/lib/components/TrackRow.test.ts git commit -m "feat(web): add TrackRow component Non-interactive album track row with number, title, duration. Player plan will add click-to-play." ``` --- ## Task 9: `LibrarySkeleton` component **Files:** - Create: `web/src/lib/components/LibrarySkeleton.svelte` - Create: `web/src/lib/components/LibrarySkeleton.test.ts` - [ ] **Step 1: Write the failing tests** Create `web/src/lib/components/LibrarySkeleton.test.ts`: ```ts import { describe, expect, test } from 'vitest'; import { render, screen } from '@testing-library/svelte'; import LibrarySkeleton from './LibrarySkeleton.svelte'; describe('LibrarySkeleton', () => { test('variant="list" renders count placeholder rows', () => { render(LibrarySkeleton, { props: { variant: 'list', count: 5 } }); const container = screen.getByTestId('skeleton-list'); expect(container.querySelectorAll('[data-skeleton-row]').length).toBe(5); }); test('variant="grid" renders count placeholder cards', () => { render(LibrarySkeleton, { props: { variant: 'grid', count: 6 } }); const container = screen.getByTestId('skeleton-grid'); expect(container.querySelectorAll('[data-skeleton-card]').length).toBe(6); }); test('variant="album" renders a hero placeholder with cover block + bars', () => { render(LibrarySkeleton, { props: { variant: 'album' } }); expect(screen.getByTestId('skeleton-album')).toBeInTheDocument(); expect(screen.getByTestId('skeleton-album-cover')).toBeInTheDocument(); }); }); ``` - [ ] **Step 2: Run tests, confirm they fail** Run: `cd web && npm test -- src/lib/components/LibrarySkeleton.test.ts` Expected: FAIL — component not found. - [ ] **Step 3: Implement `web/src/lib/components/LibrarySkeleton.svelte`** ```svelte {#if variant === 'list'}
{#each Array.from({ length: count }) as _, i (i)}
{/each}
{:else if variant === 'grid'}
{#each Array.from({ length: count }) as _, i (i)}
{/each}
{:else}
{#each Array.from({ length: 10 }) as _, i (i)}
{/each}
{/if} ``` - [ ] **Step 4: Run tests, confirm they pass** Run: `cd web && npm test -- src/lib/components/LibrarySkeleton.test.ts` Expected: PASS — 3 tests. - [ ] **Step 5: Commit** ```bash git add web/src/lib/components/LibrarySkeleton.svelte web/src/lib/components/LibrarySkeleton.test.ts git commit -m "feat(web): add LibrarySkeleton with list/grid/album variants Shimmer placeholders that mirror the layout of each library page so the real content slides in without shifting." ``` --- ## Task 10: `ApiErrorBanner` component **Files:** - Create: `web/src/lib/components/ApiErrorBanner.svelte` - Create: `web/src/lib/components/ApiErrorBanner.test.ts` - [ ] **Step 1: Write the failing tests** Create `web/src/lib/components/ApiErrorBanner.test.ts`: ```ts import { describe, expect, test, vi } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/svelte'; import ApiErrorBanner from './ApiErrorBanner.svelte'; describe('ApiErrorBanner', () => { test('renders error.message when present', () => { render(ApiErrorBanner, { props: { error: { code: 'server_error', message: 'database down', status: 500 }, onRetry: () => {} } }); expect(screen.getByRole('alert')).toHaveTextContent('database down'); }); test('falls back to generic text when error lacks message', () => { render(ApiErrorBanner, { props: { error: undefined, onRetry: () => {} } }); expect(screen.getByRole('alert')).toHaveTextContent(/something went wrong/i); }); test('clicking the button calls onRetry', async () => { const onRetry = vi.fn(); render(ApiErrorBanner, { props: { error: { code: 'server_error', message: 'x', status: 500 }, onRetry } }); await fireEvent.click(screen.getByRole('button', { name: /try again/i })); expect(onRetry).toHaveBeenCalledTimes(1); }); }); ``` - [ ] **Step 2: Run tests, confirm they fail** Run: `cd web && npm test -- src/lib/components/ApiErrorBanner.test.ts` Expected: FAIL — component not found. - [ ] **Step 3: Implement `web/src/lib/components/ApiErrorBanner.svelte`** ```svelte ``` - [ ] **Step 4: Run tests, confirm they pass** Run: `cd web && npm test -- src/lib/components/ApiErrorBanner.test.ts` Expected: PASS — 3 tests. - [ ] **Step 5: Commit** ```bash git add web/src/lib/components/ApiErrorBanner.svelte web/src/lib/components/ApiErrorBanner.test.ts git commit -m "feat(web): add ApiErrorBanner retry-capable error card" ``` --- ## Task 11: Artists list page at `/` **Files:** - Modify: `web/src/routes/+page.svelte` - Create: `web/src/test-utils/query.ts` - Create: `web/src/routes/artists.test.ts` - [ ] **Step 1: Create `web/src/test-utils/query.ts`** ```ts import type { Page } from '$lib/api/types'; // Synthetic infinite-query object shape matching what the SPA reads from // @tanstack/svelte-query. Enough surface area for component tests; // real behavior is covered by the TanStack library's own tests. export function mockInfiniteQuery(opts: { pages?: Page[]; isPending?: boolean; isError?: boolean; error?: unknown; hasNextPage?: boolean; isFetchingNextPage?: boolean; fetchNextPage?: () => void; refetch?: () => void; } = {}) { return { data: { pages: opts.pages ?? [] }, isPending: opts.isPending ?? false, isError: opts.isError ?? false, error: opts.error, hasNextPage: opts.hasNextPage ?? false, isFetchingNextPage: opts.isFetchingNextPage ?? false, fetchNextPage: opts.fetchNextPage ?? (() => {}), refetch: opts.refetch ?? (() => {}) }; } export function mockQuery(opts: { data?: T; isPending?: boolean; isError?: boolean; error?: unknown; refetch?: () => void; } = {}) { return { data: opts.data, isPending: opts.isPending ?? false, isError: opts.isError ?? false, error: opts.error, refetch: opts.refetch ?? (() => {}) }; } ``` - [ ] **Step 2: Write the failing page test** Create `web/src/routes/artists.test.ts`: ```ts import { afterEach, describe, expect, test, vi } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/svelte'; import { flushSync } from 'svelte'; import { mockInfiniteQuery } from '../test-utils/query'; import type { ArtistRef, Page } from '$lib/api/types'; 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() })); vi.mock('$lib/api/queries', () => ({ qk: { artists: (sort: string) => ['artists', { sort }] }, createArtistsQuery: vi.fn() })); import ArtistsPage from './+page.svelte'; import { createArtistsQuery } from '$lib/api/queries'; import { goto } from '$app/navigation'; function page(items: T[], total: number, offset = 0, limit = 50): Page { return { items, total, offset, limit }; } afterEach(() => { vi.clearAllMocks(); state.pageUrl = new URL('http://localhost/'); }); describe('artists list page', () => { test('renders a row per artist', () => { const alice: ArtistRef = { id: 'a', name: 'Alice', album_count: 3 }; const bob: ArtistRef = { id: 'b', name: 'Bob', album_count: 1 }; (createArtistsQuery as ReturnType).mockReturnValue( mockInfiniteQuery({ pages: [page([alice, bob], 2)] }) ); render(ArtistsPage); expect(screen.getByRole('link', { name: /Alice/ })).toHaveAttribute('href', '/artists/a'); expect(screen.getByRole('link', { name: /Bob/ })).toHaveAttribute('href', '/artists/b'); }); test('renders the total count from the first page', () => { (createArtistsQuery as ReturnType).mockReturnValue( mockInfiniteQuery({ pages: [page([], 1247)] }) ); render(ArtistsPage); expect(screen.getByText(/1247 artists/i)).toBeInTheDocument(); }); test('Load more button calls fetchNextPage when hasNextPage', async () => { const fetchNextPage = vi.fn(); (createArtistsQuery as ReturnType).mockReturnValue( mockInfiniteQuery({ pages: [page([], 100)], hasNextPage: true, fetchNextPage }) ); render(ArtistsPage); await fireEvent.click(screen.getByRole('button', { name: /load more/i })); expect(fetchNextPage).toHaveBeenCalledTimes(1); }); test('"End of library" when hasNextPage is false and at least one page loaded', () => { (createArtistsQuery as ReturnType).mockReturnValue( mockInfiniteQuery({ pages: [page([{ id: 'a', name: 'A', album_count: 1 }], 1)] }) ); render(ArtistsPage); expect(screen.getByText(/end of library/i)).toBeInTheDocument(); }); test('empty total renders empty-library message', () => { (createArtistsQuery as ReturnType).mockReturnValue( mockInfiniteQuery({ pages: [page([], 0)] }) ); render(ArtistsPage); expect(screen.getByText(/no artists yet/i)).toBeInTheDocument(); }); test('changing the sort dropdown calls goto with ?sort=newest', async () => { (createArtistsQuery as ReturnType).mockReturnValue( mockInfiniteQuery({ pages: [page([], 0)] }) ); render(ArtistsPage); const select = screen.getByLabelText(/sort/i) as HTMLSelectElement; await fireEvent.change(select, { target: { value: 'newest' } }); expect(goto).toHaveBeenCalledWith('?sort=newest', { replaceState: true }); }); test('pending state renders the list skeleton after the useDelayed window', () => { (createArtistsQuery as ReturnType).mockReturnValue( mockInfiniteQuery({ isPending: true }) ); vi.useFakeTimers(); try { render(ArtistsPage); vi.advanceTimersByTime(200); flushSync(); // flush the $effect that reads delayed.value expect(screen.getByTestId('skeleton-list')).toBeInTheDocument(); } finally { vi.useRealTimers(); } }); test('error state renders the error banner with retry', async () => { const refetch = vi.fn(); (createArtistsQuery as ReturnType).mockReturnValue( mockInfiniteQuery({ isError: true, error: { code: 'server_error', message: 'db down', status: 500 }, refetch }) ); render(ArtistsPage); expect(screen.getByRole('alert')).toHaveTextContent('db down'); await fireEvent.click(screen.getByRole('button', { name: /try again/i })); expect(refetch).toHaveBeenCalledTimes(1); }); }); ``` - [ ] **Step 3: Run tests, confirm they fail** Run: `cd web && npm test -- src/routes/artists.test.ts` Expected: FAIL — page still renders the placeholder, assertions don't match. - [ ] **Step 4: Replace `web/src/routes/+page.svelte`** ```svelte

Library

{#if !query.isPending && !query.isError}

{total} {total === 1 ? 'artist' : 'artists'}

{/if}
{#if query.isError} {:else if showSkeleton.value && artists.length === 0} {:else if !query.isPending && total === 0}

No artists yet — scan a library folder via the server's config.

{:else}
{#each artists as a (a.id)} {/each}
{#if query.hasNextPage} {:else if artists.length > 0}

End of library

{/if} {/if}
``` - [ ] **Step 5: Run tests, confirm they pass** Run: `cd web && npm test -- src/routes/artists.test.ts` Expected: PASS — 8 tests. - [ ] **Step 6: Type-check** Run: `cd web && npm run check` Expected: `0 ERRORS 0 WARNINGS`. - [ ] **Step 7: Commit** ```bash git add web/src/routes/+page.svelte web/src/routes/artists.test.ts web/src/test-utils/query.ts git commit -m "feat(web): replace / placeholder with real artists list Uses createArtistsQuery (infinite), URL-driven sort dropdown, Load more pagination, delayed skeleton, and shared error banner. Also introduces the test-utils/query.ts helpers for subsequent page tests." ``` --- ## Task 12: Artist detail page at `/artists/:id` **Files:** - Create: `web/src/routes/artists/[id]/+page.svelte` - Create: `web/src/routes/artists/[id]/artist.test.ts` - [ ] **Step 1: Write the failing test** Create `web/src/routes/artists/[id]/artist.test.ts`: ```ts import { afterEach, describe, expect, test, vi } from 'vitest'; import { render, screen } from '@testing-library/svelte'; import { flushSync } from 'svelte'; import { mockQuery } from '../../../test-utils/query'; import type { ArtistDetail, AlbumRef } from '$lib/api/types'; const state = vi.hoisted(() => ({ pageParams: { id: 'abc' } as Record })); vi.mock('$app/state', () => ({ page: { get params() { return state.pageParams; }, get url() { return new URL('http://localhost/artists/' + state.pageParams.id); } } })); vi.mock('$lib/api/queries', () => ({ qk: { artist: (id: string) => ['artist', id] }, createArtistQuery: vi.fn() })); import ArtistPage from './+page.svelte'; import { createArtistQuery } from '$lib/api/queries'; function album(id: string, title: string, year?: number): AlbumRef { return { id, title, artist_id: 'abc', artist_name: 'Alice', year, track_count: 10, duration_sec: 2400, cover_url: `/api/albums/${id}/cover` }; } afterEach(() => { vi.clearAllMocks(); state.pageParams = { id: 'abc' }; }); describe('artist detail page', () => { test('renders artist name, subtitle, and one AlbumCard per album', () => { const detail: ArtistDetail = { id: 'abc', name: 'Alice', album_count: 2, albums: [album('a1', 'First', 2020), album('a2', 'Second')] }; (createArtistQuery as ReturnType).mockReturnValue(mockQuery({ data: detail })); render(ArtistPage); expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Alice'); expect(screen.getByText(/2 albums/i)).toBeInTheDocument(); expect(screen.getByRole('link', { name: /First/ })).toHaveAttribute('href', '/albums/a1'); expect(screen.getByRole('link', { name: /Second/ })).toHaveAttribute('href', '/albums/a2'); }); test('back link points to Library', () => { (createArtistQuery as ReturnType).mockReturnValue(mockQuery({ data: { id: 'abc', name: 'Alice', album_count: 0, albums: [] } })); render(ArtistPage); const back = screen.getByRole('link', { name: /library/i }); expect(back).toHaveAttribute('href', '/'); }); test('404 renders non-retryable "Artist not found" with back link', () => { (createArtistQuery as ReturnType).mockReturnValue(mockQuery({ isError: true, error: { code: 'not_found', message: 'nope', status: 404 } })); render(ArtistPage); expect(screen.getByText(/artist not found/i)).toBeInTheDocument(); expect(screen.queryByRole('button', { name: /try again/i })).not.toBeInTheDocument(); }); test('non-404 error renders the retry banner', () => { (createArtistQuery as ReturnType).mockReturnValue(mockQuery({ isError: true, error: { code: 'server_error', message: 'boom', status: 500 } })); render(ArtistPage); expect(screen.getByRole('alert')).toHaveTextContent('boom'); expect(screen.getByRole('button', { name: /try again/i })).toBeInTheDocument(); }); test('pending (after delay) renders the grid skeleton', () => { (createArtistQuery as ReturnType).mockReturnValue(mockQuery({ isPending: true })); vi.useFakeTimers(); try { render(ArtistPage); vi.advanceTimersByTime(200); flushSync(); expect(screen.getByTestId('skeleton-grid')).toBeInTheDocument(); } finally { vi.useRealTimers(); } }); }); ``` - [ ] **Step 2: Run tests, confirm they fail** Run: `cd web && npm test -- src/routes/artists/[id]/artist.test.ts` Expected: FAIL — component not found. - [ ] **Step 3: Implement `web/src/routes/artists/[id]/+page.svelte`** ```svelte
← Library {#if notFound} {:else if query.isError} {:else if showSkeleton.value && !query.data} {:else if query.data} {@const detail = query.data}

{detail.name}

{detail.album_count} {detail.album_count === 1 ? 'album' : 'albums'}

{#if detail.albums.length === 0}

This artist has no albums in the library.

{:else}
{#each detail.albums as album (album.id)} {/each}
{/if} {/if}
``` - [ ] **Step 4: Run tests, confirm they pass** Run: `cd web && npm test -- src/routes/artists/[id]/artist.test.ts` Expected: PASS — 5 tests. - [ ] **Step 5: Type-check** Run: `cd web && npm run check` Expected: `0 ERRORS 0 WARNINGS`. - [ ] **Step 6: Commit** ```bash git add web/src/routes/artists/[id]/+page.svelte web/src/routes/artists/[id]/artist.test.ts git commit -m "feat(web): add artist detail page at /artists/:id Renders the artist name + album count and a responsive AlbumCard grid from the nested ArtistDetail response. 404 surfaces a distinct non-retryable 'not found' state; other errors share the retry banner." ``` --- ## Task 13: Album detail page at `/albums/:id` **Files:** - Create: `web/src/routes/albums/[id]/+page.svelte` - Create: `web/src/routes/albums/[id]/album.test.ts` - [ ] **Step 1: Write the failing test** Create `web/src/routes/albums/[id]/album.test.ts`: ```ts import { afterEach, describe, expect, test, vi } from 'vitest'; import { render, screen } from '@testing-library/svelte'; import { flushSync } from 'svelte'; import { mockQuery } from '../../../test-utils/query'; import type { AlbumDetail, TrackRef } from '$lib/api/types'; const state = vi.hoisted(() => ({ pageParams: { id: 'xyz' } as Record })); vi.mock('$app/state', () => ({ page: { get params() { return state.pageParams; }, get url() { return new URL('http://localhost/albums/' + state.pageParams.id); } } })); vi.mock('$lib/api/queries', () => ({ qk: { album: (id: string) => ['album', id] }, createAlbumQuery: vi.fn() })); import AlbumPage from './+page.svelte'; import { createAlbumQuery } from '$lib/api/queries'; function track(id: string, title: string, number: number, dur: number): TrackRef { return { id, title, album_id: 'xyz', album_title: 'Kind of Blue', artist_id: 'md', artist_name: 'Miles Davis', track_number: number, disc_number: 1, duration_sec: dur, stream_url: `/api/tracks/${id}/stream` }; } afterEach(() => { vi.clearAllMocks(); state.pageParams = { id: 'xyz' }; }); describe('album detail page', () => { test('renders hero metadata + one TrackRow per track', () => { const detail: AlbumDetail = { id: 'xyz', title: 'Kind of Blue', artist_id: 'md', artist_name: 'Miles Davis', year: 1959, track_count: 2, duration_sec: 544 + 565, cover_url: '/api/albums/xyz/cover', tracks: [track('t1', 'So What', 1, 544), track('t2', 'Freddie Freeloader', 2, 565)] }; (createAlbumQuery as ReturnType).mockReturnValue(mockQuery({ data: detail })); render(AlbumPage); expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Kind of Blue'); expect(screen.getByRole('link', { name: /Miles Davis/ })).toHaveAttribute('href', '/artists/md'); expect(screen.getByText(/1959/)).toBeInTheDocument(); expect(screen.getByText(/2 tracks/i)).toBeInTheDocument(); expect(screen.getByText('So What')).toBeInTheDocument(); expect(screen.getByText('Freddie Freeloader')).toBeInTheDocument(); const img = screen.getByRole('img') as HTMLImageElement; expect(img.src).toContain('/api/albums/xyz/cover'); }); test('back link points to /artists/:artistId with the artist name', () => { const detail: AlbumDetail = { id: 'xyz', title: 'T', artist_id: 'md', artist_name: 'Miles Davis', track_count: 0, duration_sec: 0, cover_url: '/api/albums/xyz/cover', tracks: [] }; (createAlbumQuery as ReturnType).mockReturnValue(mockQuery({ data: detail })); render(AlbumPage); const back = screen.getByRole('link', { name: /Miles Davis/ }); expect(back).toHaveAttribute('href', '/artists/md'); }); test('404 renders non-retryable "Album not found" with back link', () => { (createAlbumQuery as ReturnType).mockReturnValue(mockQuery({ isError: true, error: { code: 'not_found', message: 'nope', status: 404 } })); render(AlbumPage); expect(screen.getByText(/album not found/i)).toBeInTheDocument(); expect(screen.queryByRole('button', { name: /try again/i })).not.toBeInTheDocument(); }); test('non-404 error renders the retry banner', () => { (createAlbumQuery as ReturnType).mockReturnValue(mockQuery({ isError: true, error: { code: 'server_error', message: 'boom', status: 500 } })); render(AlbumPage); expect(screen.getByRole('alert')).toHaveTextContent('boom'); expect(screen.getByRole('button', { name: /try again/i })).toBeInTheDocument(); }); test('pending (after delay) renders the album skeleton', () => { (createAlbumQuery as ReturnType).mockReturnValue(mockQuery({ isPending: true })); vi.useFakeTimers(); try { render(AlbumPage); vi.advanceTimersByTime(200); flushSync(); expect(screen.getByTestId('skeleton-album')).toBeInTheDocument(); } finally { vi.useRealTimers(); } }); }); ``` - [ ] **Step 2: Run tests, confirm they fail** Run: `cd web && npm test -- src/routes/albums/[id]/album.test.ts` Expected: FAIL — component not found. - [ ] **Step 3: Implement `web/src/routes/albums/[id]/+page.svelte`** ```svelte
{#if notFound} {:else if query.isError} {:else if showSkeleton.value && !query.data} {:else if query.data} {@const album = query.data} ← {album.artist_name}

{album.title}

{album.artist_name}

{#if album.year}

{album.year}

{/if}

{album.track_count} {album.track_count === 1 ? 'track' : 'tracks'} · {formatDuration(album.duration_sec)}

{#if album.tracks.length === 0}

This album has no tracks.

{:else}
{#each album.tracks as track (track.id)} {/each}
{/if} {/if}
``` - [ ] **Step 4: Run tests, confirm they pass** Run: `cd web && npm test -- src/routes/albums/[id]/album.test.ts` Expected: PASS — 5 tests. - [ ] **Step 5: Type-check** Run: `cd web && npm run check` Expected: `0 ERRORS 0 WARNINGS`. - [ ] **Step 6: Commit** ```bash git add web/src/routes/albums/[id]/+page.svelte web/src/routes/albums/[id]/album.test.ts git commit -m "feat(web): add album detail page at /albums/:id Hero with cover + title + linked artist + metadata; TrackRow list below. Back link targets the artist page (not Library) so drilling up feels correct. 404 renders a distinct non-retryable state." ``` --- ## Task 14: Final verification + branch finish **Files:** none (verification only). - [ ] **Step 1: Full Go suite (sanity — backend untouched)** Run: `go test -short -race ./...` Expected: PASS. - [ ] **Step 2: Go linter** Run: `golangci-lint run ./...` Expected: no issues. - [ ] **Step 3: Web check + full tests + build** Run: `cd web && npm run check && npm test && npm run build` Expected: check 0 errors / 0 warnings; 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:library-smoke .` Expected: image builds. Run: `docker run --rm --entrypoint /bin/sh minstrel:library-smoke -c 'test -x /usr/local/bin/minstrel && echo ok'` Expected: `ok`. - [ ] **Step 5: Local end-to-end manual check** Bring up the stack: ```bash docker compose up --build -d ``` In a browser: 1. Sign in with the seeded admin user. 2. `/` renders the artists list with counts and "Load more" if more than 50 artists exist. 3. Change sort dropdown → URL shows `?sort=newest`, list reorders. 4. Click an artist → albums grid renders with covers (some may show the fallback glyph — that's expected for albums without embedded cover art). 5. Click an album → hero + track list. Duration format looks right (`3:42` for short, `1:02:03` for long). 6. Click the back link on the album page → returns to the artist (not to `/`). 7. Visit `/artists/00000000-0000-0000-0000-000000000000` → "Artist not found" card. 8. Visit `/albums/00000000-0000-0000-0000-000000000000` → "Album not found" card. 9. Refresh `/artists/` → Shell renders without login flash; data refetches and renders. Tear down: ```bash docker compose down ``` - [ ] **Step 6: Finish the branch** Follow `superpowers:finishing-a-development-branch`. Expected path: Option 2 — push `dev`, open a PR to `main`, wait for Forgejo CI, merge once green. --- ## Self-Review Notes **Spec coverage check:** - API types → Task 1 - `queries.ts` helpers + query-key convention → Task 5 - Cover URL + fallback → Task 3 - Duration formatter → Task 2 - Delayed skeleton helper → Task 4 - ArtistRow / AlbumCard / TrackRow / LibrarySkeleton / ApiErrorBanner → Tasks 6–10 - Test-utils mocks → Task 11 (introduced alongside the first page that needs them) - Artists list page (replace `/` placeholder) → Task 11 - Artist detail page → Task 12 - Album detail page → Task 13 - Final verification + branch finish → Task 14 All spec sections map to tasks. **Type consistency:** - `ArtistRef`, `AlbumRef`, `TrackRef`, `ArtistDetail`, `AlbumDetail`, `Page` — defined in Task 1, used identically in Tasks 5–13. - `ArtistSort` — exported from `queries.ts` (Task 5), consumed in Task 11. - `ApiError` — re-imported from `$lib/api/client` (auth plan) in Tasks 10, 12, 13. - `createArtistsQuery` / `createArtistQuery` / `createAlbumQuery` signatures consistent between Task 5 and consuming tasks. - `qk.artists(sort)` / `qk.artist(id)` / `qk.album(id)` — mocked-out in page tests exactly as defined. - `FALLBACK_COVER` — defined in Task 3, used in Tasks 7 (AlbumCard) and 13 (album page hero). - `formatDuration(seconds)` — defined in Task 2, used in Task 8 (TrackRow test and component) and Task 13. - `useDelayed(getValue, delayMs?)` — defined in Task 4, used in Tasks 11, 12, 13. **Filename hazards addressed:** No page test is named `+page.test.ts` (auth plan's lesson). Route-colocated tests use `artist.test.ts` / `album.test.ts`; the artists list test lives at `src/routes/artists.test.ts` (outside the `+` route-walker).