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

15 KiB
Raw Blame History

Web UI Library Views — Design Spec

Date: 2026-04-23 Status: Design approved Follows: Web UI Auth — 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:

['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

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

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

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

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

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