Files
minstrel/docs/superpowers/plans/2026-04-23-web-ui-library-views.md
T
bvandeusen 4cd67405c9 docs(plan): update useDelayed task to match .svelte.test.ts convention
Rune-using test files need the .svelte. infix so vite-plugin-svelte
processes them. Also adds the flushSync() after $effect.root in the
'source already true' test so fake-timer advances fire the scheduled
timeout.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 18:16:37 -04:00

56 KiB
Raw Blame History

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.goArtistRef, AlbumRef, TrackRef, ArtistDetail, AlbumDetail, Page<T>
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 <a>
src/lib/components/ArtistRow.test.ts Component test
src/lib/components/AlbumCard.svelte Album grid card — cover, title, year, wrapping <a>
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

// 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<T> = {
  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
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:

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

// 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(
    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
       <rect width="100" height="100" fill="#1f1f23"/>
       <path d="M40 30v35a7 7 0 1 1-7-7 7 7 0 0 1 3 .7V35h22v8h-18z" fill="#6b6b70"/>
     </svg>`
  );

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

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
// 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<typeof setTimeout> | 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
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:

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
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<Page<ArtistRef>>(
        `/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<ArtistDetail>(`/api/artists/${id}`),
  });
}

export function createAlbumQuery(id: string) {
  return createQuery({
    queryKey: qk.album(id),
    queryFn: () => api.get<AlbumDetail>(`/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
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:

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
<script lang="ts">
  import type { ArtistRef } from '$lib/api/types';

  let { artist }: { artist: ArtistRef } = $props();

  const initial = artist.name ? artist.name.charAt(0).toUpperCase() : '';
  const countLabel = artist.album_count === 1 ? '1 album' : `${artist.album_count} albums`;
</script>

<a
  href={`/artists/${artist.id}`}
  class="flex items-center gap-3 border-b border-border px-3 py-3 hover:bg-surface-hover focus-visible:ring-2 focus-visible:ring-accent"
>
  <span
    class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-surface font-semibold"
    aria-hidden="true"
  >
    {initial}
  </span>
  <span class="flex-1 truncate">{artist.name}</span>
  <span class="text-sm text-text-secondary">{countLabel}</span>
  <span class="text-text-secondary" aria-hidden="true"></span>
</a>
  • 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
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 <a> 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:

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
<script lang="ts">
  import type { AlbumRef } from '$lib/api/types';
  import { FALLBACK_COVER } from '$lib/media/covers';

  let { album }: { album: AlbumRef } = $props();

  function onImgError(e: Event) {
    (e.currentTarget as HTMLImageElement).src = FALLBACK_COVER;
  }
</script>

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

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
<script lang="ts">
  import type { TrackRef } from '$lib/api/types';
  import { formatDuration } from '$lib/media/duration';

  let { track }: { track: TrackRef } = $props();
</script>

<div class="grid grid-cols-[32px_1fr_auto] items-center gap-4 px-3 py-2 text-sm odd:bg-surface/50">
  <span class="text-right tabular-nums text-text-secondary">
    {track.track_number ?? '—'}
  </span>
  <span class="truncate">{track.title}</span>
  <span class="tabular-nums text-text-secondary">{formatDuration(track.duration_sec)}</span>
</div>
  • 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
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:

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
<script lang="ts">
  type Variant = 'list' | 'grid' | 'album';
  let { variant, count = 12 }: { variant: Variant; count?: number } = $props();

  // Common shimmer class applied to every placeholder block.
  const shimmer = 'animate-pulse bg-surface';
</script>

{#if variant === 'list'}
  <div data-testid="skeleton-list" class="divide-y divide-border">
    {#each Array.from({ length: count }) as _, i (i)}
      <div data-skeleton-row 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>
{:else if variant === 'grid'}
  <div
    data-testid="skeleton-grid"
    class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5"
  >
    {#each Array.from({ length: count }) as _, i (i)}
      <div data-skeleton-card>
        <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>
{:else}
  <div data-testid="skeleton-album" class="space-y-6">
    <div class="flex flex-col gap-4 md:flex-row md:items-end">
      <div data-skeleton-album-cover class="h-60 w-60 rounded {shimmer}"></div>
      <div class="flex-1 space-y-3">
        <div class="h-8 w-3/4 rounded {shimmer}"></div>
        <div class="h-4 w-1/3 rounded {shimmer}"></div>
        <div class="h-4 w-1/4 rounded {shimmer}"></div>
      </div>
    </div>
    <div class="space-y-2">
      {#each Array.from({ length: 10 }) as _, i (i)}
        <div class="h-6 w-full rounded {shimmer}"></div>
      {/each}
    </div>
  </div>
{/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
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:

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
<script lang="ts">
  import type { ApiError } from '$lib/api/client';

  let {
    error,
    onRetry
  }: { error: unknown; onRetry: () => void } = $props();

  const apiErr = error as ApiError | undefined;
  const message = apiErr?.message ?? 'Something went wrong.';
</script>

<div role="alert" class="rounded border border-danger/40 bg-surface p-4">
  <p class="text-text-primary">{message}</p>
  <button
    type="button"
    class="mt-2 rounded bg-accent px-3 py-1 text-sm text-background"
    onclick={onRetry}
  >
    Try again
  </button>
</div>
  • 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
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

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<T>(opts: {
  pages?: Page<T>[];
  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<T>(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:

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<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/');
});

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<typeof vi.fn>).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<typeof vi.fn>).mockReturnValue(
      mockInfiniteQuery({ pages: [page<ArtistRef>([], 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<typeof vi.fn>).mockReturnValue(
      mockInfiniteQuery({
        pages: [page<ArtistRef>([], 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<typeof vi.fn>).mockReturnValue(
      mockInfiniteQuery({ pages: [page<ArtistRef>([{ 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<typeof vi.fn>).mockReturnValue(
      mockInfiniteQuery({ pages: [page<ArtistRef>([], 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<typeof vi.fn>).mockReturnValue(
      mockInfiniteQuery({ pages: [page<ArtistRef>([], 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<typeof vi.fn>).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<typeof vi.fn>).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
<script lang="ts">
  import { page } from '$app/state';
  import { goto } from '$app/navigation';
  import { createArtistsQuery, type ArtistSort } 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 sort: ArtistSort = $derived(
    (page.url.searchParams.get('sort') as ArtistSort) === 'newest' ? 'newest' : 'alpha'
  );

  const query = $derived(createArtistsQuery(sort));

  // Flattened artist list from all loaded pages.
  const artists = $derived(
    query.data?.pages?.flatMap((p) => p.items) ?? []
  );

  const total = $derived(query.data?.pages?.[0]?.total ?? 0);

  const showSkeleton = useDelayed(() => query.isPending);

  function onSortChange(e: Event) {
    const value = (e.currentTarget as HTMLSelectElement).value as ArtistSort;
    goto(`?sort=${value}`, { replaceState: true });
  }
</script>

<div class="space-y-4">
  <header class="flex items-center justify-between">
    <div>
      <h1 class="text-2xl font-semibold">Library</h1>
      {#if !query.isPending && !query.isError}
        <p class="text-sm text-text-secondary">
          {total} {total === 1 ? 'artist' : 'artists'}
        </p>
      {/if}
    </div>
    <label class="flex items-center gap-2 text-sm">
      <span class="text-text-secondary">Sort</span>
      <select
        value={sort}
        onchange={onSortChange}
        class="rounded border border-border bg-surface px-2 py-1"
      >
        <option value="alpha">AZ</option>
        <option value="newest">Newest</option>
      </select>
    </label>
  </header>

  {#if query.isError}
    <ApiErrorBanner error={query.error} onRetry={query.refetch} />
  {:else if showSkeleton.value && artists.length === 0}
    <LibrarySkeleton variant="list" count={12} />
  {:else if !query.isPending && total === 0}
    <p class="text-text-secondary">
      No artists yet — scan a library folder via the server's config.
    </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>
    {:else if artists.length > 0}
      <p class="py-2 text-center text-sm text-text-secondary">End of library</p>
    {/if}
  {/if}
</div>
  • 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
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:

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<string, string> }));

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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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
<script lang="ts">
  import { page } from '$app/state';
  import { createArtistQuery } 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';
  import type { ApiError } from '$lib/api/client';

  const id = $derived(page.params.id);
  const query = $derived(createArtistQuery(id));
  const showSkeleton = useDelayed(() => query.isPending);

  const notFound = $derived(
    query.isError && (query.error as ApiError | undefined)?.code === 'not_found'
  );
</script>

<div class="space-y-4">
  <a href="/" class="text-sm text-text-secondary hover:text-text-primary">← Library</a>

  {#if notFound}
    <div role="alert" class="rounded border border-border bg-surface p-4">
      <p>Artist not found.</p>
      <a href="/" class="mt-2 inline-block text-sm text-accent">Back to Library</a>
    </div>
  {:else if query.isError}
    <ApiErrorBanner error={query.error} onRetry={query.refetch} />
  {:else if showSkeleton.value && !query.data}
    <LibrarySkeleton variant="grid" count={12} />
  {:else if query.data}
    {@const detail = query.data}
    <header>
      <h1 class="text-2xl font-semibold">{detail.name}</h1>
      <p class="text-sm text-text-secondary">
        {detail.album_count} {detail.album_count === 1 ? 'album' : 'albums'}
      </p>
    </header>

    {#if detail.albums.length === 0}
      <p class="text-text-secondary">This artist has no albums in the library.</p>
    {:else}
      <div class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
        {#each detail.albums as album (album.id)}
          <AlbumCard {album} />
        {/each}
      </div>
    {/if}
  {/if}
</div>
  • 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
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:

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<string, string> }));

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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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
<script lang="ts">
  import { page } from '$app/state';
  import { createAlbumQuery } 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 { formatDuration } from '$lib/media/duration';
  import { FALLBACK_COVER } from '$lib/media/covers';
  import type { ApiError } from '$lib/api/client';

  const id = $derived(page.params.id);
  const query = $derived(createAlbumQuery(id));
  const showSkeleton = useDelayed(() => query.isPending);

  const notFound = $derived(
    query.isError && (query.error as ApiError | undefined)?.code === 'not_found'
  );

  function onCoverError(e: Event) {
    (e.currentTarget as HTMLImageElement).src = FALLBACK_COVER;
  }
</script>

<div class="space-y-6">
  {#if notFound}
    <div role="alert" class="rounded border border-border bg-surface p-4">
      <p>Album not found.</p>
      <a href="/" class="mt-2 inline-block text-sm text-accent">Back to Library</a>
    </div>
  {:else if query.isError}
    <ApiErrorBanner error={query.error} onRetry={query.refetch} />
  {:else if showSkeleton.value && !query.data}
    <LibrarySkeleton variant="album" />
  {:else if query.data}
    {@const album = query.data}
    <a
      href={`/artists/${album.artist_id}`}
      class="text-sm text-text-secondary hover:text-text-primary"
    >{album.artist_name}
    </a>

    <header class="flex flex-col gap-4 md:flex-row md:items-end">
      <img
        src={album.cover_url}
        alt=""
        class="h-60 w-60 rounded object-cover"
        loading="lazy"
        onerror={onCoverError}
      />
      <div class="flex-1 space-y-1">
        <h1 class="text-3xl font-semibold">{album.title}</h1>
        <p>
          <a href={`/artists/${album.artist_id}`} class="hover:underline">{album.artist_name}</a>
        </p>
        {#if album.year}
          <p class="text-sm text-text-secondary">{album.year}</p>
        {/if}
        <p class="text-sm text-text-secondary">
          {album.track_count} {album.track_count === 1 ? 'track' : 'tracks'}
          · {formatDuration(album.duration_sec)}
        </p>
      </div>
    </header>

    {#if album.tracks.length === 0}
      <p class="text-text-secondary">This album has no tracks.</p>
    {:else}
      <div class="overflow-hidden rounded border border-border">
        {#each album.tracks as track (track.id)}
          <TrackRow {track} />
        {/each}
      </div>
    {/if}
  {/if}
</div>
  • 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
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:

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/<real-id> → Shell renders without login flash; data refetches and renders.

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 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 610
  • 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<T> — defined in Task 1, used identically in Tasks 513.
  • 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).