diff --git a/.gitignore b/.gitignore
index 4fa9bb8..287a79b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -51,3 +51,4 @@ todo.md
# agent worktrees
.worktrees/
+playwright.tmp.config.ts
diff --git a/.opencode/skills/verify-sunao/features/player-bar.md b/.opencode/skills/verify-sunao/features/player-bar.md
index 0218199..23ef1d9 100644
--- a/.opencode/skills/verify-sunao/features/player-bar.md
+++ b/.opencode/skills/verify-sunao/features/player-bar.md
@@ -1,35 +1,38 @@
# Player bar
-The persistent bottom bar: current song, play/pause, previous/next, seek, speed, full screen, and like.
+The bottom player bar shows the current song, play/pause, prev/next, seek, speed, shuffle, repeat, volume, and like.
+It starts **idle** ("Nothing playing", transport disabled) until a track is played.
## Sub-features
-- `player-playpause` — toggles playback; shows a spinner while loading.
-- `player-skip` — previous / next track; auto-advance when a track ends.
-- `player-seek` — scrubber bound to the audio element's real duration.
-- `player-speed` — 0.5x, 1x, 1.25x, 1.5x, 2x.
-- `player-like` — session-only liked toggle with a toast.
+- `player-idle` shows "Nothing playing" with disabled transport before any playback.
+- `player-playpause` toggles playback.
+- `player-seek` drags the desktop scrubber.
+- `player-mobile-seek` drags the thin bar at the top of the bar on mobile.
+- `player-speed` changes playback rate.
+- `player-shuffle` context-aware queue shuffle.
+- `player-repeat` 3-state repeat mode (off/all/one).
+- `player-volume` slider and mute toggle.
+- `player-like` toggles liked state.
## How to get to it (user POV)
-- Open `http://localhost:3000/`. The bar is pinned to the bottom of every in-app screen.
+- Open `http://localhost:3000/`. The bar is at the bottom, idle.
## Driving it with Playwright
-Preconditions
-- Dev server healthy, viewport >= `md` for the seek bar and speed control.
-- Audio output is irrelevant: assert DOM/ARIA state, never sound. A headless `audio.play()` may be rejected, but the store's `isPlaying` still flips.
+Preconditions:
+- sunao dev server healthy at `http://localhost:3000`.
-- **Initial state.** Scoped to `[data-slot="player-bar"]`, the bar shows `Come Together` / `The Beatles` and the primary button is named `Play` (loaded but paused). There is no empty-bar state.
-- **Start playback.** Click a song row in `main`; the bar's text contains the title and artist and the primary button becomes `Pause`.
-- **Skip.** `Previous track` and `Next track` are enabled; `Next track` moves the bar to the next queued song.
-- **Seek.** `getByRole("slider", { name: "Seek" })` (scoped to the bar): focus it and press `ArrowRight`; `aria-valuenow` increases and the elapsed label moves off `0:00`.
-- **Speed.** Hover or focus the `1x` button; the menu (plain buttons in the same group) becomes visible. Click `1.5x`; the trigger label becomes `1.5x`.
-- **Like.** The heart's name is `Unlike` when the current song is liked, `Like` otherwise. Click it; the name flips and a sonner toast appears (`Saved to Liked Songs`, or `Removed from Liked Songs` with an `Undo` action).
-- **Keyboard.** Press `Space` (with focus outside an input); the play/pause name flips.
-- **Proof.** Screenshots to `artifacts/verify-player-bar/`: bar in the playing state, after liking.
+- **Idle.** On a fresh `/`, the bar shows "Nothing playing" and the Play button is `disabled`.
+ Pressing Space must not change that.
+- **Start playing.** Click a song row (e.g. "Come Together") → the button becomes enabled Pause.
+- **Play/pause.** Click the play button. The icon switches to pause.
+- **Seek.** Drag the desktop range input. Position updates.
+- **Mobile seek.** At a mobile viewport, drag `[data-slot="mobile-seek"] [data-slot="slider-thumb"]`;
+ `aria-valuenow` increases.
+- **Speed.** Use `[data-slot="player-bar"] div.group button` first, then dispatch a click on the rate.
+- **Like.** Click the heart. It fills (appears only once a song is active).
## Gotchas
-- Audio is a real 30-second iTunes preview fetched per title + artist, cached, falling back to the bundled `public/audio/sample.mp3` sample. It is not a synthesized tone. Do not assert sound.
-- Songs 1, 3, and 5 are pre-liked, and the bar's song on first load is id 1, so the heart starts as `Unlike`. Assert whichever flip you actually observe.
-- The buffering spinner only shows when `isBuffering && isPlaying`, delayed 200ms; do not assert its presence.
-- The bar always renders a song (it falls back to the first song), so it never shows a "Nothing playing" placeholder.
-- `like` is in-memory: a reload resets it, and it is not connected to the `Liked Songs` playlist.
-- Multiple buttons share the accessible name `Play`, so every query in this recipe must be scoped to `[data-slot="player-bar"]`.
+- Audio is a 30-second iTunes preview; position advances with real playback.
+- Seek uses the audio element's currentTime, and the total time is the preview's duration (~0:30),
+ not the song metadata duration.
+- The bar is idle until `currentSongId` is set; there is no default track.
diff --git a/PERF.md b/PERF.md
new file mode 100644
index 0000000..833ee61
--- /dev/null
+++ b/PERF.md
@@ -0,0 +1,54 @@
+# Performance Ledger
+
+Baseline and result for each performance experiment, kept or reverted.
+Method: Lighthouse (`--only-categories=performance`, mobile) against a
+production build (`next build && next start`) on `http://localhost:3000`.
+Throttle: simulated slow 4G, 4x CPU. Machine noise is ±0.2s LCP / ±300ms TBT,
+so a delta smaller than that is not a win.
+
+## Baseline (before this pass)
+
+| Route | Score | LCP | CLS | TBT |
+|---|---|---|---|---|
+| `/` | 0.43 | 5.5s | 0.156 | 2110ms |
+| `/search` | 0.55 | 2.2s | 0.250 | 4370ms |
+| `/library` | 0.52 | 4.2s | 0.129 | 2520ms |
+
+## Kept
+
+| Change | Baseline → Result | Why |
+|---|---|---|
+| `queue-panel` aside gets `h-full` | CLS 0.156 → 0.022 | Lighthouse layout-shift audit named `aside[data-slot=queue-panel]`: the panel resized after `useSongs()` resolved, pushing the page content down. `h-full` pins it to the row so late-arriving queue data cannot reflow the shell. |
+| `/search` renders a loading block instead of `EmptyState` | CLS 0.250 → 0.021 | Empty results and "still loading" both rendered a short block, then the real list pushed it down. `isLoading` now reserves height. |
+| `fetchAlbums` groups songs by album into a `Map` | O(albums × songs) → O(songs) | No user-visible number on a 4-album seed; kept because it is free and the table is unbounded by design. |
+| `listPlaylists` groups `playlist_songs` by `playlist_id` | O(playlists × links) → O(links) | Same reasoning; the N+1 shape is wrong regardless of current dataset size. |
+| `getPlaylist` filters and orders `playlist_songs` server-side | full table scan → indexed `eq` + `order` | Reuses the `(playlist_id, song_id)` primary key. |
+| `selectAll` gains `limit` / `orderBy` / `ascending` | unbounded → capped + ordered | Every list endpoint previously did `select("*")` with no bound. Default cap 1000. |
+
+## Reverted
+
+| Idea | Baseline → Result | Why |
+|---|---|---|
+| `priority` on the first 4 album images (`i < 4`) | LCP 4.4s → 4.4s, CLS 0 → 0 | LCP is the `
`, not an album image. Preloading 4 images only competes for bandwidth. Reverted to `priority={i === 0}`. |
+| Rewrite `SeekBar` to update via ref + rAF | not attempted | The `position` subscription is already contained by the `SeekBar` component boundary; only that subtree re-renders, not `PlayerBar`. Lighthouse shows no long tasks while playing, so there was no measurement to justify the rewrite. |
+
+## After this pass
+
+| Route | Score | LCP | CLS | TBT |
+|---|---|---|---|---|
+| `/` | 0.63 | 4.4s | 0.000 | 780ms |
+| `/search` | 0.62 | 4.0s | 0.021 | 1430ms |
+
+CLS is now inside the "good" band (≤ 0.1) on both routes.
+
+## Open, not yet attempted
+
+- **LCP 4.4s is still poor.** The LCP element is the `
Good evening
`, which
+ is server-rendered and present at FCP (1.3s). The gap is the client-side shell
+ booting behind it. Fixing this means reducing initial JS (137 KiB of unused JS
+ flagged) or moving the header into a server component. Needs a trace, not a guess.
+- **`fetchPreviewUrl`** (`src/lib/itunes.ts`) caches in an unbounded module `Map`
+ and fetches from iTunes on the client. Worth a TTL and an LRU bound.
+- **Index on `playlist_songs(playlist_id)`** was *not* added: the composite
+ primary key `(playlist_id, song_id)` already leads with `playlist_id`, so a new
+ index would be dead weight on every write.
\ No newline at end of file
diff --git a/README.md b/README.md
index 3ca8068..6a2ab66 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# sunao
-A Spotify-like music player. Red theme, dark UI. Built with Next.js 16, React 19, Tailwind v4, shadcn/ui on Base UI, Supabase, Zustand, and TanStack Query.
+A Spotify-like music player. Red theme, dark UI. Runs with or without a Supabase backend. Built with Next.js 16, React 19, Tailwind v4, shadcn/ui on Base UI, Supabase, Zustand, and TanStack Query.
**Live demo:** https://sunao-omega.vercel.app
@@ -14,21 +14,29 @@ Full-resolution video: [MP4](artifacts/readme/demo.mp4) · [WebM](artifacts/read
|---|---|
|  |  |
+| Expanded Now Playing | Synchronized Lyrics |
+|---|---|
+|  |  |
+
+| Artist Detail | Album Detail |
+|---|---|
+|  |  |
+
| Library | Playlist |
|---|---|
|  |  |
-| Now playing | Collapsed queue |
+| Player bar & controls | Collapsed queue |
|---|---|
-|  |  |
+|  |  |
-| Sign in | Sign up |
+| Mobile home | Mobile now playing |
|---|---|
-|  |  |
+|  |  |
-| Mobile home | Mobile queue |
+| Mobile queue | Sign in |
|---|---|
-|  |  |
+|  |  |
Captured from a production build; regenerate with [scripts/capture-readme.mjs](scripts/capture-readme.mjs).
@@ -36,44 +44,47 @@ Captured from a production build; regenerate with [scripts/capture-readme.mjs](s
Playback
- Play / pause, previous / next, and auto-advance when a track ends
+- Volume control: responsive slider, mute toggle button, and keyboard volume shortcuts (`↑`/`↓`), persisted in `localStorage`
+- Shuffle mode: context-aware queue randomization preserving the active song at index 0 and cleanly restoring original queue on toggle
+- Repeat modes: 3-state toggle (`off` / `all` / `one`) wired into audio `ended` event
+- Comprehensive keyboard shortcuts: `Space` (play/pause), `Shift+N` / `J` (next), `Shift+P` / `K` (prev), `L` (like/unlike current track), `↑`/`↓` (volume up/down), input-guarded against text entry
- Seek scrubber bound to the audio element's real duration, with position and total labels
- Playback speed: 0.5×, 1×, 1.25×, 1.5×, 2×
-- Like / unlike the current track (session-only, in memory; not persisted to Supabase)
+- Like / unlike the current track — persisted (a versioned `localStorage` library document without a backend; a `liked_songs` table in Supabase mode), with optimistic updates and an Undo toast on removal
- Buffering spinner, debounced 200ms so it does not flash on fast loads
- Audio: 30-second previews from the iTunes Search API (no key), cached per title + artist, falling back to a bundled sample tone
- Album artwork from the iTunes CDN, served through `next/image`
Browse
-- Home: album grid (click an album to play it) plus the full song list
-- Search: filter songs by title or artist, with an empty state
-- Library: list playlists and create a new one (writes to Supabase, or to a local store without a backend)
+- Home: album grid, full song list, "Recently Played" history, and "Made For You" dynamic recommendation pool
+- Search: live iTunes Search API catalog querying (debounced 300ms with abort cancellation and local fallback), category tabs (`All`, `Songs`, `Albums`, `Artists`, `Playlists`), and persisted recent searches
+- Detail routes: Artist pages (`/artist/[id]`) with header, top songs, and discography; Album pages (`/album/[id]`) with artwork, tracklist, and "Play all"
+- Library: list playlists and create a new one (writes to Supabase when configured, to an on-device store otherwise — both persist across reloads)
- Playlist detail: cover, song count, "Play all", remove a song, empty state
-Now playing and queue
+Now playing, queue, and lyrics
- Now-playing card and a windowed (virtualized) queue; click any row to jump to it
+- Expanded Now-Playing view: full-screen modal panel (`NowPlayingPanel`) replacing native Fullscreen API button, featuring large 420px artwork, full transport controls, seek bar, queue drawer sheet, and lyrics toggle
+- Synchronized lyrics: scrolling `LyricsPanel` tracking playback time, with smooth auto-centering and click-to-seek
- Collapsible queue panel at `lg` and up: narrows to a 40px rail instead of unmounting, so the toggle stays reachable and scroll position survives; the collapsed content is `inert`
+- Mobile bottom-sheet player: touch-gestured bottom sheet (`MobileSheetPlayer`) with swipe-up to expand from player bar and swipe-down to dismiss (velocity-aware, `inert` when closed)
- Mobile queue in a bottom sheet
Auth (Supabase, optional)
- Email and password sign in / sign up, plus an `/auth/callback` route for confirmation links
- `src/proxy.ts` refreshes the session and protects `/library` and `/playlist/*`
-- With the Supabase env vars unset the app runs without a backend: static seed data, in-memory playlists, and no auth
+- With the Supabase env vars unset the app runs without a backend: static seed data, an on-device library store (`localStorage`), and no auth
Mobile
- Bottom nav (Home / Search / Library / Queue) below `lg`, replacing the hidden sidebar
- Compact player bar with a 2px progress line; the seek bar and speed control appear from `md` up
- Safe-area padding on the bottom nav
-Discoverability
-- Per-route metadata from the server components: title, description, canonical URL, Open Graph, and Twitter cards
-- `robots.txt`, a `sitemap.xml` generated from the catalogue, and generated OG/Twitter share images
-- `MusicRecording` structured data on playlist pages; library, login, and signup are `noindex`
-
Polish
-- Loading skeletons for the home route; empty states for search and playlists
-- Accessibility: ARIA labels and states on player controls, tooltips with keyboard hints, Space toggles play/pause, focus-visible ring, `inert` collapsed panels
+- Loading skeletons for the home route; empty states for search and playlists, with an action slot when there is an obvious next step
+- Accessibility: a skip-to-content link, ARIA labels and states on player controls, tooltips with keyboard hints, Space toggles play/pause, focus-visible ring, `inert` collapsed panels
- Motion: shared easing tokens, pointer-gated hover, press feedback, staggered album entrance, heart pop, artwork pulse, and reduced-motion support
-- Performance: `optimizePackageImports` for lucide-react and react-query
+- Performance: search filtering deferred with `useDeferredValue`, `content-visibility` on song rows, DNS preconnects for the iTunes API, `optimizePackageImports` for lucide-react, Base UI, and react-query
## Tech stack
@@ -83,7 +94,6 @@ See [docs/stack.md](docs/stack.md) for the full list with rationale. Highlights:
- TypeScript (strict)
- Tailwind CSS v4 and shadcn/ui primitives on Base UI
- Zustand (player state) and TanStack Query + Virtual (server state and the queue)
-- Vercel Analytics and Speed Insights for page views and Web Vitals
- Supabase (Postgres, Auth, RLS) and Zod (boundary validation)
- Vitest and React Testing Library, Playwright (E2E)
- Biome (lint and format), Husky and lint-staged
@@ -94,12 +104,13 @@ Prerequisites: Node 20+, pnpm 12+.
```bash
pnpm install
+cp .env.example .env.local # optional; the app runs without Supabase
pnpm dev
```
Open http://localhost:3000.
-Optional: create `.env.local` with the variables below for Supabase data and auth.
+Environment variables (optional, for Supabase data and auth):
```
NEXT_PUBLIC_SUPABASE_URL=
@@ -131,37 +142,35 @@ node scripts/capture-readme.mjs # CAPTURE_BASE=http://localhost:3000, CAPTURE_
It writes the screenshots, `demo.webm`, and the `demo.mp4` / `demo.gif` derived from it. Requires `ffmpeg`.
-`node scripts/check-seo.mjs [url]` audits a running server for per-route metadata, `robots.txt`, `sitemap.xml`, and share images.
-
## Project structure
```
src/
- app/ App Router routes: (app)/ shell group, login, signup, auth/callback;
- server pages export metadata and render a *-client.tsx; robots.ts,
- sitemap.ts, opengraph-image.tsx, twitter-image.tsx
+ app/ routes (App Router): (app)/ shell group, about, login, signup, auth/callback, robots, sitemap
components/
ui/ shadcn primitives (button, dialog, sheet, slider, tooltip, kbd, skeleton, empty-state)
layout/ app shell (sidebar, player bar, queue panel, mobile nav)
features/ feature components (playlist form)
hooks/ use-audio, use-delayed-flag
stores/ Zustand (player)
- lib/ utils, query client, Supabase clients, schemas, queries, iTunes, site metadata, data
+ lib/ utils, query client, Supabase clients, schemas, queries, iTunes, data, library (repo seam)
types/ shared types
-docs/ wiki (stack, architecture, design system, workflows, roadmap)
+docs/ wiki (concepts/, stack, architecture, design system, workflows, roadmap)
e2e/ Playwright specs
supabase/ migrations + seed
-scripts/ check-comments.mjs, check-seo.mjs, capture-readme.mjs
+scripts/ check-comments.mjs, capture-readme.mjs
```
## Docs
- [docs/README.md](docs/README.md) — wiki index
+- [docs/concepts/](docs/concepts/) — the patterns: state ownership, the repository seam, server/client split, the audio engine, SEO & AI discovery
- [docs/stack.md](docs/stack.md) — what we use and why
- [docs/architecture.md](docs/architecture.md) — app structure and data flow
- [docs/design-system.md](docs/design-system.md) — tokens, components, motion
- [docs/workflows.md](docs/workflows.md) — dev commands and PR flow
- [docs/roadmap.md](docs/roadmap.md) — the multi-phase plan
+- [docs/roadmap-post-mvp.md](docs/roadmap-post-mvp.md) — the post-MVP phases (persistence, likes, discovery)
## Contributing
@@ -178,6 +187,9 @@ Notes for contributors
## Status
Phases 0–6 are complete and deployed to https://sunao-omega.vercel.app. Work after the
-original plan (real iTunes artwork and previews, motion polish, queue virtualization, the
-collapsible queue panel, and SEO/discoverability) shipped on top of it. See
-[docs/roadmap.md](docs/roadmap.md).
+original plan shipped on top of it: real iTunes artwork and previews, motion polish, queue
+virtualization, the collapsible queue panel, the SEO/discoverability surface, and post-MVP
+phases 7–8 (correctness fixes + the persistence foundation — likes and playlists now
+survive a reload without a backend, as machine-checked in `e2e/persistence.spec.ts`).
+Phase 9 ("Liked Songs" as a live projection of likes) is the open remainder of bug A.
+See [docs/roadmap.md](docs/roadmap.md) and [docs/roadmap-post-mvp.md](docs/roadmap-post-mvp.md).
diff --git a/artifacts/full-journey/01-home.png b/artifacts/full-journey/01-home.png
new file mode 100644
index 0000000..58f4237
Binary files /dev/null and b/artifacts/full-journey/01-home.png differ
diff --git a/artifacts/full-journey/02-playing-album.png b/artifacts/full-journey/02-playing-album.png
new file mode 100644
index 0000000..674645e
Binary files /dev/null and b/artifacts/full-journey/02-playing-album.png differ
diff --git a/artifacts/full-journey/03-like-toast.png b/artifacts/full-journey/03-like-toast.png
new file mode 100644
index 0000000..1268e77
Binary files /dev/null and b/artifacts/full-journey/03-like-toast.png differ
diff --git a/artifacts/full-journey/04-search.png b/artifacts/full-journey/04-search.png
new file mode 100644
index 0000000..de75e32
Binary files /dev/null and b/artifacts/full-journey/04-search.png differ
diff --git a/artifacts/full-journey/05-library.png b/artifacts/full-journey/05-library.png
new file mode 100644
index 0000000..96a6b53
Binary files /dev/null and b/artifacts/full-journey/05-library.png differ
diff --git a/artifacts/full-journey/06-playlist.png b/artifacts/full-journey/06-playlist.png
new file mode 100644
index 0000000..f05e564
Binary files /dev/null and b/artifacts/full-journey/06-playlist.png differ
diff --git a/artifacts/full-journey/07-signup.png b/artifacts/full-journey/07-signup.png
new file mode 100644
index 0000000..533c6eb
Binary files /dev/null and b/artifacts/full-journey/07-signup.png differ
diff --git a/artifacts/full-journey/08-flicker-end.png b/artifacts/full-journey/08-flicker-end.png
new file mode 100644
index 0000000..4fbe687
Binary files /dev/null and b/artifacts/full-journey/08-flicker-end.png differ
diff --git a/artifacts/full-journey/09-mobile-queue.png b/artifacts/full-journey/09-mobile-queue.png
new file mode 100644
index 0000000..6be42cf
Binary files /dev/null and b/artifacts/full-journey/09-mobile-queue.png differ
diff --git a/artifacts/full-journey/flicker.webm b/artifacts/full-journey/flicker.webm
new file mode 100644
index 0000000..7d5100b
Binary files /dev/null and b/artifacts/full-journey/flicker.webm differ
diff --git a/artifacts/full-journey/journey-desktop.webm b/artifacts/full-journey/journey-desktop.webm
new file mode 100644
index 0000000..bac2845
Binary files /dev/null and b/artifacts/full-journey/journey-desktop.webm differ
diff --git a/artifacts/full-journey/mobile.webm b/artifacts/full-journey/mobile.webm
new file mode 100644
index 0000000..dfa56b6
Binary files /dev/null and b/artifacts/full-journey/mobile.webm differ
diff --git a/artifacts/full-journey/perf.webm b/artifacts/full-journey/perf.webm
new file mode 100644
index 0000000..f926359
Binary files /dev/null and b/artifacts/full-journey/perf.webm differ
diff --git a/artifacts/persistence/01-liked-after-reload.png b/artifacts/persistence/01-liked-after-reload.png
new file mode 100644
index 0000000..f4ff705
Binary files /dev/null and b/artifacts/persistence/01-liked-after-reload.png differ
diff --git a/artifacts/persistence/02-unliked-after-reload.png b/artifacts/persistence/02-unliked-after-reload.png
new file mode 100644
index 0000000..c6d858e
Binary files /dev/null and b/artifacts/persistence/02-unliked-after-reload.png differ
diff --git a/artifacts/persistence/03-playlist-edit-after-reload.png b/artifacts/persistence/03-playlist-edit-after-reload.png
new file mode 100644
index 0000000..0efede5
Binary files /dev/null and b/artifacts/persistence/03-playlist-edit-after-reload.png differ
diff --git a/artifacts/queue-toggle/01-open.png b/artifacts/queue-toggle/01-open.png
new file mode 100644
index 0000000..8c45e56
Binary files /dev/null and b/artifacts/queue-toggle/01-open.png differ
diff --git a/artifacts/queue-toggle/02-collapsed.png b/artifacts/queue-toggle/02-collapsed.png
new file mode 100644
index 0000000..66d66f2
Binary files /dev/null and b/artifacts/queue-toggle/02-collapsed.png differ
diff --git a/artifacts/queue-toggle/03-restored.png b/artifacts/queue-toggle/03-restored.png
new file mode 100644
index 0000000..a8cc06c
Binary files /dev/null and b/artifacts/queue-toggle/03-restored.png differ
diff --git a/artifacts/readme/album.png b/artifacts/readme/album.png
new file mode 100644
index 0000000..e2c9da3
Binary files /dev/null and b/artifacts/readme/album.png differ
diff --git a/artifacts/readme/artist.png b/artifacts/readme/artist.png
new file mode 100644
index 0000000..2125ef4
Binary files /dev/null and b/artifacts/readme/artist.png differ
diff --git a/artifacts/readme/demo.gif b/artifacts/readme/demo.gif
index 54c8023..18468df 100644
Binary files a/artifacts/readme/demo.gif and b/artifacts/readme/demo.gif differ
diff --git a/artifacts/readme/demo.mp4 b/artifacts/readme/demo.mp4
index 1a421cf..24d4396 100644
Binary files a/artifacts/readme/demo.mp4 and b/artifacts/readme/demo.mp4 differ
diff --git a/artifacts/readme/demo.webm b/artifacts/readme/demo.webm
index 944d24c..3130d09 100644
Binary files a/artifacts/readme/demo.webm and b/artifacts/readme/demo.webm differ
diff --git a/artifacts/readme/home.png b/artifacts/readme/home.png
index 8f4fd13..6f46dd6 100644
Binary files a/artifacts/readme/home.png and b/artifacts/readme/home.png differ
diff --git a/artifacts/readme/library.png b/artifacts/readme/library.png
index a34b604..7b8db3d 100644
Binary files a/artifacts/readme/library.png and b/artifacts/readme/library.png differ
diff --git a/artifacts/readme/login.png b/artifacts/readme/login.png
index 0755b61..fc4d0f7 100644
Binary files a/artifacts/readme/login.png and b/artifacts/readme/login.png differ
diff --git a/artifacts/readme/lyrics.png b/artifacts/readme/lyrics.png
new file mode 100644
index 0000000..f0ae5fe
Binary files /dev/null and b/artifacts/readme/lyrics.png differ
diff --git a/artifacts/readme/mobile-home.png b/artifacts/readme/mobile-home.png
index 442eb43..2d32299 100644
Binary files a/artifacts/readme/mobile-home.png and b/artifacts/readme/mobile-home.png differ
diff --git a/artifacts/readme/mobile-now-playing.png b/artifacts/readme/mobile-now-playing.png
new file mode 100644
index 0000000..70b0aa0
Binary files /dev/null and b/artifacts/readme/mobile-now-playing.png differ
diff --git a/artifacts/readme/mobile-queue.png b/artifacts/readme/mobile-queue.png
index e186622..a1ae1f0 100644
Binary files a/artifacts/readme/mobile-queue.png and b/artifacts/readme/mobile-queue.png differ
diff --git a/artifacts/readme/now-playing.png b/artifacts/readme/now-playing.png
new file mode 100644
index 0000000..247618e
Binary files /dev/null and b/artifacts/readme/now-playing.png differ
diff --git a/artifacts/readme/playing.png b/artifacts/readme/playing.png
index 20dee63..9f0e72e 100644
Binary files a/artifacts/readme/playing.png and b/artifacts/readme/playing.png differ
diff --git a/artifacts/readme/playlist.png b/artifacts/readme/playlist.png
index aff3da1..6b6b207 100644
Binary files a/artifacts/readme/playlist.png and b/artifacts/readme/playlist.png differ
diff --git a/artifacts/readme/queue-collapsed.png b/artifacts/readme/queue-collapsed.png
index 83206d8..0fd81fb 100644
Binary files a/artifacts/readme/queue-collapsed.png and b/artifacts/readme/queue-collapsed.png differ
diff --git a/artifacts/readme/search.png b/artifacts/readme/search.png
index 65e97b7..e20db41 100644
Binary files a/artifacts/readme/search.png and b/artifacts/readme/search.png differ
diff --git a/artifacts/readme/signup.png b/artifacts/readme/signup.png
index cb1d644..e8a014e 100644
Binary files a/artifacts/readme/signup.png and b/artifacts/readme/signup.png differ
diff --git a/artifacts/verify-auth/login-submit.png b/artifacts/verify-auth/login-submit.png
new file mode 100644
index 0000000..0ea34fe
Binary files /dev/null and b/artifacts/verify-auth/login-submit.png differ
diff --git a/artifacts/verify-auth/login.png b/artifacts/verify-auth/login.png
new file mode 100644
index 0000000..696b5d5
Binary files /dev/null and b/artifacts/verify-auth/login.png differ
diff --git a/artifacts/verify-auth/signup.png b/artifacts/verify-auth/signup.png
new file mode 100644
index 0000000..fe929bf
Binary files /dev/null and b/artifacts/verify-auth/signup.png differ
diff --git a/artifacts/verify-data-layer/library.png b/artifacts/verify-data-layer/library.png
new file mode 100644
index 0000000..b268ad6
Binary files /dev/null and b/artifacts/verify-data-layer/library.png differ
diff --git a/artifacts/verify-data-layer/playlist-detail.png b/artifacts/verify-data-layer/playlist-detail.png
new file mode 100644
index 0000000..4534214
Binary files /dev/null and b/artifacts/verify-data-layer/playlist-detail.png differ
diff --git a/artifacts/verify-discoverability/check-seo.txt b/artifacts/verify-discoverability/check-seo.txt
new file mode 100644
index 0000000..213ed14
--- /dev/null
+++ b/artifacts/verify-discoverability/check-seo.txt
@@ -0,0 +1,33 @@
+PASS GET / returns 200 status 200
+PASS GET /search returns 200 status 200
+PASS GET /library returns 200 status 200
+PASS GET /login returns 200 status 200
+PASS GET /signup returns 200 status 200
+PASS GET /playlist/p1 returns 200 status 200
+PASS every route has a missing on none
+PASS titles are unique per route duplicates: none
+PASS every route has a meta description missing on none
+PASS descriptions are unique per route duplicates: none
+PASS descriptions are at least 50 chars too short on none
+PASS every route has a canonical link missing on none
+PASS canonicals are absolute and match the served base all 6 start with http://localhost:3001
+PASS every route has og:title missing on none
+PASS every route has og:description missing on none
+PASS every route has og:image missing on none
+PASS every route has twitter:card missing on none
+PASS openGraph keeps type, site_name, url, and locale present on all routes
+PASS og:image resolves with 200 /opengraph-image -> 200
+PASS og:image is served as an image content-type image/png
+PASS robots.txt returns 200 status 200
+PASS robots.txt allows crawling wildcard user-agent with allow /
+PASS robots.txt points at a sitemap Sitemap: http://localhost:3001/sitemap.xml
+PASS sitemap.xml returns 200 status 200
+PASS sitemap.xml is xml urlset present
+PASS sitemap lists every indexable route all 3 indexable routes listed
+PASS sitemap excludes noindex routes none of 3 noindex routes listed
+PASS noindex routes emit a noindex robots meta all 3 marked noindex
+PASS robots.txt does not disallow noindex routes no conflict across 1 disallow rule(s)
+PASS playlist page has JSON-LD 1 block(s)
+PASS JSON-LD parses with @context and @type @type MusicPlaylist
+
+31/31 checks passed against http://localhost:3001
diff --git a/artifacts/verify-discoverability/home.html b/artifacts/verify-discoverability/home.html
new file mode 100644
index 0000000..573ad94
--- /dev/null
+++ b/artifacts/verify-discoverability/home.html
@@ -0,0 +1,2 @@
+sunao, a Spotify-like music player
0:00
4:18
Good evening
Songs
\ No newline at end of file
diff --git a/artifacts/verify-discoverability/library.html b/artifacts/verify-discoverability/library.html
new file mode 100644
index 0000000..5cafa06
--- /dev/null
+++ b/artifacts/verify-discoverability/library.html
@@ -0,0 +1,2 @@
+Your Library | sunao
0:00
4:18
Your Library
\ No newline at end of file
diff --git a/artifacts/verify-discoverability/playlist-p1.html b/artifacts/verify-discoverability/playlist-p1.html
new file mode 100644
index 0000000..4992c6d
--- /dev/null
+++ b/artifacts/verify-discoverability/playlist-p1.html
@@ -0,0 +1,2 @@
+Liked Songs | sunao
0:00
4:18
Liked Songs
5 songs
\ No newline at end of file
diff --git a/artifacts/verify-discoverability/robots.txt b/artifacts/verify-discoverability/robots.txt
new file mode 100644
index 0000000..e475633
--- /dev/null
+++ b/artifacts/verify-discoverability/robots.txt
@@ -0,0 +1,5 @@
+User-Agent: *
+Allow: /
+Disallow: /auth/
+
+Sitemap: http://localhost:3001/sitemap.xml
diff --git a/artifacts/verify-discoverability/sitemap.xml b/artifacts/verify-discoverability/sitemap.xml
new file mode 100644
index 0000000..4943a2e
--- /dev/null
+++ b/artifacts/verify-discoverability/sitemap.xml
@@ -0,0 +1,23 @@
+
+
+
+http://localhost:3001/
+1
+
+
+http://localhost:3001/search
+0.6
+
+
+http://localhost:3001/playlist/p1
+0.7
+
+
+http://localhost:3001/playlist/p2
+0.7
+
+
+http://localhost:3001/playlist/p3
+0.7
+
+
diff --git a/artifacts/verify-home/home-desktop.png b/artifacts/verify-home/home-desktop.png
new file mode 100644
index 0000000..c0f0a3f
Binary files /dev/null and b/artifacts/verify-home/home-desktop.png differ
diff --git a/artifacts/verify-library-playlists/created-playlist-detail.png b/artifacts/verify-library-playlists/created-playlist-detail.png
new file mode 100644
index 0000000..d665a67
Binary files /dev/null and b/artifacts/verify-library-playlists/created-playlist-detail.png differ
diff --git a/artifacts/verify-library-playlists/empty-playlist.png b/artifacts/verify-library-playlists/empty-playlist.png
new file mode 100644
index 0000000..f70d9de
Binary files /dev/null and b/artifacts/verify-library-playlists/empty-playlist.png differ
diff --git a/artifacts/verify-library-playlists/library-created.png b/artifacts/verify-library-playlists/library-created.png
new file mode 100644
index 0000000..8ac3e74
Binary files /dev/null and b/artifacts/verify-library-playlists/library-created.png differ
diff --git a/artifacts/verify-library-playlists/playlist-detail.png b/artifacts/verify-library-playlists/playlist-detail.png
new file mode 100644
index 0000000..b3e3db4
Binary files /dev/null and b/artifacts/verify-library-playlists/playlist-detail.png differ
diff --git a/artifacts/verify-phase4/home.png b/artifacts/verify-phase4/home.png
index c3f3751..e1c1158 100644
Binary files a/artifacts/verify-phase4/home.png and b/artifacts/verify-phase4/home.png differ
diff --git a/artifacts/verify-phase4/login.png b/artifacts/verify-phase4/login.png
index eba5d6d..fc4d0f7 100644
Binary files a/artifacts/verify-phase4/login.png and b/artifacts/verify-phase4/login.png differ
diff --git a/artifacts/verify-player-bar/liked.png b/artifacts/verify-player-bar/liked.png
new file mode 100644
index 0000000..036e6a3
Binary files /dev/null and b/artifacts/verify-player-bar/liked.png differ
diff --git a/artifacts/verify-player-bar/player-bar.png b/artifacts/verify-player-bar/player-bar.png
new file mode 100644
index 0000000..9b5ded5
Binary files /dev/null and b/artifacts/verify-player-bar/player-bar.png differ
diff --git a/artifacts/verify-queue-and-mobile/mobile-home.png b/artifacts/verify-queue-and-mobile/mobile-home.png
new file mode 100644
index 0000000..8ccd459
Binary files /dev/null and b/artifacts/verify-queue-and-mobile/mobile-home.png differ
diff --git a/artifacts/verify-queue-and-mobile/mobile-queue-sheet.png b/artifacts/verify-queue-and-mobile/mobile-queue-sheet.png
new file mode 100644
index 0000000..6f49d5d
Binary files /dev/null and b/artifacts/verify-queue-and-mobile/mobile-queue-sheet.png differ
diff --git a/artifacts/verify-queue-and-mobile/queue-collapsed.png b/artifacts/verify-queue-and-mobile/queue-collapsed.png
new file mode 100644
index 0000000..b2dcb3a
Binary files /dev/null and b/artifacts/verify-queue-and-mobile/queue-collapsed.png differ
diff --git a/artifacts/verify-queue-and-mobile/queue-open.png b/artifacts/verify-queue-and-mobile/queue-open.png
new file mode 100644
index 0000000..35511de
Binary files /dev/null and b/artifacts/verify-queue-and-mobile/queue-open.png differ
diff --git a/artifacts/verify-run/verify-results.json b/artifacts/verify-run/verify-results.json
new file mode 100644
index 0000000..1938d69
--- /dev/null
+++ b/artifacts/verify-run/verify-results.json
@@ -0,0 +1,426 @@
+{
+ "summary": {
+ "base": "http://localhost:3001",
+ "total": 68,
+ "passed": 67,
+ "failed": 1,
+ "failures": [
+ {
+ "feature": "library-playlists",
+ "check": "created-playlist-detail",
+ "ok": false,
+ "detail": "PRODUCT GAP: http://localhost:3001/playlist/f8e66ee2-ee3e-4727-8006-eef20a46ef7f renders 404 without Supabase"
+ }
+ ]
+ },
+ "results": [
+ {
+ "feature": "home",
+ "check": "greeting",
+ "ok": true,
+ "detail": "Good evening"
+ },
+ {
+ "feature": "home",
+ "check": "sidebar-home-current",
+ "ok": true,
+ "detail": ""
+ },
+ {
+ "feature": "home",
+ "check": "sidebar-search-link",
+ "ok": true,
+ "detail": ""
+ },
+ {
+ "feature": "home",
+ "check": "album-grid",
+ "ok": true,
+ "detail": ""
+ },
+ {
+ "feature": "home",
+ "check": "songs-heading",
+ "ok": true,
+ "detail": ""
+ },
+ {
+ "feature": "home",
+ "check": "song-row",
+ "ok": true,
+ "detail": ""
+ },
+ {
+ "feature": "home",
+ "check": "player-bar-preloaded",
+ "ok": true,
+ "detail": "Come Together The Beatles 0:00 4:18 1×"
+ },
+ {
+ "feature": "home",
+ "check": "album-click-plays",
+ "ok": true,
+ "detail": "Dreams Fleetwood Mac 0:00 4:14 1×"
+ },
+ {
+ "feature": "data-layer",
+ "check": "album:Abbey Road",
+ "ok": true,
+ "detail": ""
+ },
+ {
+ "feature": "data-layer",
+ "check": "album:Rumours",
+ "ok": true,
+ "detail": ""
+ },
+ {
+ "feature": "data-layer",
+ "check": "album:Thriller",
+ "ok": true,
+ "detail": ""
+ },
+ {
+ "feature": "data-layer",
+ "check": "album:Back In Black",
+ "ok": true,
+ "detail": ""
+ },
+ {
+ "feature": "data-layer",
+ "check": "artwork-images-present",
+ "ok": true,
+ "detail": "30 mzstatic images"
+ },
+ {
+ "feature": "data-layer",
+ "check": "library-playlists",
+ "ok": true,
+ "detail": ""
+ },
+ {
+ "feature": "data-layer",
+ "check": "playlist-detail",
+ "ok": true,
+ "detail": ""
+ },
+ {
+ "feature": "data-layer",
+ "check": "playlist-rows",
+ "ok": true,
+ "detail": "5 candidate rows"
+ },
+ {
+ "feature": "search",
+ "check": "heading",
+ "ok": true,
+ "detail": ""
+ },
+ {
+ "feature": "search",
+ "check": "input",
+ "ok": true,
+ "detail": ""
+ },
+ {
+ "feature": "search",
+ "check": "all-songs-default",
+ "ok": true,
+ "detail": ""
+ },
+ {
+ "feature": "search",
+ "check": "title-filter",
+ "ok": true,
+ "detail": ""
+ },
+ {
+ "feature": "search",
+ "check": "artist-filter",
+ "ok": true,
+ "detail": "4 Beatles results"
+ },
+ {
+ "feature": "search",
+ "check": "empty-state",
+ "ok": true,
+ "detail": ""
+ },
+ {
+ "feature": "search",
+ "check": "restore-full-list",
+ "ok": true,
+ "detail": ""
+ },
+ {
+ "feature": "library-playlists",
+ "check": "heading",
+ "ok": true,
+ "detail": ""
+ },
+ {
+ "feature": "library-playlists",
+ "check": "seed:Liked Songs",
+ "ok": true,
+ "detail": ""
+ },
+ {
+ "feature": "library-playlists",
+ "check": "seed:Late Night Drives",
+ "ok": true,
+ "detail": ""
+ },
+ {
+ "feature": "library-playlists",
+ "check": "seed:Workout",
+ "ok": true,
+ "detail": ""
+ },
+ {
+ "feature": "library-playlists",
+ "check": "create-form",
+ "ok": true,
+ "detail": ""
+ },
+ {
+ "feature": "library-playlists",
+ "check": "create-result",
+ "ok": true,
+ "detail": ""
+ },
+ {
+ "feature": "library-playlists",
+ "check": "created-playlist-detail",
+ "ok": false,
+ "detail": "PRODUCT GAP: http://localhost:3001/playlist/f8e66ee2-ee3e-4727-8006-eef20a46ef7f renders 404 without Supabase"
+ },
+ {
+ "feature": "library-playlists",
+ "check": "seeded-detail",
+ "ok": true,
+ "detail": ""
+ },
+ {
+ "feature": "library-playlists",
+ "check": "play-all",
+ "ok": true,
+ "detail": ""
+ },
+ {
+ "feature": "library-playlists",
+ "check": "play-all-starts",
+ "ok": true,
+ "detail": ""
+ },
+ {
+ "feature": "library-playlists",
+ "check": "remove-song",
+ "ok": true,
+ "detail": "5 songs -> 4 songs"
+ },
+ {
+ "feature": "library-playlists",
+ "check": "empty-state-after-removing-all",
+ "ok": true,
+ "detail": ""
+ },
+ {
+ "feature": "auth",
+ "check": "login-heading",
+ "ok": true,
+ "detail": ""
+ },
+ {
+ "feature": "auth",
+ "check": "login-email",
+ "ok": true,
+ "detail": ""
+ },
+ {
+ "feature": "auth",
+ "check": "login-password",
+ "ok": true,
+ "detail": ""
+ },
+ {
+ "feature": "auth",
+ "check": "login-submit",
+ "ok": true,
+ "detail": ""
+ },
+ {
+ "feature": "auth",
+ "check": "signup-heading",
+ "ok": true,
+ "detail": ""
+ },
+ {
+ "feature": "auth",
+ "check": "signup-cross-link",
+ "ok": true,
+ "detail": ""
+ },
+ {
+ "feature": "auth",
+ "check": "no-env-library-not-gated",
+ "ok": true,
+ "detail": "http://localhost:3001/library"
+ },
+ {
+ "feature": "auth",
+ "check": "signed-out-indicator",
+ "ok": true,
+ "detail": ""
+ },
+ {
+ "feature": "auth",
+ "check": "no-env-sign-in-does-not-navigate",
+ "ok": true,
+ "detail": "http://localhost:3001/login"
+ },
+ {
+ "feature": "queue-and-mobile",
+ "check": "panel-visible",
+ "ok": true,
+ "detail": ""
+ },
+ {
+ "feature": "queue-and-mobile",
+ "check": "rows-present",
+ "ok": true,
+ "detail": ""
+ },
+ {
+ "feature": "queue-and-mobile",
+ "check": "now-playing-heading",
+ "ok": true,
+ "detail": ""
+ },
+ {
+ "feature": "queue-and-mobile",
+ "check": "open-width",
+ "ok": true,
+ "detail": "288px"
+ },
+ {
+ "feature": "queue-and-mobile",
+ "check": "collapse-width",
+ "ok": true,
+ "detail": "40px"
+ },
+ {
+ "feature": "queue-and-mobile",
+ "check": "collapsed-inert",
+ "ok": true,
+ "detail": ""
+ },
+ {
+ "feature": "queue-and-mobile",
+ "check": "show-queue-label",
+ "ok": true,
+ "detail": ""
+ },
+ {
+ "feature": "queue-and-mobile",
+ "check": "expand-inert-cleared",
+ "ok": true,
+ "detail": ""
+ },
+ {
+ "feature": "queue-and-mobile",
+ "check": "row-click-plays",
+ "ok": true,
+ "detail": ""
+ },
+ {
+ "feature": "queue-and-mobile",
+ "check": "now-playing-card-plays",
+ "ok": true,
+ "detail": ""
+ },
+ {
+ "feature": "queue-and-mobile",
+ "check": "mobile-nav-visible",
+ "ok": true,
+ "detail": ""
+ },
+ {
+ "feature": "queue-and-mobile",
+ "check": "desktop-panel-hidden",
+ "ok": true,
+ "detail": ""
+ },
+ {
+ "feature": "queue-and-mobile",
+ "check": "nav-items",
+ "ok": true,
+ "detail": ""
+ },
+ {
+ "feature": "queue-and-mobile",
+ "check": "sheet-open",
+ "ok": true,
+ "detail": ""
+ },
+ {
+ "feature": "queue-and-mobile",
+ "check": "sheet-closed",
+ "ok": true,
+ "detail": ""
+ },
+ {
+ "feature": "player-bar",
+ "check": "starts-paused",
+ "ok": true,
+ "detail": ""
+ },
+ {
+ "feature": "player-bar",
+ "check": "now-playing-text",
+ "ok": true,
+ "detail": "Come Together The Beatles 0:00 0:30 1×"
+ },
+ {
+ "feature": "player-bar",
+ "check": "pause-state",
+ "ok": true,
+ "detail": ""
+ },
+ {
+ "feature": "player-bar",
+ "check": "prev-next-enabled",
+ "ok": true,
+ "detail": ""
+ },
+ {
+ "feature": "player-bar",
+ "check": "seek-moves",
+ "ok": true,
+ "detail": "0.434733 -> 0.662016"
+ },
+ {
+ "feature": "player-bar",
+ "check": "speed-changes",
+ "ok": true,
+ "detail": ""
+ },
+ {
+ "feature": "player-bar",
+ "check": "like-toggles",
+ "ok": true,
+ "detail": "initial=Unlike"
+ },
+ {
+ "feature": "player-bar",
+ "check": "next-track",
+ "ok": true,
+ "detail": "Something The Beatles 0:00 3:02 1.5×"
+ },
+ {
+ "feature": "player-bar",
+ "check": "space-toggles",
+ "ok": true,
+ "detail": ""
+ }
+ ]
+}
diff --git a/artifacts/verify-search/empty-state.png b/artifacts/verify-search/empty-state.png
new file mode 100644
index 0000000..a0de041
Binary files /dev/null and b/artifacts/verify-search/empty-state.png differ
diff --git a/artifacts/verify-search/filtered-artist.png b/artifacts/verify-search/filtered-artist.png
new file mode 100644
index 0000000..43c06ca
Binary files /dev/null and b/artifacts/verify-search/filtered-artist.png differ
diff --git a/artifacts/verify-search/filtered-title.png b/artifacts/verify-search/filtered-title.png
new file mode 100644
index 0000000..50b0b06
Binary files /dev/null and b/artifacts/verify-search/filtered-title.png differ
diff --git a/biome.json b/biome.json
index 530b1d8..4e241aa 100644
--- a/biome.json
+++ b/biome.json
@@ -3,7 +3,15 @@
"vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true },
"files": {
"ignoreUnknown": true,
- "includes": ["**", "!node_modules", "!.next", "!dist", "!coverage", "!docs/prototype.html"]
+ "includes": [
+ "**",
+ "!node_modules",
+ "!.next",
+ "!dist",
+ "!coverage",
+ "!docs/prototype.html",
+ "!artifacts"
+ ]
},
"formatter": {
"enabled": true,
@@ -34,6 +42,10 @@
},
"css": { "parser": { "tailwindDirectives": true }, "formatter": { "enabled": true } },
"overrides": [
+ {
+ "includes": ["src/app/about/about-client.tsx"],
+ "linter": { "rules": { "security": { "noDangerouslySetInnerHtml": "off" } } }
+ },
{
"includes": ["scripts/check-comments.mjs"],
"linter": { "rules": { "suspicious": { "noConsole": "off" } } }
diff --git a/docs/README.md b/docs/README.md
index 0e12e15..8604e7b 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -2,10 +2,23 @@
Start here. Each doc is short and decision-focused.
+## Concepts (read these first)
+
+Each one teaches a pattern this app shipped in real code, not a generic definition.
+
+- [concepts/state-ownership.md](concepts/state-ownership.md) — who owns which state (query, store, or component) and why likes had to leave the player store.
+- [concepts/persistence.md](concepts/persistence.md) — the repository seam: one port, two adapters, versioned storage, optimistic updates.
+- [concepts/server-and-client.md](concepts/server-and-client.md) — where `"use client"` lives, hydration, and why metadata pages split as thin server pages.
+- [concepts/audio-engine.md](concepts/audio-engine.md) — intent vs reality for playback, real duration vs metadata, latency-trimmed feedback.
+- [concepts/seo-and-ai-discovery.md](concepts/seo-and-ai-discovery.md) — robots, sitemap, JSON-LD, llms.txt, and why Lighthouse is not the real metric.
+
+## Reference
+
- [stack.md](stack.md) — what we use and why, per tool.
- [architecture.md](architecture.md) — app structure and data flow.
- [design-system.md](design-system.md) — tokens, components, motion rules.
- [workflows.md](workflows.md) — dev commands, tests, PR flow.
- [roadmap.md](roadmap.md) — multi-phase plan (tasks + subtasks).
+- [roadmap-post-mvp.md](roadmap-post-mvp.md) — the plan after the MVP: persistence, likes, discovery.
Prototype (throwaway UI reference): `docs/prototype.html` (moved from root).
diff --git a/docs/architecture.md b/docs/architecture.md
index e74c496..5647c40 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -1,7 +1,7 @@
# Architecture
## App shell
-The shell (sidebar, queue, player bar, mobile nav) lives in the route-group layout `src/app/(app)/layout.tsx`, so it mounts once and persists across navigation — only the page's `` swaps. `/login` and `/signup` sit outside the group with their own centered layout. Route URLs are unchanged by the group.
+The shell (sidebar, queue, player bar, mobile nav) lives in the route-group layout `src/app/(app)/layout.tsx`, so it mounts once and persists across navigation — only the page's `` swaps. `/login` and `/signup` sit outside the group with their own centered layout; `/about` is a thin server page (route metadata) around `about-client.tsx`. Route URLs are unchanged by the group.
```
┌─────────────────────────────────────────────┐
│ Sidebar │ Main content │ Queue │
@@ -19,43 +19,61 @@ Below `lg` the sidebar and queue are `hidden`; the mobile nav replaces the sideb
Above `lg` the queue panel is collapsible. A button in a header row at the top of the panel toggles it, and the column narrows to a 40px rail instead of unmounting, so the button stays reachable and the virtualizer keeps its measured scroll height. The state is local to `QueuePanel`; the collapsed content is `inert`, which drops it from the tab order and the accessibility tree while preserving scroll position.
-`QueryClientProvider` (`components/providers.tsx`), the audio engine (`components/player-provider.tsx`), the Space-bar shortcut (`components/keyboard-shortcuts.tsx`), and the `sonner` `Toaster` are all mounted from the root layout, above the route groups.
+`QueryClientProvider` (`components/providers.tsx`), the audio engine (`components/player-provider.tsx`), the Space-bar shortcut (`components/keyboard-shortcuts.tsx`), and the `sonner` `Toaster` are all mounted from the root layout, above the route groups. The root layout also exports the site metadata and issues `prefetchDNS`/`preconnect` hints for the iTunes API and Supabase hosts.
+
+## Ownership of state
+Three owners, no overlaps — see [concepts/state-ownership.md](concepts/state-ownership.md) for the reasoning:
+
+- **Zustand (playback only)**: `stores/player.ts` holds `currentSongId`, `isPlaying`, `isBuffering`, `position`, `duration`, `speed`, `volume`, `isMuted`, `isShuffle`, `repeatMode`, `isNowPlayingOpen`, `queue`, and arbitrary catalog track registration (`customSongs`). Player preferences (`volume`, `isMuted`, `isShuffle`, `repeatMode`) persist in `localStorage` under `sunao.player.v1`. It also renders the idle state: transport controls are inert and Space is a no-op until a track is chosen (`currentSongId !== null`).
+- **TanStack Query (catalogue + library)**: songs, albums, playlists, and likes. The library calls go through the repository seam ([concepts/persistence.md](concepts/persistence.md)).
+- **Component state**: search query and category tabs, sheet open, queue collapsed, lyrics panel mode, form open.
## Playback timing
-The seek bar's `max` and the total-time label come from the **audio element's real duration** (`duration` in the store, set from `loadedmetadata`/`durationchange`), not from the song metadata. Song metadata durations describe the full track; the app plays 30-second iTunes previews, so the two disagree and seeking against the metadata length would jump past the end of the audio.
+The seek bar's `max` and the total-time label come from the **audio element's real duration** (`duration` in the store, set from `loadedmetadata`/`durationchange`), not from the song metadata. Song metadata durations describe the full track; the app plays 30-second iTunes previews, so the two disagree and seeking against the metadata length would jump past the end of the audio. The slider clamps to `max || 1` so a 0/N duration cannot wedge it.
+
+While a track is loading the play button swaps to a spinner. That flag is debounced 200ms (`use-delayed-flag.ts`) so a preview that loads instantly never flashes a spinner.
+
+The mobile seek bar drags a local value and commits to the store on release (`onValueCommitted`), so scrubbing never fights the audio element.
While a track is loading the play button swaps to a spinner. That flag is debounced 200ms (`use-delayed-flag.ts`) so a preview that loads instantly never flashes a spinner.
## Folders
-- `src/app/` — root layout, `(app)/` route group (shell layout, home, search, library, playlist), `auth/callback`, `login`, `signup`, `globals.css`, and the metadata routes (`robots.ts`, `sitemap.ts`, `opengraph-image.tsx`, `twitter-image.tsx`).
-- `src/components/ui/` — shadcn primitives on Base UI.
+- `src/app/` — root layout, `(app)/` route group (shell layout, home, search, library, playlist, artist, album), `about/`, `auth/callback`, `login`, `signup`, `globals.css`, `robots.ts`, `sitemap.ts`.
+- `src/components/ui/` — shadcn primitives on Base UI (button, dialog, sheet, slider, input, tooltip, kbd, skeleton, empty-state).
- `src/components/layout/` — app shell pieces (sidebar, player bar, queue panel, mobile nav).
+- `src/components/player/` — now-playing panel overlay, mobile bottom-sheet player, lyrics panel.
- `src/components/features/` — feature components (`PlaylistForm`).
- `src/components/` — cross-cutting providers (`providers`, `player-provider`, `keyboard-shortcuts`).
- `src/hooks/` — reusable hooks (`use-audio`, `use-delayed-flag`).
- `src/stores/` — Zustand stores (player).
-- `src/lib/` — utils, query client, Supabase clients, schemas, queries, the iTunes client, the local-playlist store, site metadata (`site.ts`), and the bundled dataset.
+- `src/lib/library/` — the repository seam: `repo.ts` (port), `local-repo.ts` (localStorage), `supabase-repo.ts` (Supabase).
+- `src/lib/` — utils, query client, Supabase clients + row helpers, schemas, queries, the iTunes client, `recent-searches.ts`, `recently-played.ts`, `greeting.ts`, `site.ts`, and the bundled dataset.
- `src/types/` — shared types (re-exported from the Zod schemas).
- `docs/` — this wiki.
## Data flow
-- Player state lives in Zustand (`stores/player.ts`): current song id, play/pause, buffering, position, duration, speed, queue (ids), and likes. The store starts empty and knows nothing about the catalogue.
+- Player state lives in Zustand (`stores/player.ts`) and starts empty.
- `PlayerProvider` seeds the queue from `useSongs()` the first time it is empty, so the queue works with either data source.
-- Server state (songs, albums, playlists) goes through TanStack Query + Supabase. Components resolve the current and queued songs by id through the same query hooks.
-- A `useAudio` hook owns the `HTMLAudioElement` and syncs it with the store (play/pause/seek/rate), fetching each preview from the iTunes Search API.
+- Server state (songs, albums) goes through TanStack Query + Supabase, or the bundled dataset when `hasSupabase()` is false.
+- Library state (playlists, likes) goes through query hooks that call the repo: `createPlaylist`, `setPlaylistSongs`, `setLiked`. Likes and playlist edits are optimistic (paint now, roll back on error, invalidate after).
+- A `useAudio` hook owns the `HTMLAudioElement` and syncs it with the store (play/pause/seek/rate). It resolves songs through `useSongs()` and fetches previews from the iTunes Search API.
- UI state (queue collapsed, mobile sheet open, active view) lives in component state.
-## Metadata and SEO
-Each route is a server component that exports `metadata` and renders a client component (`HomeClient`, `SearchClient`, `LibraryClient`, `PlaylistClient`, `LoginClient`, `SignupClient`) which owns the interactive state, so metadata resolves on the server. Playlist pages build their own metadata and emit `MusicRecording` JSON-LD. `src/lib/site.ts` holds the canonical `SITE_URL`, name, and default description, so `robots.ts`, `sitemap.ts`, and the share images agree on one origin. `sitemap.ts` is built from the playlist catalogue; library, login, and signup are `noindex`, and `robots.txt` disallows `/auth/`.
-
## Without a backend
-`hasSupabase()` gates every query. When `NEXT_PUBLIC_SUPABASE_URL` is unset, queries resolve from the bundled dataset in `lib/data/`, and created playlists go to an in-memory store in `lib/local-playlists.ts`. Each query seeds `initialData` from that dataset so the server and the first client render agree — without it, a query-backed list renders empty on the server and populated on the client, which is a hydration mismatch.
+`hasSupabase()` gates everything. When `NEXT_PUBLIC_SUPABASE_URL` is unset:
+
+- Catalogue queries resolve from the bundled dataset in `lib/data/`, each seeded as `initialData` so the server and the first client render agree — without it, a query-backed list renders empty on the server and populated on the client, which is a hydration mismatch.
+- The library (playlists + likes) goes to one versioned Zod-validated localStorage document (`sunao.library.v1`), corrupt/absent values falling back to the bundled seed. Changes survive a reload and sync across tabs (`storage` event) — the store-then-reload bugs are gone.
+- The system "Liked Songs" playlist still shows its seeded list; making it a live projection of likes is phase 9 in `roadmap-post-mvp.md`, deliberately not done yet in this tree.
+
+With Supabase configured, the same calls hit `playlists`, `playlist_songs`, and the `liked_songs` table (migration `0003_liked_songs.sql`) under RLS. The seed (`supabase/seed.sql`) is aligned with `src/lib/data/songs.ts` so both modes render the same catalogue, including artwork (see `supabase/migrations/0002_artwork.sql`).
-The Supabase seed (`supabase/seed.sql`) is aligned with `src/lib/data/songs.ts` so both modes render the same catalogue, including artwork (see `supabase/migrations/0002_artwork.sql`).
+## Discoverability surface
+The app is crawlable without JavaScript: root metadata + per-route metadata (`about`), `robots.ts` (search crawlers allowed, AI-search crawlers explicitly allowed, model-training crawlers denied), `sitemap.ts`, JSON-LD (FAQ + WebApplication) on `/about`, `public/llms.txt` and `public/llms-full.txt` for answer engines. What it all means and how to measure it for real: [concepts/seo-and-ai-discovery.md](concepts/seo-and-ai-discovery.md).
-Liked state is session-only (an in-memory Zustand `Set`); it is not persisted to Supabase and does not sync to the `Liked Songs` playlist. See the roadmap descoped items.
+Search and long song lists are cheap on purpose: the search page filters paintings through `useDeferredValue` so typing stays responsive, and `.song-row` uses `content-visibility: auto` so off-screen rows skip layout and paint.
## Boundaries
- Auth is the only server round trip the client does not own end to end: `src/proxy.ts` refreshes the session cookie and redirects unauthenticated visits to `/library` and `/playlist/*` to `/login`.
- Supabase is the only backend. The client talks to Supabase directly under RLS; there is no custom API server for the MVP.
-- Validation of Supabase responses happens with Zod at the query layer.
+- Validation of Supabase rows happens with Zod at the query layer; localStorage documents are validated the same way at every read.
diff --git a/docs/concepts/audio-engine.md b/docs/concepts/audio-engine.md
new file mode 100644
index 0000000..b6c0389
--- /dev/null
+++ b/docs/concepts/audio-engine.md
@@ -0,0 +1,60 @@
+# Concept: the audio engine
+
+One `