Files
minstrel/docs/superpowers/specs/2026-04-23-web-ui-library-views-design.md
T
bvandeusen 248f03495e docs(spec): add web UI library views design
Covers the three browse routes (/, /artists/:id, /albums/:id) on top
of the existing /api/artists, /api/artists/{id}, /api/albums/{id},
and /api/albums/{id}/cover endpoints. Uses TanStack Query's infinite
query for the artists list with Load-more pagination, reusable card
and row components, and a shared delayed-skeleton pattern for
cache-hit navigation feel.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 08:09:55 -04:00

321 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.