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 |---|---| | ![Home](artifacts/readme/home.png) | ![Search](artifacts/readme/search.png) | +| Expanded Now Playing | Synchronized Lyrics | +|---|---| +| ![Now Playing](artifacts/readme/now-playing.png) | ![Lyrics](artifacts/readme/lyrics.png) | + +| Artist Detail | Album Detail | +|---|---| +| ![Artist Detail](artifacts/readme/artist.png) | ![Album Detail](artifacts/readme/album.png) | + | Library | Playlist | |---|---| | ![Library](artifacts/readme/library.png) | ![Playlist](artifacts/readme/playlist.png) | -| Now playing | Collapsed queue | +| Player bar & controls | Collapsed queue | |---|---| -| ![Now playing](artifacts/readme/playing.png) | ![Collapsed queue](artifacts/readme/queue-collapsed.png) | +| ![Player controls](artifacts/readme/playing.png) | ![Collapsed queue](artifacts/readme/queue-collapsed.png) | -| Sign in | Sign up | +| Mobile home | Mobile now playing | |---|---| -| ![Sign in](artifacts/readme/login.png) | ![Sign up](artifacts/readme/signup.png) | +| ![Mobile home](artifacts/readme/mobile-home.png) | ![Mobile now playing](artifacts/readme/mobile-now-playing.png) | -| Mobile home | Mobile queue | +| Mobile queue | Sign in | |---|---| -| ![Mobile home](artifacts/readme/mobile-home.png) | ![Mobile queue](artifacts/readme/mobile-queue.png) | +| ![Mobile queue](artifacts/readme/mobile-queue.png) | ![Sign in](artifacts/readme/login.png) | 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 @@ +<!DOCTYPE html><html lang="en" class="dark"><head><meta charSet="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><link rel="stylesheet" href="/_next/static/chunks/%5Broot-of-the-server%5D__0csfcp1hfsicy._.css" data-precedence="next_static/chunks/[root-of-the-server]__0csfcp1hfsicy._.css"/><link rel="preload" as="script" fetchPriority="low" href="/_next/static/chunks/%5Bturbopack%5D_browser_dev_hmr-client_hmr-client_ts_1j6obsv7xki2n._.js"/><script src="/_next/static/chunks/1bx2_next_dist_compiled_react-dom_cjs_react-dom-client_development_19qtuybz934_o.js" async=""></script><script src="/_next/static/chunks/1bx2_next_dist_compiled_react-dom_cjs_react-dom_development_1v61sbi731u4-.js" async=""></script><script src="/_next/static/chunks/1bx2_next_dist_compiled_react-dom_1x0wp0u62zl-o._.js" async=""></script><script src="/_next/static/chunks/1bx2_next_dist_compiled_react-server-dom-turbopack_1p5tf2x7gbepz._.js" async=""></script><script src="/_next/static/chunks/1bx2_next_dist_compiled_next-devtools_index_07fymjxxtatqx.js" async=""></script><script src="/_next/static/chunks/1bx2_next_dist_compiled_0_5tf_0yc2abk._.js" async=""></script><script src="/_next/static/chunks/1bx2_next_dist_client_03i-g41xzlcrr._.js" async=""></script><script src="/_next/static/chunks/1bx2_next_dist_1m705v5yxwv4h._.js" async=""></script><script src="/_next/static/chunks/0zix_%40swc_helpers_cjs_0hi-e474n4mns._.js" async=""></script><script src="/_next/static/chunks/_1anvha4rlo79g._.js" async=""></script><script src="/_next/static/chunks/turbopack-_0gdz9exes1h8y._.js" async=""></script><script src="/_next/static/chunks/_19zmx_hpl6_ht._.js" async=""></script><script src="/_next/static/chunks/1bx2_next_dist_06b8vodzb4-9e._.js" async=""></script><script src="/_next/static/chunks/1jrd_%40tanstack_query-core_build_modern_20sjwti7kc3ty._.js" async=""></script><script src="/_next/static/chunks/1m_t_%40base-ui_react_1xa83gofn-pj8._.js" async=""></script><script src="/_next/static/chunks/node_modules__pnpm_10nf1er5wz-lj._.js" async=""></script><script src="/_next/static/chunks/src_0ttj1ib8crq_y._.js" async=""></script><script src="/_next/static/chunks/src_1n7xlbm2h26jo._.js" async=""></script><script src="/_next/static/chunks/1bx2_next_dist_0uf6a9t310n7o._.js" async=""></script><script src="/_next/static/chunks/1m_t_%40base-ui_react_0qk__65zh4jzn._.js" async=""></script><script src="/_next/static/chunks/1h6z_zod_v4_classic_05egob7ul94te._.js" async=""></script><script src="/_next/static/chunks/1h6z_zod_v4_core_154wm9-r_42lt._.js" async=""></script><script src="/_next/static/chunks/1h6z_zod_v4_locales_1x9v8rdd-quw8._.js" async=""></script><script src="/_next/static/chunks/01-k_%40supabase_auth-js_dist_module_0l-g1lu4dc6w0._.js" async=""></script><script src="/_next/static/chunks/node_modules__pnpm_0s-83hm76gnfj._.js" async=""></script><script src="/_next/static/chunks/src_app_(app)_home-client_tsx_0vmbrnzkgr3b2._.js" async=""></script><title>sunao, a Spotify-like music player
\ 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
\ 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
\ 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 `