From 26609d8b6233961b35b69414ec82dfe19d2aa2ed Mon Sep 17 00:00:00 2001
From: Samu
Date: Sat, 29 Aug 2026 19:16:27 +0200
Subject: [PATCH] docs: frontend implementation plan (12 tasks)
---
.../plans/2026-08-29-record-shop-frontend.md | 3980 +++++++++++++++++
1 file changed, 3980 insertions(+)
create mode 100644 docs/superpowers/plans/2026-08-29-record-shop-frontend.md
diff --git a/docs/superpowers/plans/2026-08-29-record-shop-frontend.md b/docs/superpowers/plans/2026-08-29-record-shop-frontend.md
new file mode 100644
index 0000000..3f9b77a
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-29-record-shop-frontend.md
@@ -0,0 +1,3980 @@
+# record-shop Frontend (Plan 2) 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:** Build the record-shop React SPA — mobile-first PWA with phone barcode scanning, library browsing with rip-status filters, item management, text search, and settings — completing the app described in `docs/superpowers/specs/2026-08-29-record-shop-design.md`.
+
+**Architecture:** React SPA in `web/`, served by the existing Fastify backend (it already serves `web/dist` with SPA fallback and `/artwork` — backend Task 17). All data flows through the REST API from plan 1 (contract below — treat it as frozen; do not modify `server/` except the Dockerfile in Task 12). Barcode decoding is client-side: native `BarcodeDetector` when available, `@zxing/library` fallback.
+
+**Tech Stack:** React 19, Vite 7, Tailwind CSS v4, react-router-dom 7, @zxing/library. Tests: Vitest 3 (jsdom) + @testing-library/react. Dev servers: `concurrently`.
+
+**Prerequisites:** Backend merged on `main` (82/82 tests passing). Work on branch `record-shop-frontend`. Node >= 20.
+
+---
+
+## API contract (frozen, from plan 1)
+
+All endpoints are same-origin, cookie-authenticated (`rs_session`, httpOnly — fetch with `credentials: 'same-origin'` default works, but the Vite dev proxy must forward cookies, which it does by default).
+
+| Method | Path | Success | Errors |
+| --- | --- | --- | --- |
+| GET | `/api/setup` | `{needed: boolean}` | — |
+| POST | `/api/setup` | 200 `{user}` + cookie | 400 `invalid_input` (+detail), 403 `setup_already_done` |
+| POST | `/api/login` | 200 `{user}` + cookie | 401 `invalid_credentials` |
+| POST | `/api/logout` | `{ok:true}` | — |
+| GET | `/api/me` | `{user}` | 401 `unauthorized` |
+| GET | `/api/users` | `{users}` | 401, 403 `forbidden` |
+| POST | `/api/users` | `{id,username,isAdmin}` | 400, 403, 409 `username_taken` |
+| DELETE | `/api/users/:id` | `{ok:true}` | 400 `cannot_delete_self`, 403, 404 |
+| GET | `/api/settings` | view (below) | 401 |
+| PUT | `/api/settings` | view (below) | 400 `invalid_input`, 400 `subsonic_auth`/`subsonic_unreachable` (+detail) |
+| GET | `/api/lookup/barcode/:code` | `{candidates}` | 404 `not_found`, 409 `no_discogs_token`, 429 `discogs_rate_limited`, 502 `discogs_auth`/`discogs_error`/`discogs_unreachable` |
+| GET | `/api/lookup/search?q=&format=` | `{candidates}` | same + 400 `missing_query` |
+| GET | `/api/lookup/release/:id` | `{release, duplicate, ripMatch, matchCandidates}` | 409 no token, 429/502 as above, 400 `invalid_input` |
+| GET | `/api/collection?format=&ripped=&q=` | `{items, counts}` | 401 |
+| POST | `/api/collection` | item | 409 `duplicate`, 409 `no_discogs_token`, 400 `invalid_input`, 404 `album_not_found`, 429/502 |
+| GET | `/api/collection/:id` | item | 404 |
+| DELETE | `/api/collection/:id` | `{ok:true}` | 404 |
+| PATCH | `/api/collection/:id/rip` | item | 400 `invalid_input`, 404 |
+| POST | `/api/collection/:id/match` | item | 404 `not_found`/`album_not_found` |
+| GET | `/api/library/sync` | `{status, error, lastSyncedAt, albums}` | 401 |
+| POST | `/api/library/sync` | 202 `{status,...}` | 409 `no_subsonic_config` |
+| GET | `/api/library/albums?q=` | `{albums}` | 401 |
+
+Error body shape everywhere: `{error: string, detail?: string}`.
+
+**Types (JSON shapes):**
+
+```ts
+interface User { id: number; username: string; isAdmin: boolean }
+interface SettingsView {
+ hasDiscogsToken: boolean
+ discogsTokenMasked: string | null
+ subsonicUrl: string | null
+ subsonicUsername: string | null
+ hasSubsonicPassword: boolean
+}
+interface Candidate {
+ id: number; artist: string; title: string; year: number | null
+ formats: string[]; labels: string[]; country: string | null
+ catno: string | null; thumbUrl: string | null
+}
+interface Release extends Candidate {
+ genres: string[]
+ tracklist: { position: string; title: string }[]
+ coverUrl: string | null
+ barcodes: string[]
+}
+interface ReleasePreview {
+ release: Release
+ duplicate: boolean
+ ripMatch: 'ripped' | 'not_ripped' | 'ambiguous'
+ matchCandidates: { id: number; title: string; artist: string }[]
+}
+interface Item {
+ id: number; discogsReleaseId: number; title: string; artist: string
+ year: number | null; formats: string[]; genres: string[]; labels: string[]
+ tracklist: { position: string; title: string }[]
+ catno: string | null; country: string | null
+ artworkUrl: string | null; barcodes: string[]; dateAdded: string
+ ripOverride: boolean | null
+ ripStatus: 'ripped' | 'not_ripped'
+}
+interface CollectionResponse {
+ items: Item[]
+ counts: { total: number; ripped: number; notRipped: number }
+}
+interface SyncState {
+ status: 'idle' | 'running' | 'done' | 'error'
+ error: string | null
+ lastSyncedAt: string | null
+ albums: number
+}
+interface DigitalAlbum { id: number; subsonicId: string; title: string; artist: string }
+```
+
+PUT `/api/settings` semantics: send only changed fields; **empty string clears** a stored value; absent fields are untouched. The response is the fresh view.
+
+## File structure (plan 2)
+
+```
+record-shop/
+├── package.json # MODIFY: deps + scripts (dev/build/typecheck)
+├── vitest.config.ts # MODIFY: projects (server node / web jsdom)
+├── Dockerfile # MODIFY: web build + copy dist
+├── web/
+│ ├── index.html
+│ ├── package.json → none # single root package (no workspace)
+│ ├── tsconfig.json
+│ ├── vite.config.ts
+│ ├── public/
+│ │ ├── icon.svg
+│ │ ├── manifest.webmanifest
+│ │ └── sw.js
+│ ├── src/
+│ │ ├── main.tsx # entry: router + providers + SW registration
+│ │ ├── App.tsx # routes: /setup /login + protected shell
+│ │ ├── api.ts # ApiError + typed api client
+│ │ ├── types.ts # contract types (copy from above)
+│ │ ├── auth.tsx # AuthProvider: setup gate + /api/me
+│ │ ├── shell.tsx # bottom tab bar layout (Library·Scan·Add·Settings)
+│ │ ├── pages/
+│ │ │ ├── SetupPage.tsx
+│ │ │ ├── LoginPage.tsx
+│ │ │ ├── LibraryPage.tsx
+│ │ │ ├── ItemPage.tsx
+│ │ │ ├── ScanPage.tsx
+│ │ │ ├── AddPage.tsx
+│ │ │ └── SettingsPage.tsx
+│ │ ├── scan/
+│ │ │ ├── reducer.ts # pure scan-flow state machine
+│ │ │ └── ConfirmView.tsx # release preview + rip banner + match picker
+│ │ ├── components/
+│ │ │ ├── Cover.tsx
+│ │ │ ├── CandidateCard.tsx
+│ │ │ └── Scanner.tsx # camera viewfinder component
+│ │ └── hooks/
+│ │ └── useBarcodeScanner.ts
+│ └── test/
+│ ├── setup.ts # cleanup between tests
+│ ├── smoke.test.tsx
+│ ├── auth.test.tsx
+│ ├── router.test.tsx
+│ ├── scanner.test.ts
+│ ├── reducer.test.ts
+│ ├── scanPage.test.tsx
+│ ├── library.test.tsx
+│ ├── item.test.tsx
+│ ├── add.test.tsx
+│ ├── settings.test.tsx
+│ └── pwa.test.ts
+```
+
+## UI layout (from spec)
+
+Bottom tab bar on mobile: **Library · Scan · Add · Settings**. Desktop renders the same app wider (max-w-3xl centered, larger grid) — responsive Tailwind, no separate layout.
+
+- **Library** — cover grid (3 cols mobile, 5–6 desktop). Filter chips: format (All/Vinyl/CD/Cassette) and rip state (All/Ripped/Not ripped) showing counts; search box; artist jump-list (derived client-side). Tap cover → `/item/:id`.
+- **Item** — big cover, metadata (label, cat#, year, formats, genres, tracklist), rip-status banner, manual rip toggle (ripped/not/auto), re-match section (search digital albums → link/unlink), barcode(s), Discogs link, remove.
+- **Scan** — full-screen camera with viewfinder overlay; the flow: detect → candidates → confirm → added.
+- **Add** — text search + format select → same candidate → confirm flow.
+- **Settings** — account (logout), Discogs token, Subsonic config, library sync (status + trigger), users (admin).
+
+---
+
+### Task 1: Web scaffold — Vite, React, Tailwind, Vitest (jsdom), dev script
+
+**Files:**
+- Modify: `package.json`, `vitest.config.ts`
+- Create: `web/index.html`, `web/tsconfig.json`, `web/vite.config.ts`, `web/src/main.tsx`, `web/src/App.tsx`, `web/src/styles.css`, `web/test/setup.ts`, `web/test/smoke.test.tsx`
+
+- [ ] **Step 1: Install web dependencies**
+
+```bash
+npm install react react-dom react-router-dom
+npm install -D @vitejs/plugin-react vite tailwindcss @tailwindcss/vite jsdom @testing-library/react @testing-library/user-event @testing-library/dom concurrently @types/react @types/react-dom
+```
+
+(Do NOT add @zxing/library yet — Task 4. `typescript`, `vitest`, `tsx` are already installed.)
+
+- [ ] **Step 2: Update `package.json` scripts** — replace the scripts block with:
+
+```json
+ "scripts": {
+ "dev:server": "tsx watch server/src/index.ts",
+ "dev:web": "vite",
+ "dev": "concurrently -k \"npm:dev:server\" \"npm:dev:web\"",
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "build:server": "tsc -p server/tsconfig.build.json",
+ "build:web": "vite build",
+ "build": "npm run build:server && npm run build:web",
+ "start": "node server/dist/index.js",
+ "typecheck": "tsc -p server/tsconfig.json --noEmit && tsc -p web --noEmit"
+ },
+```
+
+- [ ] **Step 3: Update `vitest.config.ts`** — replace the whole file with the projects split (server = node, web = jsdom):
+
+```ts
+import { defineConfig } from 'vitest/config'
+
+export default defineConfig({
+ test: {
+ projects: [
+ {
+ test: {
+ name: 'server',
+ include: ['server/test/**/*.test.ts'],
+ environment: 'node',
+ },
+ },
+ {
+ test: {
+ name: 'web',
+ include: ['web/test/**/*.test.tsx'],
+ environment: 'jsdom',
+ setupFiles: ['web/test/setup.ts'],
+ },
+ },
+ ],
+ },
+})
+```
+
+- [ ] **Step 4: Create `web/tsconfig.json`**
+
+```json
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUncheckedIndexedAccess": true,
+ "forceConsistentCasingInFileNames": true,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "resolveJsonModule": true,
+ "noEmit": true,
+ "isolatedModules": true,
+ "types": ["vite/client"]
+ },
+ "include": ["src", "test", "vite.config.ts"]
+}
+```
+
+- [ ] **Step 5: Create `web/vite.config.ts`**
+
+```ts
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+import tailwindcss from '@tailwindcss/vite'
+
+export default defineConfig({
+ plugins: [react(), tailwindcss()],
+ root: __dirname,
+ server: {
+ proxy: {
+ '/api': 'http://localhost:3000',
+ '/artwork': 'http://localhost:3000',
+ },
+ },
+ build: {
+ outDir: 'dist',
+ },
+})
+```
+
+Note: `__dirname` is unavailable in an ESM-loaded config; Vite supports it via its config loader. If it errors, replace `root: __dirname` with `root: import.meta.dirname` (Node >= 20.11).
+
+- [ ] **Step 6: Create `web/index.html`**
+
+```html
+
+
+
+
+
+
+ record-shop
+
+
+
+
+
+
+```
+
+- [ ] **Step 7: Create `web/src/styles.css`**
+
+```css
+@import 'tailwindcss';
+```
+
+- [ ] **Step 8: Create `web/src/App.tsx`** (placeholder shell — replaced in Task 3)
+
+```tsx
+export default function App() {
+ return (
+
+
record-shop
+
+ )
+}
+```
+
+- [ ] **Step 9: Create `web/src/main.tsx`**
+
+```tsx
+import React from 'react'
+import ReactDOM from 'react-dom/client'
+import App from './App.js'
+import './styles.css'
+
+ReactDOM.createRoot(document.getElementById('root')!).render(
+
+
+
+)
+```
+
+- [ ] **Step 10: Create `web/test/setup.ts`**
+
+```ts
+import { afterEach } from 'vitest'
+import { cleanup } from '@testing-library/react'
+
+afterEach(() => {
+ cleanup()
+})
+```
+
+- [ ] **Step 11: Write the failing smoke test `web/test/smoke.test.tsx`**
+
+```tsx
+import { describe, it, expect } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import App from '../src/App.js'
+
+describe('App', () => {
+ it('renders the app title', () => {
+ render( )
+ expect(screen.getByRole('heading', { name: 'record-shop' })).toBeTruthy()
+ })
+})
+```
+
+- [ ] **Step 12: Run tests to verify they pass (this is the scaffold gate)**
+
+Run: `npm test && npm run typecheck`
+Expected: ALL PASS — 82 server tests (untouched) + 1 new web smoke test. Typecheck clean for both projects.
+If the web project fails to discover jsdom or JSX, check the vitest projects config and that `@testing-library/react` is installed.
+
+- [ ] **Step 13: Verify the dev server builds**
+
+Run: `npm run build:web` — expect `web/dist/` produced with `index.html`.
+
+- [ ] **Step 14: Commit**
+
+```bash
+git add -A && git commit -m "feat: react/vite/tailwind web scaffold with jsdom tests"
+```
+
+---
+
+### Task 2: API client, types, auth context
+
+**Files:**
+- Create: `web/src/types.ts`, `web/src/api.ts`, `web/src/auth.tsx`
+- Test: `web/test/auth.test.tsx`
+
+- [ ] **Step 1: Create `web/src/types.ts`** — copy the interface block from the "API contract" section above (User, SettingsView, Candidate, Release, ReleasePreview, Item, CollectionResponse, SyncState, DigitalAlbum) into:
+
+```ts
+// Contract types — mirrors the Fastify API (plan 1). Do not rename fields.
+export interface User {
+ id: number
+ username: string
+ isAdmin: boolean
+}
+export interface SettingsView {
+ hasDiscogsToken: boolean
+ discogsTokenMasked: string | null
+ subsonicUrl: string | null
+ subsonicUsername: string | null
+ hasSubsonicPassword: boolean
+}
+export interface Candidate {
+ id: number
+ artist: string
+ title: string
+ year: number | null
+ formats: string[]
+ labels: string[]
+ country: string | null
+ catno: string | null
+ thumbUrl: string | null
+}
+export interface Release extends Candidate {
+ genres: string[]
+ tracklist: { position: string; title: string }[]
+ coverUrl: string | null
+ barcodes: string[]
+}
+export interface ReleasePreview {
+ release: Release
+ duplicate: boolean
+ ripMatch: 'ripped' | 'not_ripped' | 'ambiguous'
+ matchCandidates: { id: number; title: string; artist: string }[]
+}
+export interface Item {
+ id: number
+ discogsReleaseId: number
+ title: string
+ artist: string
+ year: number | null
+ formats: string[]
+ genres: string[]
+ labels: string[]
+ tracklist: { position: string; title: string }[]
+ catno: string | null
+ country: string | null
+ artworkUrl: string | null
+ barcodes: string[]
+ dateAdded: string
+ ripOverride: boolean | null
+ ripStatus: 'ripped' | 'not_ripped'
+}
+export interface CollectionResponse {
+ items: Item[]
+ counts: { total: number; ripped: number; notRipped: number }
+}
+export interface SyncState {
+ status: 'idle' | 'running' | 'done' | 'error'
+ error: string | null
+ lastSyncedAt: string | null
+ albums: number
+}
+export interface DigitalAlbum {
+ id: number
+ subsonicId: string
+ title: string
+ artist: string
+}
+```
+
+- [ ] **Step 2: Create `web/src/api.ts`**
+
+```ts
+import type {
+ Candidate,
+ CollectionResponse,
+ DigitalAlbum,
+ Item,
+ ReleasePreview,
+ SettingsView,
+ SyncState,
+ User,
+} from './types.js'
+
+export class ApiError extends Error {
+ constructor(
+ public status: number,
+ public code: string,
+ public detail?: string
+ ) {
+ super(detail ? `${code}: ${detail}` : code)
+ }
+}
+
+async function request(path: string, init?: RequestInit): Promise {
+ const res = await fetch(path, {
+ headers: { Accept: 'application/json' },
+ ...init,
+ })
+ if (!res.ok) {
+ let code = 'unknown_error'
+ let detail: string | undefined
+ try {
+ const body = (await res.json()) as { error?: string; detail?: string }
+ code = body.error ?? code
+ detail = body.detail
+ } catch {
+ // non-JSON error body
+ }
+ throw new ApiError(res.status, code, detail)
+ }
+ return (await res.json()) as T
+}
+
+function post(path: string, payload?: unknown): Promise {
+ return request(path, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: payload === undefined ? undefined : JSON.stringify(payload),
+ })
+}
+
+export const api = {
+ setupStatus: () => request<{ needed: boolean }>('/api/setup'),
+ setup: (username: string, password: string) => post<{ user: User }>('/api/setup', { username, password }),
+ login: (username: string, password: string) => post<{ user: User }>('/api/login', { username, password }),
+ logout: () => post<{ ok: boolean }>('/api/logout'),
+ me: () => request<{ user: User }>('/api/me'),
+
+ listUsers: () => request<{ users: User[] }>('/api/users'),
+ createUser: (username: string, password: string) => post('/api/users', { username, password }),
+ deleteUser: (id: number) => request<{ ok: boolean }>(`/api/users/${id}`, { method: 'DELETE' }),
+
+ getSettings: () => request('/api/settings'),
+ putSettings: (payload: Partial>) =>
+ request('/api/settings', {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ }),
+
+ lookupBarcode: (code: string) => request<{ candidates: Candidate[] }>(`/api/lookup/barcode/${encodeURIComponent(code)}`),
+ lookupSearch: (q: string, format?: string) =>
+ request<{ candidates: Candidate[] }>(
+ `/api/lookup/search?q=${encodeURIComponent(q)}${format ? `&format=${encodeURIComponent(format)}` : ''}`
+ ),
+ getReleasePreview: (id: number) => request(`/api/lookup/release/${id}`),
+
+ listCollection: (params: { format?: string; ripped?: string; q?: string } = {}) => {
+ const usp = new URLSearchParams()
+ if (params.format) usp.set('format', params.format)
+ if (params.ripped) usp.set('ripped', params.ripped)
+ if (params.q) usp.set('q', params.q)
+ const qs = usp.toString()
+ return request(`/api/collection${qs ? `?${qs}` : ''}`)
+ },
+ addToCollection: (body: { releaseId: number; barcode?: string; matchAlbumId?: number }) =>
+ post- ('/api/collection', body),
+ getItem: (id: number) => request
- (`/api/collection/${id}`),
+ deleteItem: (id: number) => request<{ ok: boolean }>(`/api/collection/${id}`, { method: 'DELETE' }),
+ setRip: (id: number, ripped: boolean | null) =>
+ request
- (`/api/collection/${id}/rip`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ ripped }),
+ }),
+ setMatch: (id: number, albumId: number | null) =>
+ post
- (`/api/collection/${id}/match`, { albumId }),
+
+ syncStatus: () => request
('/api/library/sync'),
+ startSync: () => request('/api/library/sync', { method: 'POST' }),
+ searchAlbums: (q: string) => request<{ albums: DigitalAlbum[] }>(`/api/library/albums?q=${encodeURIComponent(q)}`),
+}
+```
+
+- [ ] **Step 3: Write the failing test `web/test/auth.test.tsx`**
+
+```tsx
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { render, screen, waitFor } from '@testing-library/react'
+import { AuthProvider, useAuth } from '../src/auth.js'
+
+function Probe() {
+ const { status, user, setupNeeded } = useAuth()
+ return (
+
+
status:{status}
+ {setupNeeded &&
setup-needed
}
+ {user &&
user:{user.username}
}
+
+ )
+}
+
+const fetchMock = vi.fn()
+
+beforeEach(() => {
+ fetchMock.mockReset()
+ vi.stubGlobal('fetch', fetchMock)
+})
+
+function jsonOnce(status: number, body: unknown) {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { 'content-type': 'application/json' },
+ })
+}
+
+describe('AuthProvider', () => {
+ it('reports setupNeeded when the server has no users', async () => {
+ fetchMock.mockResolvedValueOnce(jsonOnce(200, { needed: true }))
+ render(
+
+
+
+ )
+ expect(screen.getByText('status:loading')).toBeTruthy()
+ await waitFor(() => expect(screen.getByText('status:setup')).toBeTruthy())
+ expect(screen.getByText('setup-needed')).toBeTruthy()
+ expect(fetchMock).toHaveBeenCalledWith('/api/setup', expect.anything())
+ })
+
+ it('exposes the user when /api/me succeeds', async () => {
+ fetchMock
+ .mockResolvedValueOnce(jsonOnce(200, { needed: false }))
+ .mockResolvedValueOnce(jsonOnce(200, { user: { id: 1, username: 'sam', isAdmin: true } }))
+ render(
+
+
+
+ )
+ await waitFor(() => expect(screen.getByText('status:authenticated')).toBeTruthy())
+ expect(screen.getByText('user:sam')).toBeTruthy()
+ })
+
+ it('reports unauthenticated when /api/me is 401', async () => {
+ fetchMock
+ .mockResolvedValueOnce(jsonOnce(200, { needed: false }))
+ .mockResolvedValueOnce(jsonOnce(401, { error: 'unauthorized' }))
+ render(
+
+
+
+ )
+ await waitFor(() => expect(screen.getByText('status:unauthenticated')).toBeTruthy())
+ })
+})
+```
+
+- [ ] **Step 4: Run test to verify it fails**
+
+Run: `npx vitest run web/test/auth.test.tsx`
+Expected: FAIL — module `../src/auth.js` not found.
+
+- [ ] **Step 5: Create `web/src/auth.tsx`**
+
+```tsx
+import { createContext, useContext, useEffect, useState, useCallback, type ReactNode } from 'react'
+import { api } from './api.js'
+import type { User } from './types.js'
+
+export type AuthStatus = 'loading' | 'setup' | 'unauthenticated' | 'authenticated'
+
+interface AuthContextValue {
+ status: AuthStatus
+ user: User | null
+ setupNeeded: boolean
+ refresh: () => Promise
+ onSetupComplete: (user: User) => void
+ onLogin: (user: User) => void
+ onLogout: () => void
+}
+
+const AuthContext = createContext(null)
+
+export function AuthProvider({ children }: { children: ReactNode }) {
+ const [status, setStatus] = useState('loading')
+ const [user, setUser] = useState(null)
+ const [setupNeeded, setSetupNeeded] = useState(false)
+
+ const refresh = useCallback(async () => {
+ try {
+ const { needed } = await api.setupStatus()
+ if (needed) {
+ setSetupNeeded(true)
+ setStatus('setup')
+ return
+ }
+ setSetupNeeded(false)
+ const { user: me } = await api.me()
+ setUser(me)
+ setStatus('authenticated')
+ } catch {
+ setStatus('unauthenticated')
+ }
+ }, [])
+
+ useEffect(() => {
+ void refresh()
+ }, [refresh])
+
+ const onSetupComplete = useCallback((u: User) => {
+ setSetupNeeded(false)
+ setUser(u)
+ setStatus('authenticated')
+ }, [])
+ const onLogin = useCallback((u: User) => {
+ setUser(u)
+ setStatus('authenticated')
+ }, [])
+ const onLogout = useCallback(() => {
+ setUser(null)
+ setStatus('unauthenticated')
+ }, [])
+
+ return (
+
+ {children}
+
+ )
+}
+
+export function useAuth(): AuthContextValue {
+ const ctx = useContext(AuthContext)
+ if (!ctx) throw new Error('useAuth outside AuthProvider')
+ return ctx
+}
+```
+
+- [ ] **Step 6: Run tests to verify they pass**
+
+Run: `npm test && npm run typecheck`
+Expected: ALL PASS (83 total).
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add -A && git commit -m "feat: typed api client and auth context with setup gate"
+```
+
+---
+
+### Task 3: Router, app shell, setup and login pages
+
+**Files:**
+- Create: `web/src/shell.tsx`, `web/src/pages/SetupPage.tsx`, `web/src/pages/LoginPage.tsx`, `web/src/pages/LibraryPage.tsx` (stub), `web/src/pages/ScanPage.tsx` (stub), `web/src/pages/AddPage.tsx` (stub), `web/src/pages/SettingsPage.tsx` (stub)
+- Modify: `web/src/main.tsx`, `web/src/App.tsx`
+- Test: `web/test/router.test.tsx`
+
+- [ ] **Step 1: Write the failing test `web/test/router.test.tsx`**
+
+```tsx
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import App from '../src/App.js'
+
+const fetchMock = vi.fn()
+
+beforeEach(() => {
+ fetchMock.mockReset()
+ vi.stubGlobal('fetch', fetchMock)
+ window.history.replaceState(null, '', '/')
+})
+
+function json(status: number, body: unknown) {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { 'content-type': 'application/json' },
+ })
+}
+
+function loggedInServer() {
+ fetchMock.mockImplementation((url: string) => {
+ if (url === '/api/setup') return Promise.resolve(json(200, { needed: false }))
+ if (url === '/api/me') return Promise.resolve(json(200, { user: { id: 1, username: 'sam', isAdmin: true } }))
+ return Promise.resolve(json(404, { error: 'not_found' }))
+ })
+}
+
+describe('routing', () => {
+ it('shows the setup form when the server needs setup', async () => {
+ fetchMock.mockImplementation((url: string) =>
+ url === '/api/setup' ? Promise.resolve(json(200, { needed: true })) : Promise.resolve(json(404, {}))
+ )
+ render( )
+ await waitFor(() => expect(screen.getByRole('heading', { name: /welcome/i })).toBeTruthy())
+ })
+
+ it('shows the login form when unauthenticated', async () => {
+ fetchMock.mockImplementation((url: string) => {
+ if (url === '/api/setup') return Promise.resolve(json(200, { needed: false }))
+ return Promise.resolve(json(401, { error: 'unauthorized' }))
+ })
+ render( )
+ await waitFor(() => expect(screen.getByRole('heading', { name: /sign in/i })).toBeTruthy())
+ })
+
+ it('lands on Library with the tab bar when authenticated', async () => {
+ loggedInServer()
+ render( )
+ await waitFor(() => expect(screen.getByRole('tab', { name: /library/i })).toBeTruthy())
+ for (const tab of ['Library', 'Scan', 'Add', 'Settings']) {
+ expect(screen.getByRole('tab', { name: new RegExp(tab, 'i') })).toBeTruthy()
+ }
+ })
+
+ it('login form authenticates and enters the app', async () => {
+ fetchMock.mockImplementation((url: string) => {
+ if (url === '/api/setup') return Promise.resolve(json(200, { needed: false }))
+ if (url === '/api/me') return Promise.resolve(json(401, { error: 'unauthorized' }))
+ if (url === '/api/login') {
+ return Promise.resolve(json(200, { user: { id: 1, username: 'sam', isAdmin: true } }))
+ }
+ return Promise.resolve(json(404, {}))
+ })
+ render( )
+ await waitFor(() => expect(screen.getByLabelText(/username/i)).toBeTruthy())
+ await userEvent.type(screen.getByLabelText(/username/i), 'sam')
+ await userEvent.type(screen.getByLabelText(/password/i), 'password123')
+ await userEvent.click(screen.getByRole('button', { name: /sign in/i }))
+ await waitFor(() => expect(screen.getByRole('tab', { name: /library/i })).toBeTruthy())
+ })
+
+ it('setup form creates the admin account and enters the app', async () => {
+ fetchMock.mockImplementation((url: string, init?: RequestInit) => {
+ if (url === '/api/setup' && (!init || !init.method || init.method === 'GET')) {
+ return Promise.resolve(json(200, { needed: true }))
+ }
+ if (url === '/api/setup' && init?.method === 'POST') {
+ return Promise.resolve(json(200, { user: { id: 1, username: 'boss', isAdmin: true } }))
+ }
+ return Promise.resolve(json(404, {}))
+ })
+ render( )
+ await waitFor(() => expect(screen.getByLabelText(/username/i)).toBeTruthy())
+ await userEvent.type(screen.getByLabelText(/username/i), 'boss')
+ await userEvent.type(screen.getByLabelText(/password/i), 'password123')
+ await userEvent.click(screen.getByRole('button', { name: /create/i }))
+ await waitFor(() => expect(screen.getByRole('tab', { name: /library/i })).toBeTruthy())
+ })
+
+ it('tab navigation switches pages', async () => {
+ loggedInServer()
+ render( )
+ await waitFor(() => expect(screen.getByRole('tab', { name: /settings/i })).toBeTruthy())
+ await userEvent.click(screen.getByRole('tab', { name: /settings/i }))
+ await waitFor(() => expect(screen.getByRole('heading', { name: /settings/i })).toBeTruthy())
+ expect(screen.getByText(/account/i)).toBeTruthy()
+ })
+})
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `npx vitest run web/test/router.test.tsx`
+Expected: FAIL — no routes/labels exist.
+
+- [ ] **Step 3: Create `web/src/shell.tsx`** (bottom tab bar layout)
+
+```tsx
+import { NavLink, Outlet } from 'react-router-dom'
+import type { ReactNode } from 'react'
+
+const TABS: { to: string; label: string; icon: ReactNode }[] = [
+ {
+ to: '/library',
+ label: 'Library',
+ icon: (
+
+
+
+
+ ),
+ },
+ {
+ to: '/scan',
+ label: 'Scan',
+ icon: (
+
+
+
+ ),
+ },
+ {
+ to: '/add',
+ label: 'Add',
+ icon: (
+
+
+
+
+ ),
+ },
+ {
+ to: '/settings',
+ label: 'Settings',
+ icon: (
+
+
+
+
+ ),
+ },
+]
+
+export default function Shell({ title }: { title?: string }) {
+ return (
+
+
+ {title ?? 'record-shop'}
+
+
+
+
+
+
+ {TABS.map((tab) => (
+
+ `flex flex-1 flex-col items-center gap-1 py-2 text-xs ${
+ isActive ? 'text-emerald-400' : 'text-neutral-400'
+ }`
+ }
+ >
+ {tab.icon}
+ {tab.label}
+
+ ))}
+
+
+
+ )
+}
+```
+
+- [ ] **Step 4: Create the three stub pages** — each is a full placeholder replaced by later tasks:
+
+`web/src/pages/LibraryPage.tsx`:
+```tsx
+export default function LibraryPage() {
+ return Library goes here.
+}
+```
+
+`web/src/pages/ScanPage.tsx`:
+```tsx
+export default function ScanPage() {
+ return Scanner goes here.
+}
+```
+
+`web/src/pages/AddPage.tsx`:
+```tsx
+export default function AddPage() {
+ return Search goes here.
+}
+```
+
+`web/src/pages/SettingsPage.tsx`:
+```tsx
+import { useAuth } from '../auth.js'
+
+export default function SettingsPage() {
+ const { user, onLogout } = useAuth()
+ return (
+
+ Settings
+ Account
+
+ {user?.username} {user?.isAdmin ? '(admin)' : ''}
+
+
+ )
+}
+```
+
+- [ ] **Step 5: Create `web/src/pages/SetupPage.tsx`**
+
+```tsx
+import { useState, type FormEvent } from 'react'
+import { api, ApiError } from '../api.js'
+import { useAuth } from '../auth.js'
+
+export default function SetupPage() {
+ const { onSetupComplete } = useAuth()
+ const [username, setUsername] = useState('')
+ const [password, setPassword] = useState('')
+ const [error, setError] = useState(null)
+ const [busy, setBusy] = useState(false)
+
+ async function submit(e: FormEvent) {
+ e.preventDefault()
+ setBusy(true)
+ setError(null)
+ try {
+ const { user } = await api.setup(username, password)
+ onSetupComplete(user)
+ } catch (err) {
+ setError(err instanceof ApiError ? err.detail ?? err.code : 'Something went wrong')
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ return (
+
+ )
+}
+```
+
+- [ ] **Step 6: Create `web/src/pages/LoginPage.tsx`**
+
+```tsx
+import { useState, type FormEvent } from 'react'
+import { api, ApiError } from '../api.js'
+import { useAuth } from '../auth.js'
+
+export default function LoginPage() {
+ const { onLogin } = useAuth()
+ const [username, setUsername] = useState('')
+ const [password, setPassword] = useState('')
+ const [error, setError] = useState(null)
+ const [busy, setBusy] = useState(false)
+
+ async function submit(e: FormEvent) {
+ e.preventDefault()
+ setBusy(true)
+ setError(null)
+ try {
+ const { user } = await api.login(username, password)
+ onLogin(user)
+ } catch (err) {
+ setError(err instanceof ApiError && err.status === 401 ? 'Wrong username or password' : 'Something went wrong')
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ return (
+
+ )
+}
+```
+
+- [ ] **Step 7: Modify `web/src/App.tsx`** — routes + auth gating. Replace the whole file:
+
+```tsx
+import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
+import { AuthProvider, useAuth } from './auth.js'
+import Shell from './shell.js'
+import SetupPage from './pages/SetupPage.js'
+import LoginPage from './pages/LoginPage.js'
+import LibraryPage from './pages/LibraryPage.js'
+import ScanPage from './pages/ScanPage.js'
+import AddPage from './pages/AddPage.js'
+import SettingsPage from './pages/SettingsPage.js'
+
+function Gate({ children }: { children: React.ReactNode }) {
+ const { status } = useAuth()
+ if (status === 'loading') {
+ return
+ }
+ if (status === 'setup') return
+ if (status === 'unauthenticated') return
+ return <>{children}>
+}
+
+export default function App() {
+ return (
+
+
+
+ } />
+ } />
+
+
+
+ }
+ >
+ } />
+ } />
+ } />
+ } />
+
+ } />
+
+
+
+ )
+}
+```
+
+- [ ] **Step 8: Modify `web/src/main.tsx`** — import path fix for bundler resolution:
+
+```tsx
+import React from 'react'
+import ReactDOM from 'react-dom/client'
+import App from './App'
+import './styles.css'
+
+ReactDOM.createRoot(document.getElementById('root')!).render(
+
+
+
+)
+```
+
+(From here on, use extensionless relative imports in `web/src` — bundler moduleResolution handles them.)
+
+- [ ] **Step 9: Run tests to verify they pass**
+
+Run: `npm test && npm run typecheck`
+Expected: ALL PASS (83 + 6 new = 89).
+
+- [ ] **Step 10: Commit**
+
+```bash
+git add -A && git commit -m "feat: router, tab shell, setup and login pages"
+```
+
+---
+
+### Task 4: Barcode scanner — hook + camera component
+
+**Files:**
+- Install: `@zxing/library`
+- Create: `web/src/hooks/useBarcodeScanner.ts`, `web/src/components/Scanner.tsx`
+- Test: `web/test/scanner.test.ts`
+
+- [ ] **Step 1: Install the fallback decoder**
+
+```bash
+npm install @zxing/library
+```
+
+- [ ] **Step 2: Write the failing test `web/test/scanner.test.ts`**
+
+```ts
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+import { renderHook, waitFor, act } from '@testing-library/react'
+import { useBarcodeScanner, shouldEmit } from '../src/hooks/useBarcodeScanner.js'
+
+function fakeStream(): MediaStream {
+ return { getTracks: () => [{ stop: vi.fn() } as unknown as MediaStreamTrack] } as unknown as MediaStream
+}
+
+function stubMediaDevices(impl: () => Promise) {
+ vi.stubGlobal(
+ 'navigator',
+ Object.assign(Object.create(Object.getPrototypeOf(navigator)), navigator, {
+ mediaDevices: { getUserMedia: impl },
+ })
+ )
+}
+
+beforeEach(() => {
+ vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue()
+})
+
+afterEach(() => {
+ vi.unstubAllGlobals()
+ vi.restoreAllMocks()
+})
+
+describe('shouldEmit (cooldown dedup)', () => {
+ it('emits a new code, suppresses a rapid repeat, emits after cooldown', () => {
+ expect(shouldEmit(null, '123', 0, 1500)).toBe(true)
+ expect(shouldEmit({ code: '123', at: 100 }, '123', 500, 1500)).toBe(false)
+ expect(shouldEmit({ code: '123', at: 100 }, '456', 500, 1500)).toBe(true)
+ expect(shouldEmit({ code: '123', at: 100 }, '123', 1700, 1500)).toBe(true)
+ })
+})
+
+describe('useBarcodeScanner', () => {
+ it('detects a barcode via BarcodeDetector and calls onDetect', async () => {
+ const detections = [[], [{ rawValue: '5021592210629', format: 'ean_13' }]]
+ let call = 0
+ class FakeDetector {
+ constructor(_opts: { formats: string[] }) {
+ expect(_opts.formats).toEqual(['ean_13', 'upc_a', 'ean_8'])
+ }
+ async detect() {
+ return detections[Math.min(call++, 1)]!
+ }
+ }
+ ;(window as unknown as { BarcodeDetector?: unknown }).BarcodeDetector = FakeDetector
+ stubMediaDevices(() => Promise.resolve(fakeStream()))
+
+ const onDetect = vi.fn()
+ const videoRef = { current: document.createElement('video') } as React.RefObject
+ const { result } = renderHook(() => useBarcodeScanner(videoRef, onDetect, true))
+
+ await waitFor(() => expect(result.current.status).toBe('ready'))
+ await waitFor(() => expect(onDetect).toHaveBeenCalledWith('5021592210629'))
+ })
+
+ it('reports denied when camera permission is rejected', async () => {
+ stubMediaDevices(() =>
+ Promise.reject(new DOMException('denied', 'NotAllowedError'))
+ )
+ const videoRef = { current: document.createElement('video') } as React.RefObject
+ const { result } = renderHook(() => useBarcodeScanner(videoRef, () => {}, true))
+ await waitFor(() => expect(result.current.status).toBe('denied'))
+ })
+
+ it('stops the stream when enabled goes false', async () => {
+ const stream = fakeStream()
+ const stopSpy = vi.spyOn(stream.getTracks()[0]!, 'stop')
+ stubMediaDevices(() => Promise.resolve(stream))
+ ;(window as unknown as { BarcodeDetector?: unknown }).BarcodeDetector = class {
+ async detect() {
+ return []
+ }
+ }
+ const videoRef = { current: document.createElement('video') } as React.RefObject
+ const { result, rerender } = renderHook(
+ ({ enabled }) => useBarcodeScanner(videoRef, () => {}, enabled),
+ { initialProps: { enabled: true } }
+ )
+ await waitFor(() => expect(result.current.status).toBe('ready'))
+ rerender({ enabled: false })
+ expect(stopSpy).toHaveBeenCalled()
+ expect(result.current.status).toBe('idle')
+ })
+})
+```
+
+- [ ] **Step 3: Run test to verify it fails**
+
+Run: `npx vitest run web/test/scanner.test.ts`
+Expected: FAIL — module not found.
+
+- [ ] **Step 4: Implement `web/src/hooks/useBarcodeScanner.ts`**
+
+```ts
+import { useEffect, useRef, useState } from 'react'
+
+export type ScannerStatus = 'idle' | 'starting' | 'ready' | 'denied' | 'error'
+
+export const BARCODE_FORMATS = ['ean_13', 'upc_a', 'ean_8'] as const
+
+interface DetectedCode {
+ rawValue: string
+ format: string
+}
+interface BarcodeDetectorLike {
+ detect(source: HTMLVideoElement): Promise
+}
+type BarcodeDetectorCtor = new (options: { formats: string[] }) => BarcodeDetectorLike
+
+export function getBarcodeDetectorCtor(): BarcodeDetectorCtor | null {
+ return (window as unknown as { BarcodeDetector?: BarcodeDetectorCtor }).BarcodeDetector ?? null
+}
+
+/** Pure dedup rule: suppress a repeat of the same code within cooldownMs. */
+export function shouldEmit(
+ last: { code: string; at: number } | null,
+ code: string,
+ now: number,
+ cooldownMs: number
+): boolean {
+ if (!last) return true
+ if (last.code !== code) return true
+ return now - last.at >= cooldownMs
+}
+
+export function useBarcodeScanner(
+ videoRef: React.RefObject,
+ onDetect: (code: string) => void,
+ enabled: boolean
+): { status: ScannerStatus; stop: () => void } {
+ const [status, setStatus] = useState('idle')
+ const onDetectRef = useRef(onDetect)
+ onDetectRef.current = onDetect
+
+ useEffect(() => {
+ if (!enabled) {
+ setStatus('idle')
+ return
+ }
+ let stopped = false
+ let stream: MediaStream | null = null
+ let rafId = 0
+ let zxingStop: (() => void) | null = null
+ let last: { code: string; at: number } | null = null
+ const emit = (code: string) => {
+ const now = Date.now()
+ if (!shouldEmit(last, code, now, 1500)) return
+ last = { code, at: now }
+ onDetectRef.current(code)
+ }
+
+ async function start(): Promise {
+ setStatus('starting')
+ try {
+ stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } })
+ } catch (err) {
+ setStatus(err instanceof DOMException && err.name === 'NotAllowedError' ? 'denied' : 'error')
+ return
+ }
+ const video = videoRef.current
+ if (!video || stopped) {
+ stream.getTracks().forEach((t) => t.stop())
+ return
+ }
+ video.srcObject = stream
+ await video.play().catch(() => {})
+
+ const Ctor = getBarcodeDetectorCtor()
+ if (Ctor) {
+ const detector = new Ctor({ formats: [...BARCODE_FORMATS] })
+ const tick = async (): Promise => {
+ if (stopped) return
+ try {
+ const codes = await detector.detect(video)
+ const first = codes[0]
+ if (first) emit(first.rawValue)
+ } catch {
+ // undecodable frame — skip
+ }
+ rafId = requestAnimationFrame(() => void tick())
+ }
+ void tick()
+ } else {
+ const { BrowserMultiFormatReader, BarcodeFormat, DecodeHintType } = await import('@zxing/library')
+ if (stopped) return
+ const hints = new Map()
+ hints.set(DecodeHintType.POSSIBLE_FORMATS, [BarcodeFormat.EAN_13, BarcodeFormat.UPC_A, BarcodeFormat.EAN_8])
+ const reader = new BrowserMultiFormatReader(hints)
+ const controls = await reader.decodeFromStream(stream, video, (result) => {
+ if (result) emit(result.getText())
+ })
+ zxingStop = controls.stop
+ }
+ if (!stopped) setStatus('ready')
+ }
+
+ void start()
+
+ return () => {
+ stopped = true
+ cancelAnimationFrame(rafId)
+ zxingStop?.()
+ stream?.getTracks().forEach((t) => t.stop())
+ setStatus('idle')
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [enabled, videoRef])
+
+ return { status, stop: () => undefined }
+}
+```
+
+(If `decodeFromStream` has a different shape in the installed @zxing/library version, adapt minimally — the contract is: given our stream+video, call the callback per decode, and return a stop function. Keep everything else identical.)
+
+- [ ] **Step 5: Run tests to verify they pass**
+
+Run: `npx vitest run web/test/scanner.test.ts && npm test && npm run typecheck`
+Expected: ALL PASS (89 + 4 new = 93).
+
+- [ ] **Step 6: Create `web/src/components/Scanner.tsx`**
+
+```tsx
+import { useRef } from 'react'
+import { useBarcodeScanner } from '../hooks/useBarcodeScanner.js'
+
+export default function Scanner({
+ enabled,
+ onDetect,
+}: {
+ enabled: boolean
+ onDetect: (code: string) => void
+}) {
+ const videoRef = useRef(null)
+ const { status } = useBarcodeScanner(videoRef, onDetect, enabled)
+
+ return (
+
+
+
+ {status === 'starting' && (
+
Starting camera…
+ )}
+ {status === 'denied' && (
+
+
Camera permission denied
+
+ Allow camera access in your browser settings, then reload this page.
+
+
+ )}
+ {status === 'error' && (
+
+ Camera could not start.
+
+ )}
+
+ )
+}
+```
+
+- [ ] **Step 7: Verify dev build + manual checklist note**
+
+Run: `npm run build:web` — expect success.
+
+Manual (not automated — document for device testing later): camera + real barcode on iOS Safari and Android Chrome.
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add -A && git commit -m "feat: barcode scanner hook (BarcodeDetector + zxing fallback) and camera component"
+```
+
+---
+
+### Task 5: Scan-flow state machine (pure reducer)
+
+**Files:**
+- Create: `web/src/scan/reducer.ts`
+- Test: `web/test/reducer.test.ts`
+
+- [ ] **Step 1: Write the failing test `web/test/reducer.test.ts`**
+
+```ts
+import { describe, it, expect } from 'vitest'
+import { scanReducer, INITIAL_SCAN_STATE, type ScanState } from '../src/scan/reducer.js'
+import type { Candidate, Item, ReleasePreview } from '../src/types.js'
+
+const candidate: Candidate = {
+ id: 1001,
+ artist: 'The Cinematic Orchestra',
+ title: 'Motion',
+ year: 1999,
+ formats: ['CD'],
+ labels: ['Ninja Tune'],
+ country: 'UK',
+ catno: 'ZENCD012',
+ thumbUrl: 'https://img/x.jpg',
+}
+
+const preview: ReleasePreview = {
+ release: { ...candidate, genres: [], tracklist: [], coverUrl: null, barcodes: ['5021592210629'] },
+ duplicate: false,
+ ripMatch: 'not_ripped',
+ matchCandidates: [],
+}
+
+const item: Item = {
+ id: 1,
+ discogsReleaseId: 1001,
+ title: 'Motion',
+ artist: 'The Cinematic Orchestra',
+ year: 1999,
+ formats: ['CD'],
+ genres: [],
+ labels: ['Ninja Tune'],
+ tracklist: [],
+ catno: 'ZENCD012',
+ country: 'UK',
+ artworkUrl: null,
+ barcodes: ['5021592210629'],
+ dateAdded: '2026-08-29',
+ ripOverride: null,
+ ripStatus: 'not_ripped',
+}
+
+function stateOf(phase: ScanState['phase']): ScanState {
+ let state = INITIAL_SCAN_STATE
+ const steps: Parameters[1][] = [
+ { type: 'DETECT', code: '5021592210629' },
+ { type: 'CANDIDATES', code: '5021592210629', candidates: [candidate] },
+ { type: 'SELECT', candidate },
+ { type: 'PREVIEW', preview },
+ { type: 'ADD_START' },
+ { type: 'ADDED', item },
+ ]
+ const order: ScanState['phase'][] = ['scan', 'looking', 'candidates', 'confirm', 'confirm', 'confirm', 'added']
+ const idx = order.indexOf(phase)
+ if (idx < 0) return state
+ for (let i = 1; i <= idx; i++) state = scanReducer(state, steps[i - 1]!)
+ return state
+}
+
+describe('scanReducer', () => {
+ it('DETECT from scan → looking', () => {
+ const next = scanReducer(INITIAL_SCAN_STATE, { type: 'DETECT', code: '123' })
+ expect(next).toEqual({ phase: 'looking', code: '123' })
+ })
+
+ it('DETECT is ignored unless scanning (prevents duplicate lookups)', () => {
+ const looking = stateOf('looking')
+ expect(scanReducer(looking, { type: 'DETECT', code: '999' })).toBe(looking)
+ })
+
+ it('CANDIDATES from looking → candidates', () => {
+ const next = scanReducer(stateOf('looking'), {
+ type: 'CANDIDATES',
+ code: '5021592210629',
+ candidates: [candidate],
+ })
+ expect(next.phase).toBe('candidates')
+ })
+
+ it('NOT_FOUND from looking → error not_found', () => {
+ const next = scanReducer(stateOf('looking'), { type: 'NOT_FOUND', code: '123' })
+ expect(next).toEqual({ phase: 'error', kind: 'not_found', code: '123' })
+ })
+
+ it('ERROR maps kinds', () => {
+ const next = scanReducer(stateOf('looking'), { type: 'ERROR', kind: 'no_discogs_token', code: '123' })
+ expect(next).toEqual({ phase: 'error', kind: 'no_discogs_token', code: '123' })
+ })
+
+ it('SELECT from candidates → confirm with candidate, no preview yet', () => {
+ const next = scanReducer(stateOf('candidates'), { type: 'SELECT', candidate })
+ expect(next.phase).toBe('confirm')
+ if (next.phase === 'confirm') {
+ expect(next.candidate).toBe(candidate)
+ expect(next.preview).toBeNull()
+ expect(next.adding).toBe(false)
+ expect(next.matchAlbumId).toBeNull()
+ }
+ })
+
+ it('PREVIEW fills the confirm phase', () => {
+ const next = scanReducer(stateOf('confirm'), { type: 'PREVIEW', preview })
+ if (next.phase === 'confirm') expect(next.preview).toBe(preview)
+ else expect.fail?.('expected confirm')
+ })
+
+ it('SET_MATCH / CLEAR_MATCH only in confirm', () => {
+ const confirm = stateOf('confirm')
+ const set = scanReducer(confirm, { type: 'SET_MATCH', albumId: 7 })
+ if (set.phase === 'confirm') expect(set.matchAlbumId).toBe(7)
+ const cleared = scanReducer(set, { type: 'CLEAR_MATCH' })
+ if (cleared.phase === 'confirm') expect(cleared.matchAlbumId).toBeNull()
+ expect(scanReducer(stateOf('scan'), { type: 'SET_MATCH', albumId: 7 })).toBe(stateOf('scan'))
+ })
+
+ it('ADD_START → ADDED', () => {
+ const confirm = stateOf('confirm')
+ const starting = scanReducer(confirm, { type: 'ADD_START' })
+ if (starting.phase === 'confirm') expect(starting.adding).toBe(true)
+ const added = scanReducer(starting, { type: 'ADDED', item })
+ expect(added).toEqual({ phase: 'added', item })
+ })
+
+ it('ADD_ERROR stores the message without leaving confirm', () => {
+ const starting = scanReducer(stateOf('confirm'), { type: 'ADD_START' })
+ const next = scanReducer(starting, { type: 'ADD_ERROR', message: 'duplicate' })
+ if (next.phase === 'confirm') {
+ expect(next.adding).toBe(false)
+ expect(next.addError).toBe('duplicate')
+ } else {
+ throw new Error('expected confirm')
+ }
+ })
+
+ it('RESET returns to scan from anywhere', () => {
+ for (const phase of ['looking', 'candidates', 'confirm', 'added', 'error'] as const) {
+ expect(scanReducer(stateOf(phase), { type: 'RESET' })).toEqual({ phase: 'scan' })
+ }
+ })
+})
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `npx vitest run web/test/reducer.test.ts`
+Expected: FAIL — module not found.
+
+- [ ] **Step 3: Implement `web/src/scan/reducer.ts`**
+
+```ts
+import type { Candidate, Item, ReleasePreview } from '../types.js'
+
+export type ScanErrorKind = 'not_found' | 'no_discogs_token' | 'rate_limited' | 'server'
+
+export type ScanState =
+ | { phase: 'scan' }
+ | { phase: 'looking'; code: string }
+ | { phase: 'candidates'; code: string; candidates: Candidate[] }
+ | {
+ phase: 'confirm'
+ code: string | null
+ candidate: Candidate
+ preview: ReleasePreview | null
+ matchAlbumId: number | null
+ adding: boolean
+ addError: string | null
+ }
+ | { phase: 'added'; item: Item }
+ | { phase: 'error'; kind: ScanErrorKind; code: string | null }
+
+export const INITIAL_SCAN_STATE: ScanState = { phase: 'scan' }
+
+export type ScanAction =
+ | { type: 'DETECT'; code: string }
+ | { type: 'CANDIDATES'; code: string; candidates: Candidate[] }
+ | { type: 'NOT_FOUND'; code: string }
+ | { type: 'ERROR'; kind: ScanErrorKind; code: string | null }
+ | { type: 'SELECT'; candidate: Candidate }
+ | { type: 'PREVIEW'; preview: ReleasePreview }
+ | { type: 'SET_MATCH'; albumId: number }
+ | { type: 'CLEAR_MATCH' }
+ | { type: 'ADD_START' }
+ | { type: 'ADDED'; item: Item }
+ | { type: 'ADD_ERROR'; message: string }
+ | { type: 'RESET' }
+
+export function scanReducer(state: ScanState, action: ScanAction): ScanState {
+ switch (action.type) {
+ case 'DETECT':
+ return state.phase === 'scan' ? { phase: 'looking', code: action.code } : state
+ case 'CANDIDATES':
+ return state.phase === 'looking' ? { phase: 'candidates', code: action.code, candidates: action.candidates } : state
+ case 'NOT_FOUND':
+ return state.phase === 'looking' ? { phase: 'error', kind: 'not_found', code: action.code } : state
+ case 'ERROR':
+ return state.phase === 'looking' || state.phase === 'candidates' || state.phase === 'confirm'
+ ? { phase: 'error', kind: action.kind, code: action.code }
+ : state
+ case 'SELECT':
+ return state.phase === 'candidates'
+ ? {
+ phase: 'confirm',
+ code: state.code,
+ candidate: action.candidate,
+ preview: null,
+ matchAlbumId: null,
+ adding: false,
+ addError: null,
+ }
+ : state
+ case 'PREVIEW':
+ return state.phase === 'confirm' ? { ...state, preview: action.preview } : state
+ case 'SET_MATCH':
+ return state.phase === 'confirm' ? { ...state, matchAlbumId: action.albumId } : state
+ case 'CLEAR_MATCH':
+ return state.phase === 'confirm' ? { ...state, matchAlbumId: null } : state
+ case 'ADD_START':
+ return state.phase === 'confirm' && state.preview ? { ...state, adding: true, addError: null } : state
+ case 'ADDED':
+ return state.phase === 'confirm' ? { phase: 'added', item: action.item } : state
+ case 'ADD_ERROR':
+ return state.phase === 'confirm' ? { ...state, adding: false, addError: action.message } : state
+ case 'RESET':
+ return INITIAL_SCAN_STATE
+ }
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `npx vitest run web/test/reducer.test.ts && npm test`
+Expected: ALL PASS (93 + 11 new = 104).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add -A && git commit -m "feat: pure scan-flow state machine"
+```
+
+---
+
+### Task 6: Candidate card, cover, confirm view, scan page
+
+**Files:**
+- Create: `web/src/components/Cover.tsx`, `web/src/components/CandidateCard.tsx`, `web/src/scan/ConfirmView.tsx`, `web/src/pages/ScanPage.tsx` (replace stub)
+- Test: `web/test/scanPage.test.tsx`
+
+- [ ] **Step 1: Create `web/src/components/Cover.tsx`** (no test — presentational, covered via pages)
+
+```tsx
+export default function Cover({
+ src,
+ alt,
+ className = 'size-16',
+}: {
+ src: string | null
+ alt: string
+ className?: string
+}) {
+ if (!src) {
+ return (
+
+
+
+
+
+
+ )
+ }
+ return
+}
+```
+
+- [ ] **Step 2: Create `web/src/components/CandidateCard.tsx`**
+
+```tsx
+import type { Candidate } from '../types.js'
+import Cover from './Cover.js'
+
+export default function CandidateCard({
+ candidate,
+ onSelect,
+}: {
+ candidate: Candidate
+ onSelect: (candidate: Candidate) => void
+}) {
+ const meta = [candidate.year, candidate.formats[0], candidate.labels[0]].filter(Boolean).join(' · ')
+ return (
+ onSelect(candidate)}
+ className="flex w-full items-center gap-3 rounded-xl border border-neutral-800 bg-neutral-900 p-3 text-left transition-colors hover:border-neutral-600"
+ >
+
+
+
{candidate.title}
+
{candidate.artist}
+ {meta &&
{meta}
}
+
+
+ )
+}
+```
+
+- [ ] **Step 3: Create `web/src/scan/ConfirmView.tsx`**
+
+```tsx
+import type { ReleasePreview } from '../types.js'
+import Cover from '../components/Cover.js'
+
+export default function ConfirmView({
+ code,
+ preview,
+ matchAlbumId,
+ onSetMatch,
+ onClearMatch,
+ onAdd,
+ adding,
+ addError,
+}: {
+ code: string | null
+ preview: ReleasePreview
+ matchAlbumId: number | null
+ onSetMatch: (albumId: number) => void
+ onClearMatch: () => void
+ onAdd: () => void
+ adding: boolean
+ addError: string | null
+}) {
+ const { release, duplicate, ripMatch, matchCandidates } = preview
+ return (
+
+
+
+
+
{release.title}
+
{release.artist}
+
+ {[release.year, release.formats.join(', '), release.labels[0], release.catno]
+ .filter(Boolean)
+ .join(' · ')}
+
+ {code &&
Barcode {code}
}
+
+
+
+ {ripMatch === 'ripped' && (
+
+ In your digital collection ✓
+
+ )}
+ {ripMatch === 'not_ripped' && (
+
Not ripped yet
+ )}
+ {ripMatch === 'ambiguous' && (
+
+ Possible matches in your library — which one is it?
+
+ {matchCandidates.map((m) => (
+
+ onSetMatch(m.id)}
+ />
+ {m.artist} — {m.title}
+
+ ))}
+
+
+ None of these — just add it
+
+
+
+ )}
+
+ {duplicate && (
+
+ Heads up: this release is already in your collection.
+
+ )}
+
+
+ Tracklist
+
+ {release.tracklist.map((t, i) => (
+
+ {t.position}
+ {t.title}
+
+ ))}
+
+
+
+ {addError &&
{addError}
}
+
+ {adding ? 'Adding…' : 'Add to collection'}
+
+
+ )
+}
+```
+
+- [ ] **Step 4: Write the failing test `web/test/scanPage.test.tsx`**
+
+```tsx
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { MemoryRouter } from 'react-router-dom'
+import ScanPage from '../src/pages/ScanPage.js'
+import type { Candidate, ReleasePreview } from '../src/types.js'
+
+vi.mock('../src/components/Scanner.js', () => ({
+ default: ({ onDetect }: { onDetect: (code: string) => void }) => (
+ onDetect('5021592210629')}>
+ fake-scan
+
+ ),
+}))
+
+vi.mock('../src/api.js', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ api: {
+ ...actual.api,
+ lookupBarcode: vi.fn(),
+ getReleasePreview: vi.fn(),
+ addToCollection: vi.fn(),
+ },
+ }
+})
+
+import { api } from '../src/api.js'
+
+const candidate: Candidate = {
+ id: 1001,
+ artist: 'The Cinematic Orchestra',
+ title: 'Motion',
+ year: 1999,
+ formats: ['CD'],
+ labels: ['Ninja Tune'],
+ country: 'UK',
+ catno: 'ZENCD012',
+ thumbUrl: null,
+}
+
+const preview: ReleasePreview = {
+ release: { ...candidate, genres: [], tracklist: [{ position: '1', title: 'Overture' }], coverUrl: null, barcodes: ['5021592210629'] },
+ duplicate: false,
+ ripMatch: 'not_ripped',
+ matchCandidates: [],
+}
+
+function jsonOk(body: unknown) {
+ return Promise.resolve(new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } }))
+}
+
+beforeEach(() => {
+ vi.mocked(api.lookupBarcode).mockReset()
+ vi.mocked(api.getReleasePreview).mockReset()
+ vi.mocked(api.addToCollection).mockReset()
+})
+
+function renderScan() {
+ return render(
+
+
+
+ )
+}
+
+describe('ScanPage flow', () => {
+ it('scan → candidates → confirm → added', async () => {
+ vi.mocked(api.lookupBarcode).mockResolvedValue(jsonOk({ candidates: [candidate] }) as never)
+ vi.mocked(api.getReleasePreview).mockResolvedValue(jsonOk(preview) as never)
+ vi.mocked(api.addToCollection).mockResolvedValue(
+ jsonOk({
+ id: 1,
+ discogsReleaseId: 1001,
+ title: 'Motion',
+ artist: 'The Cinematic Orchestra',
+ year: 1999,
+ formats: ['CD'],
+ genres: [],
+ labels: ['Ninja Tune'],
+ tracklist: [],
+ catno: 'ZENCD012',
+ country: 'UK',
+ artworkUrl: null,
+ barcodes: ['5021592210629'],
+ dateAdded: '2026-08-29',
+ ripOverride: null,
+ ripStatus: 'not_ripped',
+ }) as never
+ )
+
+ renderScan()
+ await userEvent.click(screen.getByRole('button', { name: 'fake-scan' }))
+
+ await waitFor(() => expect(screen.getByRole('button', { name: /motion/i })).toBeTruthy())
+ await userEvent.click(screen.getByRole('button', { name: /motion/i }))
+
+ await waitFor(() => expect(screen.getByText('Not ripped yet')).toBeTruthy())
+ expect(screen.getByText('Overture')).toBeTruthy()
+
+ await userEvent.click(screen.getByRole('button', { name: /add to collection/i }))
+ await waitFor(() => expect(screen.getByText(/added to collection/i)).toBeTruthy())
+ expect(api.addToCollection).toHaveBeenCalledWith({
+ releaseId: 1001,
+ barcode: '5021592210629',
+ })
+ })
+
+ it('shows not-found guidance with a manual-search escape hatch', async () => {
+ vi.mocked(api.lookupBarcode).mockRejectedValue(
+ Object.assign(new Error('nf'), { status: 404, code: 'not_found' })
+ )
+ renderScan()
+ await userEvent.click(screen.getByRole('button', { name: 'fake-scan' }))
+ await waitFor(() => expect(screen.getByText(/nothing found/i)).toBeTruthy())
+ expect(screen.getByRole('link', { name: /search manually/i })).toHaveAttribute('href', '/add?q=5021592210629')
+ })
+
+ it('sends the picked match candidate for ambiguous rip matches', async () => {
+ vi.mocked(api.lookupBarcode).mockResolvedValue(jsonOk({ candidates: [candidate] }) as never)
+ vi.mocked(api.getReleasePreview).mockResolvedValue(
+ jsonOk({
+ ...preview,
+ ripMatch: 'ambiguous',
+ matchCandidates: [{ id: 77, title: 'Motion', artist: 'Somebody Else' }],
+ }) as never
+ )
+ vi.mocked(api.addToCollection).mockResolvedValue(jsonOk({}) as never)
+
+ renderScan()
+ await userEvent.click(screen.getByRole('button', { name: 'fake-scan' }))
+ await userEvent.click(await screen.findByRole('button', { name: /motion/i }))
+ await waitFor(() => expect(screen.getByRole('radio', { name: /somebody else/i })).toBeTruthy())
+ await userEvent.click(screen.getByRole('radio', { name: /somebody else/i }))
+ await userEvent.click(screen.getByRole('button', { name: /add to collection/i }))
+ await waitFor(() => expect(api.addToCollection).toHaveBeenCalledWith({
+ releaseId: 1001,
+ barcode: '5021592210629',
+ matchAlbumId: 77,
+ }))
+ })
+
+ it('shows a duplicate warning from the preview', async () => {
+ vi.mocked(api.lookupBarcode).mockResolvedValue(jsonOk({ candidates: [candidate] }) as never)
+ vi.mocked(api.getReleasePreview).mockResolvedValue(jsonOk({ ...preview, duplicate: true }) as never)
+ renderScan()
+ await userEvent.click(screen.getByRole('button', { name: 'fake-scan' }))
+ await userEvent.click(await screen.findByRole('button', { name: /motion/i }))
+ await waitFor(() => expect(screen.getByText(/already in your collection/i)).toBeTruthy())
+ })
+
+ it('links to settings when no discogs token is configured', async () => {
+ vi.mocked(api.lookupBarcode).mockRejectedValue(
+ Object.assign(new Error('no token'), { status: 409, code: 'no_discogs_token' })
+ )
+ renderScan()
+ await userEvent.click(screen.getByRole('button', { name: 'fake-scan' }))
+ await waitFor(() => expect(screen.getByText(/discogs token/i)).toBeTruthy())
+ expect(screen.getByRole('link', { name: /settings/i })).toHaveAttribute('href', '/settings')
+ })
+})
+```
+
+Note: `mockRejectedValue(Object.assign(new Error(...), {status, code}))` simulates `ApiError` — the page maps via `err instanceof ApiError ? … : 'server'`. If the mock-object approach fights TypeScript, build real `new ApiError(status, code)` instances from `../src/api.js` instead (it is exported) — prefer that:
+
+```ts
+import { ApiError } from '../src/api.js'
+vi.mocked(api.lookupBarcode).mockRejectedValue(new ApiError(404, 'not_found'))
+```
+
+Use the `ApiError` form; it is cleaner and type-safe.
+
+- [ ] **Step 5: Run test to verify it fails**
+
+Run: `npx vitest run web/test/scanPage.test.tsx`
+Expected: FAIL — ScanPage is a stub.
+
+- [ ] **Step 6: Implement `web/src/pages/ScanPage.tsx`** (replace the stub)
+
+```tsx
+import { useCallback, useEffect, useReducer } from 'react'
+import { Link } from 'react-router-dom'
+import { api, ApiError } from '../api.js'
+import Scanner from '../components/Scanner.js'
+import CandidateCard from '../components/CandidateCard.js'
+import ConfirmView from '../scan/ConfirmView.js'
+import Cover from '../components/Cover.js'
+import { scanReducer, INITIAL_SCAN_STATE, type ScanErrorKind } from '../scan/reducer.js'
+
+function toErrorKind(err: unknown): ScanErrorKind {
+ if (err instanceof ApiError) {
+ if (err.code === 'not_found') return 'not_found'
+ if (err.code === 'no_discogs_token') return 'no_discogs_token'
+ if (err.code === 'discogs_rate_limited' || err.status === 429) return 'rate_limited'
+ }
+ return 'server'
+}
+
+export default function ScanPage() {
+ const [state, dispatch] = useReducer(scanReducer, INITIAL_SCAN_STATE)
+
+ const onDetect = useCallback((code: string) => {
+ dispatch({ type: 'DETECT', code })
+ void (async () => {
+ try {
+ const { candidates } = await api.lookupBarcode(code)
+ if (candidates.length === 0) {
+ dispatch({ type: 'NOT_FOUND', code })
+ } else {
+ dispatch({ type: 'CANDIDATES', code, candidates })
+ }
+ } catch (err) {
+ dispatch({ type: 'ERROR', kind: toErrorKind(err), code })
+ }
+ })()
+ }, [])
+
+ // Load the release preview once a candidate is selected.
+ useEffect(() => {
+ if (state.phase !== 'confirm' || state.preview) return
+ const candidateId = state.candidate.id
+ void (async () => {
+ try {
+ const preview = await api.getReleasePreview(candidateId)
+ dispatch({ type: 'PREVIEW', preview })
+ } catch (err) {
+ dispatch({ type: 'ERROR', kind: toErrorKind(err), code: null })
+ }
+ })()
+ }, [state])
+
+ const add = useCallback(() => {
+ if (state.phase !== 'confirm' || !state.preview || state.adding) return
+ const { candidate, code, matchAlbumId } = state
+ void (async () => {
+ try {
+ const item = await api.addToCollection({
+ releaseId: candidate.id,
+ ...(code ? { barcode: code } : {}),
+ ...(matchAlbumId !== null ? { matchAlbumId } : {}),
+ })
+ dispatch({ type: 'ADDED', item })
+ } catch (err) {
+ const message =
+ err instanceof ApiError
+ ? err.code === 'duplicate'
+ ? 'Already in your collection.'
+ : err.detail ?? err.code
+ : 'Something went wrong'
+ dispatch({ type: 'ADD_ERROR', message })
+ }
+ })()
+ }, [state])
+
+ return (
+
+ {state.phase === 'scan' &&
}
+
+ {state.phase === 'looking' && (
+
Looking up {state.code}…
+ )}
+
+ {state.phase === 'candidates' && (
+
+
Which release is it?
+ {state.candidates.map((c) => (
+
dispatch({ type: 'SELECT', candidate: cand })} />
+ ))}
+ dispatch({ type: 'RESET' })}
+ className="w-full rounded-xl border border-neutral-700 py-2 text-sm text-neutral-300"
+ >
+ Scan another
+
+
+ )}
+
+ {state.phase === 'confirm' && (
+
+ {!state.preview &&
Checking release…
}
+ {state.preview && (
+
dispatch({ type: 'SET_MATCH', albumId })}
+ onClearMatch={() => dispatch({ type: 'CLEAR_MATCH' })}
+ onAdd={add}
+ adding={state.adding}
+ addError={state.addError}
+ />
+ )}
+ dispatch({ type: 'RESET' })}
+ className="w-full rounded-xl border border-neutral-700 py-2 text-sm text-neutral-300"
+ >
+ Cancel
+
+
+ )}
+
+ {state.phase === 'added' && (
+
+
+
Added to collection ✓
+
+ {state.item.artist} — {state.item.title}
+
+
+ dispatch({ type: 'RESET' })}
+ className="flex-1 rounded-xl bg-emerald-500 py-2 font-medium text-neutral-950"
+ >
+ Scan another
+
+
+ View item
+
+
+
+ )}
+
+ {state.phase === 'error' && state.kind === 'not_found' && (
+
+
Nothing found
+
+ Discogs has no release for barcode {state.code}. Older vinyl often isn't listed by barcode.
+
+
+ Search manually
+
+
+ )}
+
+ {state.phase === 'error' && state.kind === 'no_discogs_token' && (
+
+
Add your Discogs token first
+
+ Settings
+
+
+ )}
+
+ {state.phase === 'error' && state.kind === 'rate_limited' && (
+
+
Slow down
+
Discogs is rate limiting us. Try again in a moment.
+
dispatch({ type: 'RESET' })}
+ className="rounded-xl border border-neutral-700 px-4 py-2 text-sm text-neutral-300"
+ >
+ Try again
+
+
+ )}
+
+ {state.phase === 'error' && state.kind === 'server' && (
+
+
Lookup failed
+
dispatch({ type: 'RESET' })}
+ className="rounded-xl border border-neutral-700 px-4 py-2 text-sm text-neutral-300"
+ >
+ Try again
+
+
+ )}
+
+ )
+}
+```
+
+- [ ] **Step 7: Run tests to verify they pass**
+
+Run: `npx vitest run web/test/scanPage.test.tsx && npm test && npm run typecheck`
+Expected: ALL PASS (104 + 5 new = 109).
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add -A && git commit -m "feat: scan flow page with candidates, confirm view and error guidance"
+```
+
+---
+
+### Task 7: Library page — cover grid, filters, artist index
+
+**Files:**
+- Create: `web/src/components/CoverGrid.tsx`
+- Modify: `web/src/pages/LibraryPage.tsx` (replace stub)
+- Test: `web/test/library.test.tsx`
+
+- [ ] **Step 1: Write the failing test `web/test/library.test.tsx`**
+
+```tsx
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { MemoryRouter } from 'react-router-dom'
+import LibraryPage from '../src/pages/LibraryPage.js'
+import type { Item } from '../src/types.js'
+
+vi.mock('../src/api.js', async (importOriginal) => {
+ const actual = await importOriginal()
+ return { ...actual, api: { ...actual.api, listCollection: vi.fn() } }
+})
+
+import { api } from '../src/api.js'
+
+function item(overrides: Partial- ): Item {
+ return {
+ id: 1,
+ discogsReleaseId: 100,
+ title: 'Motion',
+ artist: 'The Cinematic Orchestra',
+ year: 1999,
+ formats: ['CD'],
+ genres: [],
+ labels: [],
+ tracklist: [],
+ catno: null,
+ country: null,
+ artworkUrl: null,
+ barcodes: [],
+ dateAdded: '2026-08-29',
+ ripOverride: null,
+ ripStatus: 'not_ripped',
+ ...overrides,
+ }
+}
+
+const data = {
+ items: [
+ item({ id: 1, ripStatus: 'not_ripped' }),
+ item({ id: 2, title: 'Blue Lines', artist: 'Massive Attack', formats: ['Vinyl'], ripStatus: 'ripped' }),
+ item({ id: 3, title: 'Mezzanine', artist: 'Massive Attack', formats: ['Cassette'], ripStatus: 'not_ripped' }),
+ ],
+ counts: { total: 3, ripped: 1, notRipped: 2 },
+}
+
+beforeEach(() => {
+ vi.mocked(api.listCollection).mockReset()
+ vi.mocked(api.listCollection).mockResolvedValue(data as never)
+})
+
+function renderLibrary() {
+ return render(
+
+
+
+ )
+}
+
+describe('LibraryPage', () => {
+ it('renders covers with artist and rip badges', async () => {
+ renderLibrary()
+ await waitFor(() => expect(screen.getAllByRole('link', { name: /motion/i })).toHaveLength(1))
+ expect(screen.getByRole('link', { name: /blue lines/i })).toBeTruthy()
+ expect(screen.getByRole('link', { name: /mezzanine/i })).toBeTruthy()
+ })
+
+ it('passes format and rip filters to the api', async () => {
+ renderLibrary()
+ await waitFor(() => expect(screen.getByRole('button', { name: /^vinyl$/i })).toBeTruthy())
+ await userEvent.click(screen.getByRole('button', { name: /^vinyl$/i }))
+ await waitFor(() =>
+ expect(api.listCollection).toHaveBeenLastCalledWith(expect.objectContaining({ format: 'Vinyl' }))
+ )
+ await userEvent.click(screen.getByRole('button', { name: /^ripped$/i }))
+ await waitFor(() =>
+ expect(api.listCollection).toHaveBeenLastCalledWith(
+ expect.objectContaining({ format: 'Vinyl', ripped: 'ripped' })
+ )
+ )
+ })
+
+ it('searches by title/artist via the q filter', async () => {
+ renderLibrary()
+ await waitFor(() => expect(screen.getByPlaceholderText(/search/i)).toBeTruthy())
+ await userEvent.type(screen.getByPlaceholderText(/search/i), 'mezz')
+ await waitFor(() =>
+ expect(api.listCollection).toHaveBeenLastCalledWith(expect.objectContaining({ q: 'mezz' }))
+ )
+ })
+
+ it('shows collection counts', async () => {
+ renderLibrary()
+ await waitFor(() => expect(screen.getByText(/3 in collection/i)).toBeTruthy())
+ expect(screen.getByText(/1 ripped/i)).toBeTruthy()
+ expect(screen.getByText(/2 not ripped/i)).toBeTruthy()
+ })
+
+ it('artist index sets the search box to the artist name', async () => {
+ renderLibrary()
+ const artistBtn = await screen.findByRole('button', { name: /massive attack/i })
+ await userEvent.click(artistBtn)
+ await waitFor(() =>
+ expect(api.listCollection).toHaveBeenLastCalledWith(expect.objectContaining({ q: 'Massive Attack' }))
+ )
+ })
+
+ it('shows an empty state when the collection is empty', async () => {
+ vi.mocked(api.listCollection).mockResolvedValue({ items: [], counts: { total: 0, ripped: 0, notRipped: 0 } } as never)
+ renderLibrary()
+ await waitFor(() => expect(screen.getByText(/nothing here yet/i)).toBeTruthy())
+ expect(screen.getByRole('link', { name: /add your first record/i })).toHaveAttribute('href', '/add')
+ })
+})
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `npx vitest run web/test/library.test.tsx`
+Expected: FAIL — LibraryPage is a stub.
+
+- [ ] **Step 3: Create `web/src/components/CoverGrid.tsx`**
+
+```tsx
+import { Link } from 'react-router-dom'
+import type { Item } from '../types.js'
+import Cover from './Cover.js'
+
+export default function CoverGrid({ items }: { items: Item[] }) {
+ return (
+
+ {items.map((item) => (
+
+
+
+
+
+
{item.title}
+
{item.artist}
+
+ ))}
+
+ )
+}
+```
+
+- [ ] **Step 4: Implement `web/src/pages/LibraryPage.tsx`** (replace the stub)
+
+```tsx
+import { useEffect, useMemo, useRef, useState } from 'react'
+import { Link } from 'react-router-dom'
+import { api } from '../api.js'
+import type { CollectionResponse } from '../types.js'
+import CoverGrid from '../components/CoverGrid.js'
+
+const FORMATS = ['All', 'Vinyl', 'CD', 'Cassette'] as const
+const RIP = ['All', 'Ripped', 'Not ripped'] as const
+
+export default function LibraryPage() {
+ const [format, setFormat] = useState<(typeof FORMATS)[number]>('All')
+ const [ripped, setRipped] = useState<(typeof RIP)[number]>('All')
+ const [q, setQ] = useState('')
+ const [data, setData] = useState(null)
+ const [error, setError] = useState(false)
+ const seq = useRef(0)
+
+ useEffect(() => {
+ const mine = ++seq.current
+ const params = {
+ ...(format !== 'All' ? { format } : {}),
+ ...(ripped !== 'All' ? { ripped: ripped === 'Ripped' ? 'ripped' : 'not_ripped' } : {}),
+ ...(q.trim() ? { q: q.trim() } : {}),
+ }
+ void api
+ .listCollection(params)
+ .then((res) => {
+ if (seq.current === mine) {
+ setData(res)
+ setError(false)
+ }
+ })
+ .catch(() => {
+ if (seq.current === mine) setError(true)
+ })
+ }, [format, ripped, q])
+
+ const artists = useMemo(() => {
+ const set = new Set()
+ for (const item of data?.items ?? []) set.add(item.artist)
+ return [...set].sort((a, b) => a.localeCompare(b))
+ }, [data])
+
+ return (
+
+
setQ(e.target.value)}
+ className="w-full rounded-xl border border-neutral-800 bg-neutral-900 px-3 py-2 text-sm"
+ />
+
+
+ {FORMATS.map((f) => (
+ setFormat(f)}
+ className={`rounded-full px-3 py-1 text-xs font-medium ${
+ format === f ? 'bg-emerald-500 text-neutral-950' : 'border border-neutral-700 text-neutral-300'
+ }`}
+ >
+ {f}
+
+ ))}
+ {RIP.map((r) => (
+ setRipped(r)}
+ className={`rounded-full px-3 py-1 text-xs font-medium ${
+ ripped === r ? 'bg-emerald-500 text-neutral-950' : 'border border-neutral-700 text-neutral-300'
+ }`}
+ >
+ {r}
+
+ ))}
+
+
+ {error &&
Could not load your collection.
}
+
+ {data && (
+ <>
+
+ {data.counts.total} in collection · {data.counts.ripped} ripped · {data.counts.notRipped} not ripped
+
+
+ {data.items.length === 0 ? (
+
+
Nothing here yet.
+
+ Add your first record →
+
+
+ ) : (
+
+ )}
+
+ {!q && artists.length > 1 && (
+
+ Artists
+
+ {artists.map((artist) => (
+ setQ(artist)}
+ className="rounded-full border border-neutral-700 px-2.5 py-0.5 text-xs text-neutral-300"
+ >
+ {artist}
+
+ ))}
+
+
+ )}
+ >
+ )}
+
+ )
+}
+```
+
+- [ ] **Step 5: Run tests to verify they pass**
+
+Run: `npx vitest run web/test/library.test.tsx && npm test && npm run typecheck`
+Expected: ALL PASS (109 + 6 new = 115).
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add -A && git commit -m "feat: library grid with rip/format filters, search and artist index"
+```
+
+---
+
+### Task 8: Item page — detail, rip toggle, re-match, remove
+
+**Files:**
+- Modify: `web/src/pages/ItemPage.tsx` — new file, plus route registration in `web/src/App.tsx`
+- Test: `web/test/item.test.tsx`
+
+- [ ] **Step 1: Write the failing test `web/test/item.test.tsx`**
+
+```tsx
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { MemoryRouter, Route, Routes } from 'react-router-dom'
+import ItemPage from '../src/pages/ItemPage.js'
+import type { Item } from '../src/types.js'
+
+vi.mock('../src/api.js', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ api: { ...actual.api, getItem: vi.fn(), setRip: vi.fn(), setMatch: vi.fn(), deleteItem: vi.fn(), searchAlbums: vi.fn() },
+ }
+})
+
+import { api } from '../src/api.js'
+
+const item: Item = {
+ id: 1,
+ discogsReleaseId: 1001,
+ title: 'Motion',
+ artist: 'The Cinematic Orchestra',
+ year: 1999,
+ formats: ['CD'],
+ genres: ['Electronic'],
+ labels: ['Ninja Tune'],
+ tracklist: [{ position: '1', title: 'Overture' }],
+ catno: 'ZENCD012',
+ country: 'UK',
+ artworkUrl: '/artwork/abc.jpg',
+ barcodes: ['5021592210629'],
+ dateAdded: '2026-08-29',
+ ripOverride: null,
+ ripStatus: 'not_ripped',
+}
+
+beforeEach(() => {
+ vi.mocked(api.getItem).mockReset()
+ vi.mocked(api.getItem).mockResolvedValue(item as never)
+ vi.mocked(api.setRip).mockReset()
+ vi.mocked(api.setMatch).mockReset()
+ vi.mocked(api.deleteItem).mockReset()
+ vi.mocked(api.searchAlbums).mockReset()
+})
+
+function renderItem() {
+ return render(
+
+
+ } />
+ library } />
+
+
+ )
+}
+
+describe('ItemPage', () => {
+ it('renders metadata and the rip-status banner', async () => {
+ renderItem()
+ await waitFor(() => expect(screen.getByRole('heading', { name: 'Motion' })).toBeTruthy())
+ expect(screen.getByText('The Cinematic Orchestra')).toBeTruthy()
+ expect(screen.getByText(/not ripped yet/i)).toBeTruthy()
+ expect(screen.getByText('ZENCD012')).toBeTruthy()
+ expect(screen.getByText('5021592210629')).toBeTruthy()
+ expect(screen.getByText('Overture')).toBeTruthy()
+ expect(screen.getByRole('link', { name: /view on discogs/i })).toHaveAttribute(
+ 'href',
+ 'https://www.discogs.com/release/1001'
+ )
+ })
+
+ it('rip override: mark ripped, then reset to auto', async () => {
+ vi.mocked(api.setRip).mockResolvedValue({ ...item, ripOverride: true, ripStatus: 'ripped' } as never)
+ renderItem()
+ await userEvent.click(await screen.findByRole('button', { name: /mark ripped/i }))
+ await waitFor(() => expect(api.setRip).toHaveBeenCalledWith(1, true))
+ await waitFor(() => expect(screen.getByText(/in your digital collection/i)).toBeTruthy())
+
+ vi.mocked(api.setRip).mockResolvedValue(item as never)
+ await userEvent.click(screen.getByRole('button', { name: /reset to auto/i }))
+ await waitFor(() => expect(api.setRip).toHaveBeenCalledWith(1, null))
+ })
+
+ it('re-match: search albums and link one', async () => {
+ vi.mocked(api.searchAlbums).mockResolvedValue({
+ albums: [{ id: 77, subsonicId: 'a1', title: 'Motion (Remaster)', artist: 'The Cinematic Orchestra' }],
+ } as never)
+ vi.mocked(api.setMatch).mockResolvedValue({ ...item, ripStatus: 'ripped' } as never)
+ renderItem()
+ await userEvent.click(await screen.findByRole('button', { name: /re-match/i }))
+ await userEvent.click(screen.getByRole('button', { name: /^search$/i }))
+ const albumRadio = await screen.findByRole('radio', { name: /motion \(remaster\)/i })
+ await userEvent.click(albumRadio)
+ await userEvent.click(screen.getByRole('button', { name: /^link$/i }))
+ await waitFor(() => expect(api.setMatch).toHaveBeenCalledWith(1, 77))
+ })
+
+ it('unlink clears the match', async () => {
+ vi.mocked(api.setMatch).mockResolvedValue(item as never)
+ renderItem()
+ await userEvent.click(await screen.findByRole('button', { name: /re-match/i }))
+ await userEvent.click(screen.getByRole('button', { name: /^unlink$/i }))
+ await waitFor(() => expect(api.setMatch).toHaveBeenCalledWith(1, null))
+ })
+
+ it('remove deletes the item and navigates back to the library', async () => {
+ vi.mocked(api.deleteItem).mockResolvedValue({ ok: true } as never)
+ renderItem()
+ await userEvent.click(await screen.findByRole('button', { name: /^remove$/i }))
+ await userEvent.click(await screen.findByRole('button', { name: /^confirm remove$/i }))
+ await waitFor(() => expect(api.deleteItem).toHaveBeenCalledWith(1))
+ await waitFor(() => expect(screen.getByText('library')).toBeTruthy())
+ })
+})
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `npx vitest run web/test/item.test.tsx`
+Expected: FAIL — ItemPage does not exist / route missing.
+
+- [ ] **Step 3: Create `web/src/pages/ItemPage.tsx`**
+
+```tsx
+import { useEffect, useState } from 'react'
+import { Link, useNavigate, useParams } from 'react-router-dom'
+import { api } from '../api.js'
+import type { DigitalAlbum, Item } from '../types.js'
+import Cover from '../components/Cover.js'
+
+export default function ItemPage() {
+ const { id } = useParams()
+ const navigate = useNavigate()
+ const [item, setItem] = useState- (null)
+ const [error, setError] = useState(false)
+ const [matching, setMatching] = useState(false)
+ const [albumQuery, setAlbumQuery] = useState('')
+ const [albums, setAlbums] = useState
(null)
+ const [pickedAlbum, setPickedAlbum] = useState(null)
+ const [confirmRemove, setConfirmRemove] = useState(false)
+
+ useEffect(() => {
+ void api
+ .getItem(Number(id))
+ .then(setItem)
+ .catch(() => setError(true))
+ }, [id])
+
+ function searchAlbums() {
+ void api
+ .searchAlbums(albumQuery)
+ .then((res) => setAlbums(res.albums))
+ .catch(() => setAlbums([]))
+ }
+
+ function applyMatch(albumId: number | null) {
+ if (!item) return
+ void api.setMatch(item.id, albumId).then(setItem)
+ }
+
+ function remove() {
+ if (!item) return
+ void api.deleteItem(item.id).then(() => navigate('/library'))
+ }
+
+ if (error) return Item not found.
+ if (!item) return Loading…
+
+ return (
+
+
+
+
+
{item.title}
+
{item.artist}
+
+ {[item.year, item.formats.join(', '), item.labels.join(', '), item.catno, item.country]
+ .filter(Boolean)
+ .join(' · ')}
+
+ {item.genres.length > 0 &&
{item.genres.join(', ')}
}
+
+ View on Discogs ↗
+
+
+
+
+ {item.ripStatus === 'ripped' ? (
+
+ In your digital collection ✓
+ {item.ripOverride !== null && ' (manually set)'}
+
+ ) : (
+
Not ripped yet
+ )}
+
+
+ {item.ripStatus === 'ripped' ? (
+ <>
+ void api.setRip(item.id, false).then(setItem)}
+ className="rounded-xl border border-neutral-700 px-3 py-1.5 text-sm text-neutral-300"
+ >
+ Mark not ripped
+
+ {item.ripOverride !== null && (
+ void api.setRip(item.id, null).then(setItem)}
+ className="rounded-xl border border-neutral-700 px-3 py-1.5 text-sm text-neutral-300"
+ >
+ Reset to auto
+
+ )}
+ >
+ ) : (
+ void api.setRip(item.id, true).then(setItem)}
+ className="rounded-xl border border-neutral-700 px-3 py-1.5 text-sm text-neutral-300"
+ >
+ Mark ripped
+
+ )}
+
+
+
+ setMatching((m) => !m)}
+ className="text-sm font-medium text-neutral-200"
+ aria-expanded={matching}
+ >
+ Re-match
+
+ {matching && (
+
+
+ setAlbumQuery(e.target.value)}
+ placeholder="Search your digital library"
+ className="min-w-0 flex-1 rounded-lg border border-neutral-700 bg-neutral-950 px-3 py-1.5 text-sm"
+ />
+
+ Search
+
+
+ {albums && albums.length === 0 &&
No matches in your library.
}
+ {albums && albums.length > 0 && (
+
+ {albums.map((a) => (
+
+ setPickedAlbum(a.id)}
+ />
+ {a.artist} — {a.title}
+
+ ))}
+
+ )}
+
+ applyMatch(pickedAlbum)}
+ className="rounded-lg bg-emerald-500 px-3 py-1.5 text-sm font-medium text-neutral-950 disabled:opacity-50"
+ >
+ Link
+
+ applyMatch(null)}
+ className="rounded-lg border border-neutral-700 px-3 py-1.5 text-sm text-neutral-300"
+ >
+ Unlink
+
+
+
+ )}
+
+
+ {item.tracklist.length > 0 && (
+
+ Tracklist
+
+ {item.tracklist.map((t, i) => (
+
+ {t.position}
+ {t.title}
+
+ ))}
+
+
+ )}
+
+ {item.barcodes.length > 0 && (
+
Barcodes: {item.barcodes.join(', ')}
+ )}
+
+
+
+ ← Back
+
+ {confirmRemove ? (
+
+ Confirm remove
+
+ ) : (
+ setConfirmRemove(true)} className="text-sm text-red-400">
+ Remove
+
+ )}
+
+
+ )
+}
+```
+
+- [ ] **Step 4: Register the route in `web/src/App.tsx`** — add import and route inside the protected shell Routes block:
+
+```tsx
+import ItemPage from './pages/ItemPage.js'
+```
+
+```tsx
+ } />
+```
+
+- [ ] **Step 5: Run tests to verify they pass**
+
+Run: `npx vitest run web/test/item.test.tsx && npm test && npm run typecheck`
+Expected: ALL PASS (115 + 5 new = 120).
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add -A && git commit -m "feat: item detail with rip override, re-match and remove"
+```
+
+---
+
+### Task 9: Add page — text search into the confirm flow
+
+**Files:**
+- Modify: `web/src/pages/AddPage.tsx` (replace stub)
+- Test: `web/test/add.test.tsx`
+
+- [ ] **Step 1: Write the failing test `web/test/add.test.tsx`**
+
+```tsx
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { MemoryRouter } from 'react-router-dom'
+import AddPage from '../src/pages/AddPage.js'
+import type { Candidate, ReleasePreview } from '../src/types.js'
+
+vi.mock('../src/api.js', async (importOriginal) => {
+ const actual = await importOriginal()
+ return { ...actual, api: { ...actual.api, lookupSearch: vi.fn(), getReleasePreview: vi.fn(), addToCollection: vi.fn() } }
+})
+
+import { api } from '../src/api.js'
+
+const candidate: Candidate = {
+ id: 1001,
+ artist: 'The Cinematic Orchestra',
+ title: 'Motion',
+ year: 1999,
+ formats: ['Vinyl'],
+ labels: ['Ninja Tune'],
+ country: 'UK',
+ catno: 'ZEN012',
+ thumbUrl: null,
+}
+
+const preview: ReleasePreview = {
+ release: { ...candidate, genres: [], tracklist: [], coverUrl: null, barcodes: [] },
+ duplicate: false,
+ ripMatch: 'not_ripped',
+ matchCandidates: [],
+}
+
+function jsonOk(body: unknown) {
+ return Promise.resolve(
+ new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } })
+ )
+}
+
+beforeEach(() => {
+ vi.mocked(api.lookupSearch).mockReset()
+ vi.mocked(api.getReleasePreview).mockReset()
+ vi.mocked(api.addToCollection).mockReset()
+})
+
+function renderAdd(initialEntry = '/add') {
+ return render(
+
+
+
+ )
+}
+
+describe('AddPage', () => {
+ it('searches discogs and shows candidate cards', async () => {
+ vi.mocked(api.lookupSearch).mockResolvedValue(jsonOk({ candidates: [candidate] }) as never)
+ renderAdd()
+ await userEvent.type(screen.getByPlaceholderText(/artist and title/i), 'cinematic orchestra motion{Enter}')
+ await waitFor(() =>
+ expect(api.lookupSearch).toHaveBeenCalledWith('cinematic orchestra motion', undefined)
+ )
+ expect(await screen.findByRole('button', { name: /motion/i })).toBeTruthy()
+ })
+
+ it('passes the format filter and pre-filled query from the URL', async () => {
+ vi.mocked(api.lookupSearch).mockResolvedValue(jsonOk({ candidates: [] }) as never)
+ renderAdd('/add?q=5021592210629')
+ expect(screen.getByPlaceholderText(/artist and title/i)).toHaveValue('5021592210629')
+ await userEvent.selectOptions(screen.getByLabelText(/format/i), 'Vinyl')
+ await userEvent.click(screen.getByRole('button', { name: /^search$/i }))
+ await waitFor(() => expect(api.lookupSearch).toHaveBeenCalledWith('5021592210629', 'Vinyl'))
+ })
+
+ it('shows a not-found message', async () => {
+ vi.mocked(api.lookupSearch).mockRejectedValue(new (await import('../src/api.js')).ApiError(404, 'not_found'))
+ renderAdd()
+ await userEvent.type(screen.getByPlaceholderText(/artist and title/i), 'zzz{Enter}')
+ await waitFor(() => expect(screen.getByText(/nothing found/i)).toBeTruthy())
+ })
+
+ it('selecting a candidate loads the confirm view and adds', async () => {
+ vi.mocked(api.lookupSearch).mockResolvedValue(jsonOk({ candidates: [candidate] }) as never)
+ vi.mocked(api.getReleasePreview).mockResolvedValue(jsonOk(preview) as never)
+ vi.mocked(api.addToCollection).mockResolvedValue(jsonOk({}) as never)
+ renderAdd()
+ await userEvent.type(screen.getByPlaceholderText(/artist and title/i), 'motion{Enter}')
+ await userEvent.click(await screen.findByRole('button', { name: /motion/i }))
+ await waitFor(() => expect(screen.getByText('Not ripped yet')).toBeTruthy())
+ await userEvent.click(screen.getByRole('button', { name: /add to collection/i }))
+ await waitFor(() => expect(api.addToCollection).toHaveBeenCalledWith({ releaseId: 1001 }))
+ })
+})
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `npx vitest run web/test/add.test.tsx`
+Expected: FAIL — AddPage is a stub.
+
+- [ ] **Step 3: Implement `web/src/pages/AddPage.tsx`** (replace the stub)
+
+```tsx
+import { useCallback, useEffect, useReducer, useState } from 'react'
+import { useSearchParams } from 'react-router-dom'
+import { api, ApiError } from '../api.js'
+import CandidateCard from '../components/CandidateCard.js'
+import ConfirmView from '../scan/ConfirmView.js'
+import Cover from '../components/Cover.js'
+import {
+ scanReducer,
+ INITIAL_SCAN_STATE,
+ type ScanErrorKind,
+ type ScanState,
+} from '../scan/reducer.js'
+
+function toErrorKind(err: unknown): ScanErrorKind {
+ if (err instanceof ApiError) {
+ if (err.code === 'no_discogs_token') return 'no_discogs_token'
+ if (err.code === 'discogs_rate_limited' || err.status === 429) return 'rate_limited'
+ }
+ return 'server'
+}
+
+const FORMATS = ['', 'Vinyl', 'CD', 'Cassette'] as const
+
+export default function AddPage() {
+ const [params] = useSearchParams()
+ const [q, setQ] = useState(params.get('q') ?? '')
+ const [format, setFormat] = useState<(typeof FORMATS)[number]>('')
+ const [state, dispatch] = useReducer(scanReducer, INITIAL_SCAN_STATE)
+
+ const search = useCallback(
+ (query: string) => {
+ if (!query.trim()) return
+ dispatch({ type: 'DETECT', code: query.trim() })
+ void (async () => {
+ try {
+ const { candidates } = await api.lookupSearch(query.trim(), format || undefined)
+ if (candidates.length === 0) {
+ dispatch({ type: 'NOT_FOUND', code: query.trim() })
+ } else {
+ dispatch({ type: 'CANDIDATES', code: query.trim(), candidates })
+ }
+ } catch (err) {
+ dispatch({ type: 'ERROR', kind: toErrorKind(err), code: query.trim() })
+ }
+ })()
+ },
+ [format]
+ )
+
+ useEffect(() => {
+ if (state.phase !== 'confirm' || state.preview) return
+ const candidateId = state.candidate.id
+ void (async () => {
+ try {
+ const preview = await api.getReleasePreview(candidateId)
+ dispatch({ type: 'PREVIEW', preview })
+ } catch (err) {
+ dispatch({ type: 'ERROR', kind: toErrorKind(err), code: null })
+ }
+ })()
+ }, [state])
+
+ const add = useCallback(() => {
+ if (state.phase !== 'confirm' || !state.preview || state.adding) return
+ const { candidate, matchAlbumId } = state
+ void (async () => {
+ try {
+ const item = await api.addToCollection({
+ releaseId: candidate.id,
+ ...(matchAlbumId !== null ? { matchAlbumId } : {}),
+ })
+ dispatch({ type: 'ADDED', item })
+ } catch (err) {
+ const message =
+ err instanceof ApiError
+ ? err.code === 'duplicate'
+ ? 'Already in your collection.'
+ : err.detail ?? err.code
+ : 'Something went wrong'
+ dispatch({ type: 'ADD_ERROR', message })
+ }
+ })()
+ }, [state])
+
+ return (
+
+ {state.phase === 'scan' && (
+
+ )}
+
+ {state.phase === 'looking' &&
Searching…
}
+
+ {state.phase === 'candidates' && (
+
+
Which release is it?
+ {state.candidates.map((c) => (
+
dispatch({ type: 'SELECT', candidate: cand })} />
+ ))}
+
+ )}
+
+ {state.phase === 'confirm' && (
+
+ {!state.preview &&
Checking release…
}
+ {state.preview && (
+
dispatch({ type: 'SET_MATCH', albumId })}
+ onClearMatch={() => dispatch({ type: 'CLEAR_MATCH' })}
+ onAdd={add}
+ adding={state.adding}
+ addError={state.addError}
+ />
+ )}
+ dispatch({ type: 'RESET' })}
+ className="w-full rounded-xl border border-neutral-700 py-2 text-sm text-neutral-300"
+ >
+ Back to search
+
+
+ )}
+
+ {state.phase === 'added' && (
+
+
+
Added to collection ✓
+
+ View item
+
+
dispatch({ type: 'RESET' })}
+ className="w-full rounded-xl border border-neutral-700 py-2 text-sm text-neutral-300"
+ >
+ Add another
+
+
+ )}
+
+ {state.phase === 'error' && (
+
+
+ {state.kind === 'not_found' ? 'Nothing found' : 'Search failed'}
+
+
+ {state.kind === 'not_found' && 'Try different spelling, or add the year.'}
+ {state.kind === 'no_discogs_token' && 'Add your Discogs token in Settings first.'}
+ {state.kind === 'rate_limited' && 'Discogs is rate limiting us. Try again shortly.'}
+
+
dispatch({ type: 'RESET' })}
+ className="rounded-xl border border-neutral-700 px-4 py-2 text-sm text-neutral-300"
+ >
+ Back
+
+
+ )}
+
+ )
+}
+```
+
+Note: add `Link` to the react-router-dom import at the top (the sketch uses it in the added phase).
+
+Also add the camera entry point required by the spec ("Add — camera entry point plus text search"): below the search form, inside the `state.phase === 'scan'` block, render:
+
+```tsx
+
+ or scan a barcode →
+
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `npx vitest run web/test/add.test.tsx && npm test && npm run typecheck`
+Expected: ALL PASS (120 + 4 new = 124).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add -A && git commit -m "feat: add page with discogs text search into confirm flow"
+```
+
+---
+
+### Task 10: Settings page — integrations, sync, user management
+
+**Files:**
+- Modify: `web/src/pages/SettingsPage.tsx` (replace stub)
+- Test: `web/test/settings.test.tsx`
+
+- [ ] **Step 1: Write the failing test `web/test/settings.test.tsx`**
+
+```tsx
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { MemoryRouter } from 'react-router-dom'
+import { AuthProvider } from '../src/auth.js'
+import SettingsPage from '../src/pages/SettingsPage.js'
+import type { SettingsView, SyncState, User } from '../src/types.js'
+
+vi.mock('../src/api.js', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ api: {
+ ...actual.api,
+ getSettings: vi.fn(),
+ putSettings: vi.fn(),
+ syncStatus: vi.fn(),
+ startSync: vi.fn(),
+ listUsers: vi.fn(),
+ createUser: vi.fn(),
+ deleteUser: vi.fn(),
+ logout: vi.fn(),
+ },
+ }
+})
+
+import { api } from '../src/api.js'
+
+const emptyView: SettingsView = {
+ hasDiscogsToken: false,
+ discogsTokenMasked: null,
+ subsonicUrl: null,
+ subsonicUsername: null,
+ hasSubsonicPassword: false,
+}
+
+const idleSync: SyncState = { status: 'idle', error: null, lastSyncedAt: null, albums: 0 }
+
+const admin: User = { id: 1, username: 'sam', isAdmin: true }
+
+function jsonOk(body: unknown) {
+ return Promise.resolve(
+ new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } })
+ )
+}
+
+beforeEach(() => {
+ for (const fn of [api.getSettings, api.putSettings, api.syncStatus, api.startSync, api.listUsers, api.createUser, api.deleteUser, api.logout] as const) {
+ vi.mocked(fn).mockReset()
+ }
+ vi.mocked(api.getSettings).mockResolvedValue(emptyView as never)
+ vi.mocked(api.syncStatus).mockResolvedValue(idleSync as never)
+ vi.mocked(api.listUsers).mockResolvedValue({ users: [admin] } as never)
+})
+
+function renderSettings() {
+ return render(
+
+
+
+
+
+ )
+}
+
+describe('SettingsPage', () => {
+ it('saves the discogs token and shows the mask', async () => {
+ vi.mocked(api.putSettings).mockResolvedValue({ ...emptyView, hasDiscogsToken: true, discogsTokenMasked: '****6789' } as never)
+ renderSettings()
+ await userEvent.type(await screen.findByLabelText(/discogs token/i), 'abcdef0123456789')
+ await userEvent.click(screen.getByRole('button', { name: /save discogs/i }))
+ await waitFor(() => expect(api.putSettings).toHaveBeenCalledWith({ discogsToken: 'abcdef0123456789' }))
+ await waitFor(() => expect(screen.getByText(/token saved/i)).toBeTruthy())
+ })
+
+ it('clears the discogs token with an empty save', async () => {
+ vi.mocked(api.getSettings).mockResolvedValue({ ...emptyView, hasDiscogsToken: true, discogsTokenMasked: '****6789' } as never)
+ vi.mocked(api.putSettings).mockResolvedValue(emptyView as never)
+ renderSettings()
+ await userEvent.click(await screen.findByRole('button', { name: /save discogs/i }))
+ await waitFor(() => expect(api.putSettings).toHaveBeenCalledWith({ discogsToken: '' }))
+ })
+
+ it('saves subsonic config and surfaces validation errors', async () => {
+ const err = new (await import('../src/api.js')).ApiError(400, 'subsonic_unreachable', 'could not reach http://x')
+ vi.mocked(api.putSettings).mockRejectedValue(err)
+ renderSettings()
+ await userEvent.type(await screen.findByLabelText(/subsonic url/i), 'http://x')
+ await userEvent.type(screen.getByLabelText(/subsonic username/i), 'sam')
+ await userEvent.type(screen.getByLabelText(/subsonic password/i), 'pass')
+ await userEvent.click(screen.getByRole('button', { name: /save subsonic/i }))
+ await waitFor(() => expect(screen.getByText(/could not reach http:\/\/x/i)).toBeTruthy())
+ })
+
+ it('shows sync state and triggers a sync', async () => {
+ vi.mocked(api.startSync).mockResolvedValue({ ...idleSync, status: 'running' } as never)
+ vi.mocked(api.syncStatus).mockResolvedValue({
+ status: 'done',
+ error: null,
+ lastSyncedAt: '2026-08-29T12:00:00.000Z',
+ albums: 503,
+ } as never)
+ renderSettings()
+ await userEvent.click(await screen.findByRole('button', { name: /sync now/i }))
+ await waitFor(() => expect(api.startSync).toHaveBeenCalled())
+ await waitFor(() => expect(screen.getByText(/503 albums/i)).toBeTruthy())
+ })
+
+ it('admin manages users', async () => {
+ vi.mocked(api.createUser).mockResolvedValue({ id: 2, username: 'bob', isAdmin: false } as never)
+ renderSettings()
+ expect(await screen.findByText('sam')).toBeTruthy()
+ await userEvent.type(screen.getByLabelText(/new username/i), 'bob')
+ await userEvent.type(screen.getByLabelText(/new password/i), 'bobpass123')
+ await userEvent.click(screen.getByRole('button', { name: /add user/i }))
+ await waitFor(() => expect(api.createUser).toHaveBeenCalledWith('bob', 'bobpass123'))
+ expect(await screen.findByText('bob')).toBeTruthy()
+
+ vi.mocked(api.deleteUser).mockResolvedValue({ ok: true } as never)
+ await userEvent.click(screen.getByRole('button', { name: /remove bob/i }))
+ await waitFor(() => expect(api.deleteUser).toHaveBeenCalledWith(2))
+ })
+
+ it('hides user management from non-admins', async () => {
+ vi.useFakeTimers()
+ // AuthProvider reads /api/me via fetch; stub an authed non-admin session
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn((url: string) => {
+ if (url === '/api/setup') return Promise.resolve(new Response(JSON.stringify({ needed: false }), { status: 200 }))
+ return Promise.resolve(
+ new Response(JSON.stringify({ user: { id: 2, username: 'bob', isAdmin: false } }), { status: 200 })
+ )
+ })
+ )
+ renderSettings()
+ await vi.advanceTimersByTimeAsync(50)
+ expect(screen.queryByLabelText(/new username/i)).toBeNull()
+ vi.useRealTimers()
+ vi.unstubAllGlobals()
+ })
+
+ it('logout button calls the api', async () => {
+ vi.mocked(api.logout).mockResolvedValue({ ok: true } as never)
+ renderSettings()
+ await userEvent.click(await screen.findByRole('button', { name: /log out/i }))
+ expect(api.logout).toHaveBeenCalled()
+ })
+})
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `npx vitest run web/test/settings.test.tsx`
+Expected: FAIL — SettingsPage is a stub.
+
+- [ ] **Step 3: Implement `web/src/pages/SettingsPage.tsx`** (replace the stub)
+
+```tsx
+import { useCallback, useEffect, useState, type FormEvent } from 'react'
+import { useNavigate } from 'react-router-dom'
+import { api } from '../api.js'
+import { useAuth } from '../auth.js'
+import type { SettingsView, SyncState, User } from '../types.js'
+
+function Section({ title, children }: { title: string; children: React.ReactNode }) {
+ return (
+
+ )
+}
+
+const inputCls = 'w-full rounded-lg border border-neutral-700 bg-neutral-950 px-3 py-2 text-sm'
+
+export default function SettingsPage() {
+ const { user, onLogout } = useAuth()
+ const navigate = useNavigate()
+
+ const [view, setView] = useState(null)
+ const [discogsToken, setDiscogsToken] = useState('')
+ const [subsonicUrl, setSubsonicUrl] = useState('')
+ const [subsonicUsername, setSubsonicUsername] = useState('')
+ const [subsonicPassword, setSubsonicPassword] = useState('')
+ const [message, setMessage] = useState<{ kind: 'ok' | 'error'; text: string } | null>(null)
+
+ const [sync, setSync] = useState(null)
+
+ const [users, setUsers] = useState(null)
+ const [newUsername, setNewUsername] = useState('')
+ const [newPassword, setNewPassword] = useState('')
+
+ const refreshSettings = useCallback(() => {
+ void api.getSettings().then((v) => {
+ setView(v)
+ setSubsonicUrl(v.subsonicUrl ?? '')
+ setSubsonicUsername(v.subsonicUsername ?? '')
+ })
+ }, [])
+
+ useEffect(() => {
+ refreshSettings()
+ }, [refreshSettings])
+
+ useEffect(() => {
+ let alive = true
+ const poll = (): void => {
+ void api
+ .syncStatus()
+ .then((s) => {
+ if (alive) setSync(s)
+ return s
+ })
+ .then((s) => {
+ if (alive && s?.status === 'running') setTimeout(poll, 2000)
+ })
+ }
+ poll()
+ return () => {
+ alive = false
+ }
+ }, [])
+
+ useEffect(() => {
+ if (user?.isAdmin) {
+ void api.listUsers().then((res) => setUsers(res.users))
+ }
+ }, [user])
+
+ function flash(kind: 'ok' | 'error', text: string) {
+ setMessage({ kind, text })
+ setTimeout(() => setMessage(null), 4000)
+ }
+
+ function saveDiscogs(e: FormEvent) {
+ e.preventDefault()
+ void api
+ .putSettings({ discogsToken: discogsToken })
+ .then((v) => {
+ setView(v)
+ setDiscogsToken('')
+ flash('ok', 'Token saved')
+ })
+ .catch((err: unknown) => flash('error', err instanceof Error ? err.message : 'Save failed'))
+ }
+
+ function saveSubsonic(e: FormEvent) {
+ e.preventDefault()
+ void api
+ .putSettings({ subsonicUrl, subsonicUsername, subsonicPassword })
+ .then((v) => {
+ setView(v)
+ setSubsonicPassword('')
+ flash('ok', 'Music server saved — syncing library')
+ })
+ .catch((err: unknown) => flash('error', err instanceof Error ? err.message : 'Save failed'))
+ }
+
+ function addUser(e: FormEvent) {
+ e.preventDefault()
+ void api
+ .createUser(newUsername, newPassword)
+ .then((created) => {
+ setUsers((u) => [...(u ?? []), created])
+ setNewUsername('')
+ setNewPassword('')
+ })
+ .catch((err: unknown) => flash('error', err instanceof Error ? err.message : 'Could not add user'))
+ }
+
+ function removeUser(id: number, username: string) {
+ void api.deleteUser(id).then(() => setUsers((u) => (u ?? []).filter((x) => x.id !== id || x.username !== username)))
+ }
+
+ return (
+
+ {message && (
+
+ {message.text}
+
+ )}
+
+
+
+ {user?.username} {user?.isAdmin && (admin) }
+
+
+ void api.logout().then(() => {
+ onLogout()
+ navigate('/login')
+ })
+ }
+ className="rounded-xl border border-neutral-700 px-3 py-1.5 text-sm text-neutral-300"
+ >
+ Log out
+
+
+
+
+
+ Personal access token from discogs.com → Settings → Developers.{' '}
+ {view?.hasDiscogsToken && `Current: ${view.discogsTokenMasked}`}
+
+
+
+
+
+
+
+ {sync && (
+
+ {sync.status === 'running' && 'Syncing…'}
+ {sync.status === 'done' && `${sync.albums} albums synced`}
+ {sync.status === 'error' && Sync failed: {sync.error} }
+ {sync.status === 'idle' && 'Not synced yet'}
+ {sync.lastSyncedAt && (
+ · last {new Date(sync.lastSyncedAt).toLocaleString()}
+ )}
+
+ )}
+ void api.startSync().then(setSync).then(() => setTimeout(() => void api.syncStatus().then(setSync), 500))}
+ className="rounded-xl border border-neutral-700 px-3 py-1.5 text-sm text-neutral-300"
+ >
+ Sync now
+
+
+
+ {user?.isAdmin && (
+
+
+ {(users ?? []).map((u) => (
+
+
+ {u.username} {u.isAdmin && (admin) }
+
+ {!u.isAdmin && (
+ removeUser(u.id, u.username)}
+ className="text-xs text-red-400"
+ aria-label={`remove ${u.username}`}
+ >
+ remove
+
+ )}
+
+ ))}
+
+
+
+ )}
+
+ )
+}
+```
+
+Note: remove the unused `useNavigate`/`navigate` import pair if the logout handler uses plain navigation — it does use navigate('/login'), keep it.
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `npx vitest run web/test/settings.test.tsx && npm test && npm run typecheck`
+Expected: ALL PASS (124 + 7 new = 131). If the non-admin test is flaky under fake timers, switch it to real timers with `await waitFor(() => expect(screen.queryByLabelText(/new username/i)).toBeNull())` and a stubbed fetch instead.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add -A && git commit -m "feat: settings page with integrations, sync control and user management"
+```
+
+---
+
+### Task 11: PWA — manifest, icon, service worker
+
+**Files:**
+- Create: `web/public/manifest.webmanifest`, `web/public/icon.svg`, `web/public/sw.js`
+- Modify: `web/index.html`, `web/src/main.tsx`
+- Test: `web/test/pwa.test.ts`
+
+- [ ] **Step 1: Write the failing test `web/test/pwa.test.ts`**
+
+```ts
+import { describe, it, expect } from 'vitest'
+import { readFileSync } from 'node:fs'
+import path from 'node:path'
+
+const pub = path.resolve(__dirname, '../public')
+
+describe('PWA assets', () => {
+ it('manifest has required fields and the icon exists', () => {
+ const manifest = JSON.parse(readFileSync(path.join(pub, 'manifest.webmanifest'), 'utf8')) as {
+ name: string
+ display: string
+ start_url: string
+ icons: { src: string; sizes: string; type: string }[]
+ }
+ expect(manifest.name).toContain('record-shop')
+ expect(manifest.display).toBe('standalone')
+ expect(manifest.start_url).toBe('/')
+ expect(manifest.icons.some((i) => i.src === '/icon.svg')).toBe(true)
+ expect(() => readFileSync(path.join(pub, 'icon.svg'))).not.toThrow()
+ })
+
+ it('service worker registers a fetch handler', () => {
+ const sw = readFileSync(path.join(pub, 'sw.js'), 'utf8')
+ expect(sw).toContain("addEventListener('fetch'")
+ })
+})
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `npx vitest run web/test/pwa.test.ts`
+Expected: FAIL — files missing.
+
+- [ ] **Step 3: Create `web/public/icon.svg`**
+
+```svg
+
+
+
+
+
+
+
+```
+
+- [ ] **Step 4: Create `web/public/manifest.webmanifest`**
+
+```json
+{
+ "name": "record-shop",
+ "short_name": "record-shop",
+ "description": "Track your physical music collection",
+ "start_url": "/",
+ "display": "standalone",
+ "background_color": "#0a0a0a",
+ "theme_color": "#0a0a0a",
+ "icons": [
+ { "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" },
+ { "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "maskable" }
+ ]
+}
+```
+
+- [ ] **Step 5: Create `web/public/sw.js`** — deliberately minimal: its only job is to enable installability; the app is online-only by design.
+
+```js
+self.addEventListener('install', () => {
+ self.skipWaiting()
+})
+
+self.addEventListener('activate', (event) => {
+ event.waitUntil(self.clients.claim())
+})
+
+self.addEventListener('fetch', () => {
+ // no-op: presence of a fetch handler enables PWA install
+})
+```
+
+- [ ] **Step 6: Modify `web/index.html`** — add inside ``:
+
+```html
+
+
+
+```
+
+- [ ] **Step 7: Modify `web/src/main.tsx`** — register the worker in production builds only:
+
+```tsx
+import React from 'react'
+import ReactDOM from 'react-dom/client'
+import App from './App'
+import './styles.css'
+
+ReactDOM.createRoot(document.getElementById('root')!).render(
+
+
+
+)
+
+if ('serviceWorker' in navigator && import.meta.env.PROD) {
+ void navigator.serviceWorker.register('/sw.js')
+}
+```
+
+- [ ] **Step 8: Run tests to verify they pass**
+
+Run: `npx vitest run web/test/pwa.test.ts && npm test && npm run typecheck`
+Expected: ALL PASS (131 + 2 new = 133).
+
+- [ ] **Step 9: Commit**
+
+```bash
+git add -A && git commit -m "feat: pwa manifest, icon and minimal service worker"
+```
+
+---
+
+### Task 12: Docker web build + full-stack verification
+
+**Files:**
+- Modify: `Dockerfile`, `README.md`
+
+- [ ] **Step 1: Modify `Dockerfile`** — build the web app and ship it. Replace the build stage's source copy and gate, and add the web dist to the runtime stage:
+
+```dockerfile
+# ---- build stage: deps + compile + test ----
+FROM node:22-slim AS build
+WORKDIR /app
+
+# Install build tools as a fallback for native modules without prebuilds
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends python3 make g++ \
+ && rm -rf /var/lib/apt/lists/*
+
+COPY package.json package-lock.json ./
+RUN npm ci
+
+COPY tsconfig.json vitest.config.ts ./
+COPY server ./server
+COPY web ./web
+RUN npm run build:server && npm run build:web && npm test
+RUN npm prune --omit=dev
+
+# ---- runtime stage ----
+FROM node:22-slim
+ENV NODE_ENV=production
+WORKDIR /app
+
+COPY package.json package-lock.json ./
+COPY --from=build /app/node_modules ./node_modules
+COPY --from=build /app/server/dist ./server/dist
+COPY --from=build /app/web/dist ./web/dist
+
+ENV PORT=3000
+ENV DATA_DIR=/data
+VOLUME /data
+EXPOSE 3000
+
+CMD ["node", "server/dist/index.js"]
+```
+
+(The test gate stays after both builds so the shipped image is the tested artifact.)
+
+- [ ] **Step 2: Verify the full local pipeline**
+
+Run: `npm test && npm run typecheck && npm run build`
+Expected: 133 tests pass, typecheck clean, `server/dist/` AND `web/dist/` produced.
+
+- [ ] **Step 3: Verify the SPA is actually served (backend integration)**
+
+```bash
+node server/dist/index.js &
+sleep 1
+curl -s http://localhost:3000/ | grep -o '[^<]* '
+curl -s http://localhost:3000/library | grep -o '[^<]* '
+curl -s http://localhost:3000/api/health
+kill %1
+```
+Expected: `record-shop ` twice (SPA + fallback) and `{"ok":true}`. Then remove the `data/` dir created by the run.
+
+- [ ] **Step 4: Verify a dev-mode sanity pass**
+
+Run: `npm run dev` (starts API on 3000 + Vite on 5173), open , complete setup, and confirm the library page renders. Stop with Ctrl+C. (Automated tests already cover the flows; this is the human smoke test.)
+
+- [ ] **Step 5: Update `README.md`** — replace the Development section:
+
+````markdown
+## Development
+
+```bash
+npm install
+npm test # vitest (server + web)
+npm run dev # API on :3000 + Vite dev server on :5173 (proxied)
+npm run build # server + web production build
+```
+
+Spec: `docs/superpowers/specs/2026-08-29-record-shop-design.md`
+````
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add -A && git commit -m "feat: docker builds the web app, full-stack serving verified"
+```
+
+---
+
+## Final device checklist (manual, before release)
+
+Automated tests cannot cover the camera on real hardware. On iOS Safari and Android Chrome:
+
+1. Install the PWA from the home-screen prompt; launch standalone
+2. Scan a real CD barcode (EAN-13) — candidates appear, pick the right edition, add
+3. Scan the same item again — duplicate warning shows on confirm
+4. Scan an unlisted barcode — "Nothing found" → manual search escape hatch works
+5. Deny camera permission once — denial guidance shows, app recovers after allowing + reload
+6. Library grid scrolls smoothly with ~200 items; rip-status dots are accurate against your Subsonic server