# Web UI Library Views — Design Spec

**Date:** 2026-04-23
**Status:** Design approved
**Follows:** [Web UI Auth](2026-04-22-web-ui-auth-design.md) — this spec is the first data-consuming feature on top of the auth foundation.

## Goal

Let an authenticated user browse the server's music library — artists, artist detail with albums, album detail with tracks — entirely in the SPA. After this lands, `/` is a real library index, deep links like `/artists/abc-123` resolve to pages with data, and the `cover_url`/`stream_url` forward references emitted by the JSON API finally have consumers.

## Non-goals

Explicit YAGNI — documented so they don't drift back in:

- No track detail page. Track metadata is already visible inline in the album view; a dedicated `/tracks/:id` page would show identical fields with no action affordances (the player plan adds playback; that's where click-to-play belongs).
- No search UI. `/search` keeps the auth plan's "Coming soon" placeholder. Search deserves its own sub-plan — different UX concerns (debouncing, faceted results, empty states).
- No playback. All track rows are non-interactive; `<audio>` never mounts. The player sub-plan wires everything.
- No infinite scroll, virtualization, or hover-play previews. "Load more" is enough; we upgrade when the library actually feels slow.
- No "Recently Added" / recommendation surfaces. We don't have play signals yet.
- No theme switcher. Dark-only.
- No artist images / avatars from MusicBrainz or similar. Initial-letter circle is the placeholder forever (or until we add something better in a dedicated plan).

## Architecture

**Three routes, three queries.** Route-per-view matches every widely-used music browser (Navidrome, Plex, Spotify web):

| Route | Page responsibility | Query |
|---|---|---|
| `/` | Artists list with sort + pagination | `createInfiniteQuery(['artists', {sort}])` |
| `/artists/:id` | Artist detail — name header, albums grid | `createQuery(['artist', id])` |
| `/albums/:id` | Album detail — hero with cover, track list | `createQuery(['album', id])` |

Modal-detail layouts (single-page with overlays) and three-pane split views were considered and rejected — they either break deep-linking or fight the existing sidebar Shell.

**Data layer.** TanStack Query owns all fetches and caching. A small `web/src/lib/api/queries.ts` module owns the query-key convention and exports `createArtistsQuery`, `createArtistQuery`, `createAlbumQuery` helpers so every page imports the same plumbing.

Query-key convention:

```ts
['artists', { sort: 'alpha' | 'newest' }]   // infinite query; pageParam = offset
['artist', id]                               // single artist + nested albums
['album', id]                                // single album + nested tracks
```

**Component decomposition.** Each repeating visual unit is its own small Svelte component. Pages stay short; components stay testable in isolation.

- `ArtistRow` — one row in the artists list. 40×40 avatar (first letter), name, album count, chevron. Whole row is an `<a href="/artists/:id">`.
- `AlbumCard` — one card in the albums grid. Cover image, title, year. `<a href="/albums/:id">`.
- `TrackRow` — one row in the album track table. Non-interactive until the player plan.
- `LibrarySkeleton` — shimmer placeholder with `variant="list" | "grid" | "album"` and `count` props.
- `ApiErrorBanner` — retry-capable error card shared by all three pages.

**Cache lifecycle.** `queryClient.clear()` already runs on logout (auth plan), so library data from user A never leaks to user B. `staleTime: 30_000` from the existing query-client defaults means tab-switching back within 30s avoids refetch; anything older is background-refreshed on mount.

## File structure

**New files under `web/src/`:**

```
lib/
├── api/
│   ├── queries.ts              # qk.* helpers + createArtistsQuery/createArtistQuery/createAlbumQuery
│   └── types.ts                # ArtistRef, AlbumRef, TrackRef, ArtistDetail, AlbumDetail, Page<T>
├── components/
│   ├── ArtistRow.svelte
│   ├── ArtistRow.test.ts
│   ├── AlbumCard.svelte
│   ├── AlbumCard.test.ts
│   ├── TrackRow.svelte
│   ├── TrackRow.test.ts
│   ├── LibrarySkeleton.svelte
│   ├── LibrarySkeleton.test.ts
│   ├── ApiErrorBanner.svelte
│   └── ApiErrorBanner.test.ts
├── media/
│   ├── covers.ts               # FALLBACK_COVER data URL + coverUrl(id) helper
│   └── duration.ts             # formatDuration(seconds): '3:42' or '1:02:03'
└── utils/
    └── useDelayed.svelte.ts    # delayed-boolean rune helper for skeleton suppression

routes/
├── +page.svelte                # Library root — artists list (REPLACE existing placeholder)
├── artists.test.ts             # tests for the artists list page
├── artists/
│   └── [id]/
│       ├── +page.svelte
│       └── artist.test.ts
└── albums/
    └── [id]/
        ├── +page.svelte
        └── album.test.ts

test-utils/
└── query.ts                    # shared vi.mock helpers for @tanstack/svelte-query
```

**Modified files:**

| File | Change |
|---|---|
| `web/src/routes/+page.svelte` | Replace "Library" placeholder with the real artists list. |

Nothing touches the `lib/api/client.ts` transport — the api facade from the auth plan is sufficient. Nothing touches `lib/auth/` — auth is a done surface.

## `queries.ts` shape

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

`types.ts` — new file mirroring the backend `internal/api/types.go` shapes (`ArtistRef`, `AlbumRef`, `TrackRef`, `ArtistDetail`, `AlbumDetail`, `Page<T>`). Lives next to `client.ts` for discoverability.

## Per-page layout

### Artists list — `/`

- **Top bar**: left "Library" title (h1); right, `<select>` bound to `?sort=alpha|newest` via `goto('?sort=...', {replaceState: true})`. Query key re-derives from URL; TanStack Query fetches the new sort. Subtitle below: "1,247 artists" (pulled from the first page's `total`).
- **Body**: stacked `<ArtistRow>` items. Dark row background with lighter hover. Each row ~60px tall: 40×40 circle avatar (first letter), name, muted-color `N albums`, chevron.
- **Footer**: full-width `<button class="Load more">`. Disabled + internal spinner while `isFetchingNextPage`. Becomes `<p>End of library</p>` when `hasNextPage === false`.
- **Empty state** (total === 0): "No artists yet — scan a library folder via the server's config."
- **Loading state** (isPending): `<LibrarySkeleton variant="list" count={12} />`.
- **Error state**: `<ApiErrorBanner error={query.error} onRetry={query.refetch} />`.

### Artist detail — `/artists/:id`

- **Top**: back chevron link "← Library" that routes to `/`. h1 with artist name; subtitle `N albums`.
- **Body**: responsive grid of `<AlbumCard>`. Tailwind breakpoints — `grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5`. Each card ~200px: cover (square), title, year below; hover scales the cover via `hover:scale-[1.03] transition-transform`.
- **Empty state**: "This artist has no albums in the library." (Rare — backend only surfaces artists that have at least one album; included for defense in depth.)
- **Not-found state** (ApiError `{code: 'not_found', status: 404}`): "Artist not found" with a back link.
- **Loading / error**: standard skeleton + banner patterns.

### Album detail — `/albums/:id`

- **Top**: back chevron link "← {artistName}" routing to `/artists/:artistId`.
- **Hero**: flex row on `md:+`, column stack on mobile. Left/top: 240px square cover. Right/bottom: h1 title, artist name as a link to `/artists/:artistId`, year (if present), `N tracks · H:MM:SS` total duration.
- **Body**: track table. Columns: `#` (32px right-aligned), title, duration (right-aligned). Track rows alternate subtle background tint. No play icons, no hover effects — player plan adds those. Duration renders via `formatDuration(seconds)` — `mm:ss` when under an hour (`242 → "4:02"`), `h:mm:ss` at or above (`3723 → "1:02:03"`).
- **Empty state**: "This album has no tracks." (Rare; defense in depth.)
- **Not-found state**: "Album not found" with a back link to `/`.
- **Loading**: skeleton with cover-sized gray square + title/metadata bars + ~10 gray track rows.

## Cover rendering

```svelte
<img
  src="/api/albums/{id}/cover"
  alt=""
  class="aspect-square w-full object-cover"
  loading="lazy"
  onerror={(e) => {
    // fallback: inline SVG data URL with a music-note glyph on the surface color
    (e.currentTarget as HTMLImageElement).src = FALLBACK_COVER;
  }}
/>
```

- Same-origin in dev (Vite proxy) and prod (embedded SPA) — the browser attaches the `minstrel_session` cookie automatically.
- `loading="lazy"` means off-screen cards in the artist grid don't issue requests until scrolled.
- Alt text is empty because the visible label (album title) is adjacent and screen readers read both; doubling the announcement is worse than skipping.
- `FALLBACK_COVER` is a module-level data-URL constant exported from a small `covers.ts` helper.

## Loading/error patterns

**Delayed skeleton.** A small shared helper prevents flash-of-skeleton on cache hits:

```ts
// lib/utils/useDelayed.svelte.ts
export function useDelayed(getValue: () => boolean, delayMs = 100) {
  let delayed = $state(false);
  let timeout: ReturnType<typeof setTimeout> | null = null;
  $effect(() => {
    const v = getValue();
    if (v) {
      timeout = setTimeout(() => { delayed = true; }, delayMs);
    } else {
      if (timeout) clearTimeout(timeout);
      delayed = false;
    }
    return () => { if (timeout) clearTimeout(timeout); };
  });
  return { get value() { return delayed; } };
}
```

Use: `const showSkeleton = useDelayed(() => query.isPending);` — skeleton renders only if `isPending` persists beyond 100ms. Cache-hit navigation feels instant; genuinely-loading views still show feedback.

**Error banner.** Single component reused across pages:

```svelte
<!-- 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>
```

**404 branch.** Detail pages check `apiErr?.code === 'not_found'` specifically and render a non-retryable "not found" card with a back link — retry buttons on 404s just confuse users.

## Testing

**Component unit tests (Vitest + Testing Library + jest-dom):**

- `ArtistRow.test.ts` — renders initial (`"alice"` → `"A"`), name, `12 albums`, href `/artists/{id}`.
- `AlbumCard.test.ts` — renders `<img src="/api/albums/{id}/cover">`, title, year, href `/albums/{id}`. Broken-cover handler swaps src to the fallback data URL.
- `TrackRow.test.ts` — renders `#` (track number), title, duration — with `formatDuration(242)` → `"4:02"`, `formatDuration(3723)` → `"1:02:03"`.
- `LibrarySkeleton.test.ts` — variants `list | grid | album` each render a distinctive structure; `count` prop controls item count.
- `ApiErrorBanner.test.ts` — shows `error.message` when present; falls back to "Something went wrong"; clicking the button calls `onRetry`.

**Page integration tests (mocked TanStack Query):**

Shared test util at `src/test-utils/query.ts`:

```ts
// Returns a minimal synthetic infinite-query store.
export function mockInfiniteQuery<T>(opts: {
  pages?: T[];
  isPending?: boolean;
  isError?: boolean;
  error?: unknown;
  hasNextPage?: boolean;
  isFetchingNextPage?: boolean;
  fetchNextPage?: () => void;
}) { /* ... */ }

export function mockQuery<T>(opts: {
  data?: T;
  isPending?: boolean;
  isError?: boolean;
  error?: unknown;
  refetch?: () => void;
}) { /* ... */ }
```

Then:

- `artists.test.ts`
  - Renders `<ArtistRow>` for each artist in mocked pages.
  - Clicking "Load more" calls `fetchNextPage`.
  - `hasNextPage === false` renders "End of library".
  - Sort `<select>` change triggers `goto('?sort=newest', ...)`.
  - Pending renders `<LibrarySkeleton variant="list">`; error renders `<ApiErrorBanner>`.
  - Empty state (total === 0) renders the empty-library message.
- `artist.test.ts`
  - Renders `<AlbumCard>` for each album in `ArtistDetail.albums`.
  - Back link `href="/"` with text "← Library".
  - 404 error renders "Artist not found" with back link and NO retry button.
  - Pending renders skeleton; non-404 error renders banner with retry.
- `album.test.ts`
  - Renders cover (src pointing at `/api/albums/:id/cover`), title, artist link, year, metadata summary.
  - Renders `<TrackRow>` for each track.
  - Back link `href="/artists/:artistId"` with text `"← {artistName}"`.
  - 404 error renders "Album not found" with back link.

**Manual browser check** (Plan Task N verification step):

1. `docker compose up --build -d` + login with seeded user.
2. `/` shows the artists list; counts visible; "Load more" appends pages without reloading.
3. Change sort dropdown → URL updates to `?sort=newest`; list reorders (oldest-added artists move to the bottom).
4. Click an artist → albums grid renders with covers. Broken covers (if any) show the fallback glyph.
5. Click an album → hero + track list. Back link returns to the artist (not the root).
6. Visit `/albums/00000000-0000-0000-0000-000000000000` → "Album not found".
7. Refresh `/artists/abc` while authenticated → no flash of login; data re-fetches and renders.

## Success criteria

- A user can start at `/`, drill into any artist, then into any album, then back — all via deep-linkable URLs.
- Cover images render; broken covers fall back gracefully.
- "Load more" extends the artist list; sort dropdown changes order and reflects in the URL.
- 404s (not-found artist / album) render a specific not-found card, not a retry banner.
- Cache-hit navigation (e.g., pressing Back) is instant — no skeleton flash.
- All unit and page tests pass in CI (`npm test` in `web/`).
- No backend changes required; this is pure frontend consumption of the existing `/api/artists`, `/api/artists/{id}`, `/api/albums/{id}`, `/api/albums/{id}/cover` endpoints.
