diff --git a/.opencode/skills/verify-sunao/SKILL.md b/.opencode/skills/verify-sunao/SKILL.md index d06c9a8..77ec639 100644 --- a/.opencode/skills/verify-sunao/SKILL.md +++ b/.opencode/skills/verify-sunao/SKILL.md @@ -6,22 +6,24 @@ description: Verify the sunao web app by launching it locally, driving the UI wi # Verify sunao ## Launch -- Command: `pnpm dev` (Next.js dev server, Turbopack). -- Ready when: `http://localhost:3000` responds (log line "Ready in"). +- Command: `pnpm dev` (Next.js dev server, Turbopack). Default base: `http://localhost:3000`. +- If port 3000 is held by a process you did not start, launch yours explicitly instead: `pnpm exec next dev --turbopack --port 3001`, and use that base in every recipe. +- Ready when: the base URL responds with HTML containing "sunao". +- For metadata/SEO checks, set `NEXT_PUBLIC_SITE_URL` to the base you serve on. - Teardown: kill the process you started (Ctrl-C / kill the PID). Never kill by process name. ## Doctor - `curl -s http://localhost:3000 | head` returns HTML containing "sunao". -- Port 3000 owned by the dev server you started. -- Build revision matches `git rev-parse HEAD`. +- The port is owned by the dev server you started: `readlink /proc/$(ss -ltnp | grep ':3000' | grep -o 'pid=[0-9]*' | head -1 | cut -d= -f2)/cwd` points at your working tree. +- Build revision matches `git rev-parse HEAD` of the tree you launched. ## Drive -- Harness: Playwright (config in `playwright.config.ts`). -- Prefer ARIA roles/accessible names over CSS selectors. -- Base URL: `http://localhost:3000`. +- Harness: Playwright (config in `playwright.config.ts`); `@playwright/test` is installed, `playwright` is not a direct dependency. +- Prefer ARIA roles and accessible names over CSS selectors, and scope them (`main`, `[data-slot="player-bar"]`, `#queue-panel-content`): the queue panel and sidebar repeat song and playlist names. +- Base URL: `http://localhost:3000` (or the port from Launch). ## Evidence -- Screenshots and ARIA snapshots go to `artifacts//`. +- Screenshots and ARIA snapshots go to `artifacts/verify-/`; server-only checks save text dumps there. - Capture the action and the resulting state, not just the final screen. - A passing unit test is not live-surface proof. @@ -29,4 +31,4 @@ description: Verify the sunao web app by launching it locally, driving the UI wi - Kill the dev server you started. Remove scratch state only; keep evidence. ## Features -See `features/README.md` for the index and `features/*.md` for recipes. +See `features/README.md` for the index, baseline preconditions, and the known product gaps, and `features/*.md` for the per-feature recipes. diff --git a/.opencode/skills/verify-sunao/features/README.md b/.opencode/skills/verify-sunao/features/README.md index d33ccf7..9d199b4 100644 --- a/.opencode/skills/verify-sunao/features/README.md +++ b/.opencode/skills/verify-sunao/features/README.md @@ -3,21 +3,36 @@ Maintained source for verifying user-facing behavior of sunao. ## Baseline preconditions -- Launch sunao at `http://localhost:3000` via `pnpm dev`. -- Run `pnpm build` first if checking production behavior. +- Launch sunao with `pnpm dev` (Next.js dev server). Default base: `http://localhost:3000`. +- If port 3000 is held by a process you did not start (a concurrent agent, a stale server), start yours explicitly and use that base everywhere: + `pnpm exec next dev --turbopack --port 3001`. Check the owner before driving: + `readlink /proc/$(ss -ltnp | grep ':3000' | grep -o 'pid=[0-9]*' | head -1 | cut -d= -f2)/cwd` +- Run `pnpm build && pnpm start` first if checking production behavior. - Never drive an instance not started by this verification run. +- `discoverability` additionally needs `NEXT_PUBLIC_SITE_URL` set to the served origin (see that recipe). +- No Supabase env vars are required. Without them the app uses the bundled dataset, an in-memory playlist store, and no auth. ## Driving conventions - Start every recipe from the baseline state. -- Prefer ARIA roles and accessible names over CSS selectors. -- Run browser actions through Playwright (`e2e/` specs or `npx playwright`). +- Prefer ARIA roles and accessible names over CSS selectors, and scope them: the queue panel repeats song titles as buttons, and the sidebar repeats playlist names, so unscoped `getByRole("button", { name })` queries match several elements. Scope to `main`, `[data-slot="player-bar"]`, or `#queue-panel-content`. +- Run browser actions through Playwright (`e2e/` specs or a short node script importing `chromium` from `@playwright/test` and running inside the repo, where the module resolves). ## Proof and skip reporting - Capture the user action and resulting state. - UI proof includes a screenshot with the app identity visible. - Record the feature ID with every artifact. +- Evidence goes to `artifacts/verify-/`; keep it, do not commit run notes. + +## Known product gaps (reported 2026-10-17) +- Without Supabase, creating a playlist succeeds and it appears in `/library` and the sidebar, but opening it renders "404: This page could not be found". Cause: `/playlist/[id]` resolves the id server-side from the static `PLAYLISTS` and calls `notFound()`, while the created playlist exists only in the client store. See `library-playlists.md` for how the recipe handles it. +- Submitting `/login` or `/signup` without Supabase env vars shows the user nothing: the client-construction throw goes unhandled and the form's error state never renders. See `auth.md`. ## Features -- [Home renders](./home-renders.md) covers the home page greeting and app shell. -- [Player bar](./player-bar.md) covers playback controls. -- [Data layer](./data-layer.md) covers songs/albums/playlists loading. +- [Home renders](./home-renders.md) — greeting, album grid, song list, app shell. +- [Search](./search.md) — filter songs by title or artist, empty state. +- [Library and playlists](./library-playlists.md) — list and create playlists, playlist detail, play all, remove. +- [Player bar](./player-bar.md) — playback controls, seek, speed, like, buffering. +- [Queue and mobile shell](./queue-and-mobile.md) — queue panel, collapse, bottom nav, queue sheet. +- [Auth](./auth.md) — sign in / sign up, route protection, backend-less behavior. +- [Data layer](./data-layer.md) — songs, albums, playlists, fallback dataset. +- [Discoverability](./discoverability.md) — metadata, robots.txt, sitemap.xml, share images, JSON-LD. diff --git a/.opencode/skills/verify-sunao/features/auth.md b/.opencode/skills/verify-sunao/features/auth.md new file mode 100644 index 0000000..9d200e4 --- /dev/null +++ b/.opencode/skills/verify-sunao/features/auth.md @@ -0,0 +1,33 @@ +# Auth + +Email and password sign in / sign up on two standalone pages, plus route protection for `/library` and `/playlist/*` when Supabase is configured. + +## Sub-features +- `auth-login` — `/login` form: email, password, submit, inline error. +- `auth-signup` — `/signup` form, cross-linked with `/login`. +- `auth-protected` — `src/proxy.ts` redirects unauthenticated visits to `/library` and `/playlist/*` to `/login` (only when Supabase is configured). +- `auth-callback` — `/auth/callback` exchanges a confirmation code for a session. + +## How to get to it (user POV) +- Signed-out visitors reach `/login` and `/signup` from the sidebar `Sign in` link. + +## Driving it with Playwright +Preconditions +- Dev server healthy. Real credentials are NOT available in the default run (no Supabase env vars). + +- **Login page.** `page.goto("/login")`; the `Sign in` heading, an `Email` and a `Password` placeholder input, and the `Sign in` submit button are visible. +- **Signup page.** Click the `Sign up` link; the URL becomes `/signup`, the `Sign up` heading is visible, and the `Sign in` link is visible to go back. +- **Backend-less gating.** `page.goto("/library")` stays on `/library` and the `Your Library` heading renders; the sidebar still shows the `Sign in` link. There is no redirect because the proxy short-circuits without env vars. +- **Backend-less submit.** On `/login`, fill both fields and click `Sign in`; the URL stays `/login` and the form is otherwise unchanged. Do **not** assert an inline error. +- **Proof.** Screenshots to `artifacts/verify-auth/`: login card, signup card, `/library` while signed out. + +## Verified-unreachable +- `auth-callback` and any real session flow need a live Supabase project with `NEXT_PUBLIC_SUPABASE_URL` and `NEXT_PUBLIC_SUPABASE_ANON_KEY` set (plus a confirmed account). With a configured backend, the additions are: signing in navigates to `/` and the sidebar `Sign in` link disappears; signing up routes to `/`; and an unauthenticated visit to `/library` or `/playlist/*` redirects to `/login`. + +## Known product gap (reported 2026-10-17) +- Submitting `/login` or `/signup` without Supabase env vars gives the user no feedback at all: `createClient()` throws (`@supabase/ssr: Your project's URL and API key are required...`), the rejection is unhandled, and the form's error state is never set, so only the console and the dev overlay show anything. The recipe above asserts only that the route stays put. The missing feedback is a product issue; do not paper over it by asserting an error message. + +## Gotchas +- The inputs have placeholders but no labels or `aria-label`; address them by placeholder. +- Without Supabase env vars the proxy is bypassed entirely, so `/library` and `/playlist/*` are publicly reachable — do not assert a redirect in that mode. +- `.text-red-500` is also the `Sign up` link, so counting red elements is not a way to detect an error. diff --git a/.opencode/skills/verify-sunao/features/data-layer.md b/.opencode/skills/verify-sunao/features/data-layer.md index 0577f48..b4f1e79 100644 --- a/.opencode/skills/verify-sunao/features/data-layer.md +++ b/.opencode/skills/verify-sunao/features/data-layer.md @@ -1,22 +1,29 @@ # Data layer -Songs, albums, and playlists load via TanStack Query with Supabase fallback to static data. +Songs, albums, and playlists come from TanStack Query, backed by Supabase when configured and by the bundled dataset otherwise. ## Sub-features -- `data-songs` loads the song list. -- `data-albums` loads albums. -- `data-fallback` uses static data when Supabase env is absent. +- `data-songs` — the song list renders on `/`. +- `data-albums` — the album grid renders on `/`, with remote artwork. +- `data-playlists` — `/library` and `/playlist/` render playlists from the same layer. +- `data-fallback` — with no Supabase env vars, queries resolve from the static dataset and an in-memory playlist store. ## How to get to it (user POV) -- Open `http://localhost:3000/`. Songs and albums render. +- Open `http://localhost:3000/`, `/library`, or `/playlist/p1`. ## Driving it with Playwright -Preconditions: -- sunao dev server healthy at `http://localhost:3000`. +Preconditions +- `NEXT_PUBLIC_SUPABASE_URL` and `NEXT_PUBLIC_SUPABASE_ANON_KEY` must be unset so the fallback path is exercised. No `.env.local` should set them. +- Dev server healthy. -- **Load home.** Navigate to `/`. Run `page.goto("/")`. Songs and albums render. -- **Proof.** Capture screenshot. Run `page.screenshot({ path: "artifacts/verify-phase2/home.png" })`. +- **Home.** `page.goto("/")`; the album grid shows `Abbey Road`, `Rumours`, `Thriller`, `Back In Black`, and the song list shows 12 rows. +- **Artwork.** `page.locator('img[src*="mzstatic"]')` matches several images (remote covers via `next/image`). +- **Playlists.** `page.goto("/library")` lists `Liked Songs`, `Late Night Drives`, `Workout` with no sign-in; `page.goto("/playlist/p1")` shows the `Liked Songs` heading and 5 rows. +- **Proof.** Screenshots to `artifacts/verify-data-layer/`: home grid, library, playlist detail. ## Gotchas -- Without Supabase env vars, static data is used. -- Zod validates all rows; a schema mismatch throws. +- Every read hook seeds `initialData` from the static dataset when Supabase is absent, so lists are present on the first paint; a loading skeleton on these routes is a regression, not expected behavior. +- Zod parses the static dataset too, so a bad row in `src/lib/data/songs.ts` throws without any Supabase env. +- `next.config.ts` allowlists the mzstatic host for `next/image`; removing it breaks every cover. +- Playlists created in fallback mode go to an in-memory store (`src/lib/local-playlists.ts`) and vanish on reload; their detail route 404s (see `library-playlists.md`). +- `src/proxy.ts` short-circuits without Supabase env vars, so `/library` and `/playlist/*` are not auth-gated in this mode. diff --git a/.opencode/skills/verify-sunao/features/discoverability.md b/.opencode/skills/verify-sunao/features/discoverability.md new file mode 100644 index 0000000..bb9f91b --- /dev/null +++ b/.opencode/skills/verify-sunao/features/discoverability.md @@ -0,0 +1,34 @@ +# Discoverability + +Per-route metadata, `robots.txt`, `sitemap.xml`, generated share images, and playlist structured data. This one is verified against the server, not the UI. + +## Sub-features +- `seo-metadata` — every route has a unique ``, description, absolute canonical, and Open Graph / Twitter tags. +- `seo-noindex` — `/library`, `/login`, and `/signup` emit `noindex`; unknown playlist ids are `noindex, nofollow`. +- `seo-robots` — `/robots.txt` allows crawling, disallows `/auth/`, and advertises the sitemap. +- `seo-sitemap` — `/sitemap.xml` lists the indexable routes and excludes the `noindex` ones. +- `seo-share-image` — the `og:image` URL resolves with an image content type (`next/og`, 1200x630). +- `seo-jsonld` — `/playlist/<id>` emits `MusicPlaylist` JSON-LD. + +## How to get to it (user/bot POV) +- A crawler or a link unfurler fetches the HTML head, `/robots.txt`, `/sitemap.xml`, and the share image. + +## Driving it with Playwright (or curl) +Preconditions +- Dev server healthy, started with `NEXT_PUBLIC_SITE_URL` set to the origin you are serving, e.g. `NEXT_PUBLIC_SITE_URL=http://localhost:3001 pnpm exec next dev --turbopack --port 3001`. The default `SITE_URL` is the production Vercel origin, so canonical checks fail against localhost without it. + +- **Run the project's SEO check.** `node scripts/check-seo.mjs [base]` (default `http://localhost:3001`; there is no npm alias). It exits 0 only when every check passes and prints `N/N checks passed against <base>`. +- **The raw requests it makes**, if you need to inspect a failure: + - `curl -s -D - <base>/robots.txt` + - `curl -s <base>/sitemap.xml` + - `curl -s <base>/ | grep -Eo '<title>[^<]*|]*>|]*>'` + - `curl -s /library | grep -io '/ | grep -o 'property="og:image" content="[^"]*"' | sed 's/.*content="//;s/"//'); curl -s -o /dev/null -w '%{http_code} %{content_type}\n' "$IMG"` + - `curl -s /playlist/p1 | grep -o '"@type":"[^"]*"'` +- **Assert.** Each route returns 200 with a unique title/description; canonicals are absolute and start with ``; `og:image` resolves 200 with an image content type; `/robots.txt` has `User-agent: *` and a `Sitemap:` line; `/sitemap.xml` contains `/`, `/search`, and `/playlist/p1` and excludes `/library`, `/login`, `/signup`; the `noindex` routes emit the robots meta; `/playlist/p1` has ≥1 JSON-LD block with `@context` and `@type: MusicPlaylist`. +- **Proof.** Text dumps, not screenshots, in `artifacts/verify-discoverability/`: `check-seo.txt`, `robots.txt`, `sitemap.xml`, `home.html`, `library.html`, `playlist-p1.html`. + +## Gotchas +- Without `NEXT_PUBLIC_SITE_URL`, `metadataBase` points at the Vercel origin, so absolute-canonical assertions fail against a local server while the pages themselves look correct. +- `og:image` is a generated route (`/opengraph-image?...`), so assert its content type after resolving the URL from the head; do not hardcode the hashed path. +- The playlist JSON-LD type is `MusicPlaylist`, not `MusicRecording`. diff --git a/.opencode/skills/verify-sunao/features/home-renders.md b/.opencode/skills/verify-sunao/features/home-renders.md index e1a745f..6d61b4f 100644 --- a/.opencode/skills/verify-sunao/features/home-renders.md +++ b/.opencode/skills/verify-sunao/features/home-renders.md @@ -1,22 +1,28 @@ # Home renders -The home page greets the user and shows the app shell (sidebar, main, queue panel, player bar). +The home route greets the user and shows the album grid, the song list, and the app shell (sidebar, queue panel, player bar; a bottom nav below `lg`). ## Sub-features -- `home-greeting` shows "Good evening". -- `home-shell` renders sidebar, main, queue panel, player bar. +- `home-greeting` — time-of-day greeting heading ("Good morning" / "Good afternoon" / "Good evening"), derived from the browser clock. +- `home-shell` — sidebar, queue panel, and player bar at `>= lg`; bottom nav below `lg`. +- `home-albums` — album grid; clicking a card queues the album and plays its first song. +- `home-songs` — numbered song list; clicking a row queues every song and plays it. ## How to get to it (user POV) -- Open `http://localhost:3000/` in a browser. +- Open `http://localhost:3000/`. ## Driving it with Playwright -Preconditions: -- sunao dev server healthy at `http://localhost:3000`. -- `pnpm build` succeeds (for production check). +Preconditions +- Dev server healthy. Viewport 1280x800 for the desktop shell, ~390x844 for the mobile shell. -- **Load home.** Navigate to `/`. Run `page.goto("/")`. The page shows "Good evening". -- **Proof.** Capture screenshot. Run `page.screenshot({ path: "artifacts/verify-home/home.png" })`. The screenshot shows the greeting and app shell. +- **Load home.** `page.goto("/")` and wait for network idle. `getByRole("heading", { level: 1 })` matches `/Good (morning|afternoon|evening)/`. +- **Shell.** The sidebar link `Home` has `aria-current="page"`; `Search` and `Your Library` links are visible. `[data-slot="queue-panel"]` is visible with a `Now playing` heading. +- **Content.** `getByRole("button", { name: /Abbey Road/ })` is visible; the `Songs` heading is visible; scoped to `main`, `getByRole("button", { name: /Come Together/ })` is visible. +- **Play from a song row.** Click that row; `[data-slot="player-bar"]` shows `Come Together` / `The Beatles` and its toggle becomes `Pause`. +- **Play from an album card.** Click `getByRole("main").getByRole("button", { name: /Rumours/ })`; the bar shows `Dreams` / `Fleetwood Mac` (the album's first track) and the toggle becomes `Pause`. +- **Proof.** `page.screenshot({ path: "artifacts/verify-home/home-desktop.png" })` at the default state. ## Gotchas -- Dev server must be started before driving; never reuse an unknown instance. -- Keep evidence after cleanup. +- The player bar is never empty: the store seeds `currentSongId: 1` and the queue with every song id, so the bar shows `Come Together` paused on first load. +- Assertions on song titles must be scoped to `main`; the queue panel renders the same titles as buttons. +- The greeting is client-clock derived and wrapped in `suppressHydrationWarning`, so assert by pattern, not by a fixed string. diff --git a/.opencode/skills/verify-sunao/features/library-playlists.md b/.opencode/skills/verify-sunao/features/library-playlists.md new file mode 100644 index 0000000..b8c573e --- /dev/null +++ b/.opencode/skills/verify-sunao/features/library-playlists.md @@ -0,0 +1,32 @@ +# Library and playlists + +The library lists playlists and creates new ones; a playlist detail page shows its tracks with play-all, per-row play, and remove. + +## Sub-features +- `library-list` — `/library` lists the playlists with a song count; system playlists are marked `· system`. +- `library-create` — "+ New playlist" reveals an inline name form; creating adds the playlist and fires a success toast. +- `playlist-detail` — cover, name, song count, numbered rows; `Play all`; per-row play; `Remove` on hover. +- `playlist-empty` — "Empty playlist" / "Add songs to get started." when a playlist has no tracks. + +## How to get to it (user POV) +- Open `http://localhost:3000/library`. Click a playlist to open `/playlist/`. + +## Driving it with Playwright +Preconditions +- Dev server healthy. No auth or Supabase needed; playlists come from the fallback dataset and an in-memory store. +- Scope queries to `main`: the sidebar repeats the playlist list and its own "New playlist" trigger. + +- **List.** `page.goto("/library")`. The `Your Library` heading is visible and the seeds `Liked Songs`, `Late Night Drives`, `Workout` are listed with song counts. +- **Create.** In `main`, click `New playlist`; the `Playlist name` textbox appears. Fill `Verify Road Trip` and click `Create`; the toast `Created "Verify Road Trip"` appears and the playlist is listed with `0 songs`. +- **Detail.** Open a seeded playlist (`/playlist/p1`); the heading is `Liked Songs`, `5 songs` is shown, and five rows render. +- **Play all.** Click `Play all`; the player bar shows the first track and its toggle becomes `Pause`. +- **Remove.** Hover a row and click `Remove`; the song count decrements (`5 songs` -> `4 songs`). +- **Empty state.** Remove every row of a playlist (hover each row, click `Remove`); `Empty playlist` and `Add songs to get started.` appear. Removal is client-side state, so a reload restores the playlist. +- **Proof.** Screenshots to `artifacts/verify-library-playlists/`: library with the create form open, a populated detail page, the empty state. + +## Known product gap (reported 2026-10-17) +- Opening a playlist created in this session renders `404: This page could not be found`. `/playlist/[id]` resolves the id server-side from the static `PLAYLISTS` and calls `notFound()`, but a new playlist is written only to the client in-memory store, so the server route cannot see it. Creation, listing, and the toast are all fine; only the detail route is broken. Verified at `310c99e`; keep this out of the verification PR and record it for the product owner. + +## Gotchas +- Created playlists live in memory only: a page reload drops them and `/playlist/` stays 404. +- `Remove` is client state, not a Supabase mutation; it does not survive a reload, and it is not the same as the roadmap's descoped persisted-likes work. diff --git a/.opencode/skills/verify-sunao/features/player-bar.md b/.opencode/skills/verify-sunao/features/player-bar.md index 03fdba8..0218199 100644 --- a/.opencode/skills/verify-sunao/features/player-bar.md +++ b/.opencode/skills/verify-sunao/features/player-bar.md @@ -1,25 +1,35 @@ # Player bar -The bottom player bar shows the current song, play/pause, prev/next, seek, speed, and like. +The persistent bottom bar: current song, play/pause, previous/next, seek, speed, full screen, and like. ## Sub-features -- `player-playpause` toggles playback. -- `player-seek` drags the scrubber. -- `player-speed` changes playback rate. -- `player-like` toggles liked state. +- `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. ## How to get to it (user POV) -- Open `http://localhost:3000/`. The bar is at the bottom. +- Open `http://localhost:3000/`. The bar is pinned to the bottom of every in-app screen. ## Driving it with Playwright -Preconditions: -- sunao dev server healthy at `http://localhost:3000`. +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. -- **Play/pause.** Click the play button. The icon switches to pause. -- **Seek.** Drag the range input. Position updates. -- **Speed.** Hover the speed button, pick a rate. Label updates. -- **Like.** Click the heart. It fills. +- **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. ## Gotchas -- Audio is a placeholder tone; position advances with real playback. -- Seek uses the audio element's currentTime. +- 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"]`. diff --git a/.opencode/skills/verify-sunao/features/queue-and-mobile.md b/.opencode/skills/verify-sunao/features/queue-and-mobile.md new file mode 100644 index 0000000..de0cf81 --- /dev/null +++ b/.opencode/skills/verify-sunao/features/queue-and-mobile.md @@ -0,0 +1,33 @@ +# Queue and mobile shell + +The right-hand queue panel (desktop) and the bottom nav plus queue sheet (mobile), including the collapsible rail. + +## Sub-features +- `queue-panel` — `Now playing` card, a live song count, and a virtualized list of queue rows. +- `queue-jump` — clicking a row (or the now-playing card) plays that song. +- `queue-collapse` — the desktop panel collapses to a 40px rail and back; the content goes `inert`. +- `mobile-nav` — Home / Search / Library links plus a `Queue` button below `lg`. +- `mobile-queue-sheet` — the `Queue` button opens the queue in a bottom sheet. + +## How to get to it (user POV) +- Desktop: the queue panel is the right column at `>= lg` (1024px). +- Mobile: the bottom nav is visible below `lg`; `Queue` opens the sheet. + +## Driving it with Playwright +Preconditions +- Dev server healthy. Desktop viewport 1280x800, mobile 390x844. +- The queue is seeded with all 12 songs on mount, so rows exist before anything plays. + +- **Panel.** `[data-slot="queue-panel"]` and `#queue-panel-content` are visible; `#queue-panel-content button[data-index]` has at least one row; the `Now playing` heading is visible; the open panel is about 288px wide. +- **Jump from a row.** Click `#queue-panel-content button[data-index]` (first); the player bar's toggle becomes `Pause`. +- **Jump from the now-playing card.** The card is the first button inside `#queue-panel-content`; click it and the bar's toggle becomes `Pause`. +- **Collapse.** Click `Hide queue`; the panel narrows to ~40px, `#queue-panel-content` gains `inert` and `opacity: 0`, and the toggle is now `Show queue`. Click `Show queue`; the rail expands and `inert` is cleared. +- **Mobile nav.** At 390x844, `[data-slot="mobile-nav"]` is visible, the desktop panel is hidden, and its `Home` / `Search` / `Library` links are visible. +- **Mobile sheet.** Click the nav's `Queue` button; `[data-slot="sheet-content"]` is visible with the queue inside. Press `Escape`; it closes. +- **Proof.** Screenshots to `artifacts/verify-queue-and-mobile/`: queue open, collapsed rail, mobile sheet, mobile home. + +## Gotchas +- The queue is virtualized: only visible rows are in the DOM, so always use `.first()` and never assert a full row count. +- Scope mobile selectors to `[data-slot="mobile-nav"]`; the sidebar also has a library link (named `Your Library`, versus the nav's `Library`). +- The collapsed panel is `opacity: 0` rather than unmounted, so visibility assertions need the width or `inert` checks, not `toBeHidden()`. +- `e2e/queue-toggle.spec.ts` already covers the desktop collapse contract (rail width, `inert`, `opacity`); reuse it rather than re-deriving the selectors. diff --git a/.opencode/skills/verify-sunao/features/search.md b/.opencode/skills/verify-sunao/features/search.md new file mode 100644 index 0000000..f07dada --- /dev/null +++ b/.opencode/skills/verify-sunao/features/search.md @@ -0,0 +1,27 @@ +# Search + +The user types into one box and the song library filters client-side by title or artist, with an empty state when nothing matches. + +## Sub-features +- `search-filter` — case-insensitive substring match against title OR artist; an empty query shows every song. +- `search-empty` — "No results" / "Try a different search term." when the query matches nothing. +- `search-play` — clicking a result queues the current (filtered) results and plays that song. + +## How to get to it (user POV) +- Open `http://localhost:3000/search` (sidebar or bottom-nav `Search`). + +## Driving it with Playwright +Preconditions +- Dev server healthy. No auth and no Supabase needed. + +- **Load search.** `page.goto("/search")`. The `Search` heading is visible and `getByPlaceholder("What do you want to listen to?")` is visible and empty. With an empty query the full list is shown, e.g. `getByRole("main").getByRole("button", { name: /Come Together/ })`. +- **Filter by title.** `box.fill("sun")`; `Here Comes the Sun` is visible and `Come Together` is not. +- **Filter by artist.** `box.fill("beatles")`; several results whose row text contains `The Beatles` are visible. +- **Empty state.** `box.fill("zzzzzzzz")`; `No results` and `Try a different search term.` are visible and no result buttons remain. +- **Restore.** `box.fill("")`; the full list returns. +- **Play a result.** Filter to one song, click its row, and the player bar shows that song with the toggle as `Pause`. +- **Proof.** Screenshots to `artifacts/verify-search/`: default list, filtered results, empty state. + +## Gotchas +- Scope result assertions to `main`. The queue panel lists every song too, so `Come Together` is visible even while the search list has filtered it out. +- The input has no label or `aria-label`; address it by placeholder. The results are not a listbox, just buttons.