docs: add web UI scaffold design spec

First pass of the web UI design doc from the M1 close-out brainstorm.
Covers the stack (SvelteKit static + Go embed), auth model
(cookie + bearer from one endpoint), /api/* surface, shell layout
(sidebar + bottom player), first-cut feature scope, and how later
web UI work threads into M2–M5 alongside each backend milestone
instead of landing as a post-hoc M6.

Also ignores the local .superpowers/ brainstorming companion cache.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-20 19:18:04 -04:00
parent 4a9193bc52
commit cb3df51f08
2 changed files with 271 additions and 0 deletions
+3
View File
@@ -25,3 +25,6 @@ go.work.sum
# env file
.env
# Superpowers brainstorming companion sessions
.superpowers/
@@ -0,0 +1,268 @@
# Web UI Scaffold — Design
**Status:** Approved (brainstorm), ready for implementation plan
**Date:** 2026-04-20
**Milestone:** M1.5 (new) — scaffold for the web UI that M2M5 layer features onto
## Purpose
Minstrel needs a built-in web UI with feature parity to the planned Flutter client. The M1 Subsonic baseline is verified end-to-end via third-party clients (Feishin), but Minstrel's own UI surface is currently empty. This spec describes the scaffold: the stack, the packaging, the auth model, the API surface the SPA consumes, the shell layout, and the first-cut feature set.
Features that depend on backend work not yet built (events, recommendations, radio, Lidarr) are explicitly deferred and will land alongside their respective backend milestones. The web UI is not a late-stage M6 milestone — it advances through M2M5 in step with the server.
## Non-goals
- OIDC / SSO login (deferred until after a solid UI exists; possibly post-v1).
- Light-theme support (dark theme only for first cut; light mode follows).
- Mobile-first responsive layouts (desktop-first; the web UI is expected to be *more dense* than the Flutter client, which will own the mobile experience).
- SSR / server-rendered initial HTML (pure SPA — SvelteKit static adapter).
- Reusing the `/rest/*` Subsonic surface from the SPA.
## Stack
- **Framework:** SvelteKit + TypeScript + Vite.
- **Adapter:** `@sveltejs/adapter-static`, building to `web/build/` as fully-static HTML/CSS/JS.
- **SPA fallback:** `adapter-static` configured with `fallback: 'index.html'` so deep links (`/artists/abc`) load the SPA shell and client-side routing resolves the path.
- **Styling:** Tailwind CSS + component-scoped Svelte styles. No UI kit — hand-rolled components kept minimal.
- **State:** Svelte stores. No external state library.
- **HTTP:** Native `fetch`; a thin wrapper (`web/src/lib/api.ts`) adds credentials, parses errors, returns typed responses.
### Why Svelte over React/Solid
- Compiled output is small and fast. Matters when rendering large library views (paginated at first; virtualization if we hit perf walls later).
- Built-in routing / stores / forms mean fewer library decisions for a solo project.
- The trade-off (smaller ecosystem) is acceptable because the components we need — list views, player UI, form controls — are trivial to build or exist as libraries.
## Packaging
- **Single binary, single image.** SvelteKit builds to static assets; Go embeds `web/build/` via `//go:embed` and serves from root (`/`) with SPA fallback (unmatched paths return `index.html`).
- **Dockerfile:** multi-stage.
1. `FROM node:<lts> AS web``npm ci`, `npm run build`, produces `web/build/`.
2. `FROM golang:<ver> AS go``go build`, with `web/build/` copied in before build so `//go:embed` picks it up.
3. `FROM debian:stable-slim` (or existing base) — copy binary, ffmpeg, run.
- Release artifact remains a single container image. `docker compose up` stays one service for the app.
## Dev workflow
- Two processes during development:
1. `docker compose up` — Go server on `:4533`, Postgres, scanner.
2. `cd web && npm run dev` — Vite dev server on `:5173` with HMR.
- Vite dev server proxies `/api/*` and `/rest/*` to `http://localhost:4533`. The SPA is loaded from `:5173` in dev; cookies on `:5173` work because the proxy rewrites the origin.
- Production build: `npm run build` runs in CI / Docker build; static assets get embedded.
## API surface
### Principle
- `/rest/*` is the Subsonic-compatibility surface for third-party clients. It keeps salted-md5 + apiKey auth, Subsonic envelope, XML/JSON shape. **The SPA does not call `/rest/*`.**
- `/api/*` is the native surface for Minstrel's own clients (web SPA now, Flutter later). Clean JSON, cookie-or-bearer auth, designed for modern consumers.
### First-cut endpoints
| Method | Path | Purpose |
| --- | --- | --- |
| POST | `/api/auth/login` | Verify credentials. Returns `{token, user}`, sets `httpOnly` session cookie. |
| POST | `/api/auth/logout` | Invalidate session cookie and the bearer token associated with the caller's session. |
| GET | `/api/me` | Return the authenticated user (username, is_admin, etc.). |
| GET | `/api/artists` | Paginated artist list with sort keys. |
| GET | `/api/artists/{id}` | Artist with albums array. |
| GET | `/api/albums/{id}` | Album with tracks array. |
| GET | `/api/tracks/{id}` | Single track (used for now-playing detail / direct-link). |
| GET | `/api/search?q=` | Unified search returning `{artists, albums, tracks}` arrays. |
| GET | `/api/stream/{trackId}` | Audio stream with `Accept-Ranges: bytes` for scrubbing. Cookie-auth works because `<audio src>` sends cookies; bearer works for non-browser clients. |
| GET | `/api/cover/{albumId}` | Album cover art, sized via `?size=` query. |
### Implementation approach
- Handlers are thin — argument parsing, call to existing sqlc queries in `internal/db/dbq/*`, JSON marshalling.
- Shared types go in `internal/api/types.go` (request/response shapes). These map 1:1 to TypeScript types in `web/src/lib/types.ts` (kept hand-synced; codegen deferred).
- Errors follow `{error: {code, message}}` with standard HTTP status codes. No Subsonic-style "status=failed" envelope on `/api/*`.
### Deferred endpoints (land with their backend milestone)
- M2: `POST /api/events/{play|skip|complete}`, `POST /api/tracks/{id}/star`, `DELETE /api/tracks/{id}/star`, `GET /api/me/recently-played`.
- M3: `GET /api/shuffle`, `POST /api/tracks/{id}/contextual-like`, related artist/session queries.
- M4: `POST /api/radio`, `GET /api/radio/{id}`, ListenBrainz settings.
- M5: Lidarr proxy + quarantine endpoints.
## Authentication
### Login flow
1. SPA `POST /api/auth/login` with `{username, password}`.
2. Server verifies against `password_hash`, creates a session row (opaque 32-byte random token), returns `{token, user}`.
3. Server also sets `Set-Cookie: session=<token>; HttpOnly; Secure; SameSite=Strict; Path=/`.
4. SPA stores the user object in a Svelte store; the cookie travels automatically. **SPA does not persist the bearer token** — it's returned only so Flutter can consume the same endpoint later.
### Middleware
- `internal/auth.RequireUser` middleware resolves the caller in this order:
1. `Cookie: session=<token>` → sessions table lookup.
2. `Authorization: Bearer <token>` → sessions table lookup.
3. Legacy Subsonic params (only within `/rest/*` — unchanged from today).
- On success, user is placed in request context (same pattern as `UserFromContext` used in `internal/subsonic`).
- Unauthenticated requests to `/api/*``401 {error: {code: "unauthenticated", message: "..."}}`.
### Sessions table
- New migration: `sessions(id uuid pk, user_id uuid fk, token_hash bytea, created_at, last_seen_at, user_agent text)`.
- Token is hashed in DB (sha256) so a DB leak doesn't grant active sessions.
- Logout deletes the session row; cookie is expired via `Set-Cookie: session=; Max-Age=0`.
### Why this design
- Web is cookie-safe (no token in JS / localStorage → no XSS extraction).
- Flutter later uses the same `POST /api/auth/login` endpoint; it ignores the cookie and sends `Authorization: Bearer` on every call.
- OIDC, when it eventually lands, plugs in at the same login-success point: the callback handler mints the same `{token + cookie}` response. The rest of the app doesn't change.
## Shell layout
### Structure
```
+-------------------------------------------------------------+
| SIDEBAR | MAIN CONTENT |
| | |
| Minstrel | (route content — artists list, |
| | album grid, search results, |
| ▸ Home | artist detail, settings, etc.) |
| ▸ Library | |
| ▸ Search | |
| ▸ Recent | |
| ▸ Settings | |
| | |
| — Admin — | |
| ▸ Users | |
| ▸ Scan | |
| ▸ Quarantine | |
| | |
+-------------------------------------------------------------+
| PLAYER BAR: art · title / artist · controls · scrub · time |
+-------------------------------------------------------------+
```
### Components
- `Sidebar.svelte` — brand, primary nav, admin section (items render as disabled until their backend exists).
- `PlayerBar.svelte` — persistent across route changes. Owns a single `<audio>` element via `bind:this`. Reads from and writes to the `player` Svelte store.
- `RouteShell.svelte` — root layout. Mounts sidebar + outlet + player bar. Subscribes `player` store to `<audio>` element events (`timeupdate`, `ended`, `error`).
- Routes: `/login`, `/` (home), `/library`, `/artists/[id]`, `/albums/[id]`, `/search`, `/settings`, `/admin/*`.
### Player state (Svelte store)
```typescript
interface PlayerState {
queue: Track[];
queueIndex: number;
isPlaying: boolean;
currentTime: number; // seconds
duration: number; // seconds
volume: number; // 0..1
}
```
- Queue operations: `enqueue`, `playNow`, `prev`, `next`, `seek`, `setVolume`.
- Store methods kick the `<audio>` element via imperative calls (`audio.play()`, `audio.currentTime = …`).
- The store is the only place that writes player state; components read via `$player` subscriptions.
### Theme
- Dark-only for first cut. Colors via CSS custom properties in `:root`. Palette: neutral dark grays (`#14161a` / `#1a1d22` / `#2a2f36`), light text (`#cdd3db` / `#e8ecf2`), accent color picked during implementation — scaffold works with any single-hue accent.
- Layout convention: left sidebar width `240px`, player bar height `72px`, content scrolls independently of shell.
### Keyboard shortcuts
- `Space` — play/pause (blocked when focus is in an input).
- `ArrowLeft` / `ArrowRight` — skip back / forward 5s.
- `Shift+ArrowLeft` / `Shift+ArrowRight` — prev / next track.
- `/` — focus search input.
## First-cut feature scope
- Login / logout.
- Artist list (sortable, searchable within-list).
- Artist detail → albums grid.
- Album detail → track list with inline play buttons.
- Global search: `?q=` against tracks + albums + artists, results grouped.
- Player: queue, play/pause/seek/prev/next, volume, keyboard shortcuts, auto-advance on `ended`.
- Now-playing: persistent player bar always visible; clicking it opens an expanded queue panel.
## Deferred
Tracked in Fable milestones 2730 (M2M5). Each milestone adds the corresponding UI alongside its backend work.
- **M2:** Star/unstar UI, recently-played view, event reporting from player (play/skip/complete at the appropriate thresholds).
- **M3:** Smart-shuffle entry points, contextual-like controls.
- **M4:** Radio seed / start UI, ListenBrainz account connection in Settings.
- **M5:** Lidarr quarantine admin, "suggested additions" panel on radio.
- **Post-v1:** Stats dashboards, user management UI, OIDC login, light theme, mobile-responsive polish.
## Testing
- **Svelte (Vitest):** component tests for non-trivial units only — `player` store (queue/seek/advance logic), `api.ts` (error parsing / auth header injection), auth store. Skip snapshot tests and boilerplate component rendering.
- **Go (`internal/api`):** handler-level tests per the existing pattern in `internal/subsonic/*_test.go` — httptest ResponseRecorder, context-injected user, JSON decode of response body.
- **No E2E / Playwright** for first cut. If a full-stack smoke test becomes valuable, add one headless Playwright scenario (login → play a track) later.
## Repo structure
```
minstrel/
├── cmd/minstrel/ # Go binary (unchanged)
├── internal/
│ ├── api/ # NEW: /api/* handlers + shared types
│ │ ├── auth.go
│ │ ├── library.go
│ │ ├── search.go
│ │ ├── stream.go
│ │ ├── types.go
│ │ └── *_test.go
│ ├── auth/ # existing; add session token logic
│ ├── subsonic/ # unchanged
│ ├── library/ # unchanged
│ ├── db/ # + migration for sessions
│ └── server/ # Router() wires /api/* alongside /rest/*
├── web/ # NEW: SvelteKit project
│ ├── src/
│ │ ├── lib/
│ │ │ ├── api.ts # fetch wrapper
│ │ │ ├── stores/
│ │ │ │ ├── player.ts
│ │ │ │ └── auth.ts
│ │ │ ├── components/ # Sidebar, PlayerBar, etc.
│ │ │ └── types.ts # TypeScript mirrors of internal/api/types.go
│ │ ├── routes/
│ │ │ ├── +layout.svelte
│ │ │ ├── +page.svelte # Home
│ │ │ ├── login/+page.svelte
│ │ │ ├── artists/+page.svelte
│ │ │ ├── artists/[id]/+page.svelte
│ │ │ ├── albums/[id]/+page.svelte
│ │ │ ├── search/+page.svelte
│ │ │ └── settings/+page.svelte
│ │ └── app.html
│ ├── static/
│ ├── svelte.config.js # adapter-static + fallback
│ ├── vite.config.ts # /api + /rest proxy for dev
│ ├── package.json
│ └── tsconfig.json
├── docs/
├── Dockerfile # multi-stage: node → go → slim
├── docker-compose.yml
└── ...
```
## Open questions resolved
- **Framework:** Svelte (B in brainstorm).
- **Packaging:** Single binary with embedded static (A in brainstorm).
- **Auth:** Cookie + bearer from same endpoint (C in brainstorm).
- **API shape:** New `/api/*` surface (B in brainstorm).
- **Scope:** Rich target, scaffold first, features woven through M2M5 (C in brainstorm with sequencing plan).
- **Layout:** Left sidebar + bottom persistent player (A in brainstorm).
## Risks / decisions to revisit during implementation
- **Cover art sizing.** `/rest/getCoverArt` does thumbnail scaling via ffmpeg (or similar). `/api/cover/{id}?size=` may need the same logic; could call the existing Subsonic helper internally rather than re-implementing.
- **TypeScript / Go type sync.** Manual hand-sync is fine at first-cut scale (maybe 10 types). If it becomes a churn point, introduce codegen (oapi-codegen / sqlc-like generator) — not first-cut scope.
- **Stream auth with `<audio>` elements.** Cookies carry automatically. Verify Chromium, Firefox, Safari all forward the cookie on `<audio src>` requests — we believe they do; flag if not.
- **Keyboard shortcut collisions.** Space-bar for play/pause conflicts with page scroll. Only capture when body isn't scrolled-to-focus-element; easy to get wrong. Write a small test when implementing.