Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 11 additions & 9 deletions .opencode/skills/verify-sunao/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,27 +6,29 @@ 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/<feature-id>/`.
- Screenshots and ARIA snapshots go to `artifacts/verify-<feature-id>/`; 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.

## Cleanup
- 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.
29 changes: 22 additions & 7 deletions .opencode/skills/verify-sunao/features/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<feature-id>/`; 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.
33 changes: 33 additions & 0 deletions .opencode/skills/verify-sunao/features/auth.md
Original file line number Diff line number Diff line change
@@ -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.
29 changes: 18 additions & 11 deletions .opencode/skills/verify-sunao/features/data-layer.md
Original file line number Diff line number Diff line change
@@ -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/<id>` 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.
34 changes: 34 additions & 0 deletions .opencode/skills/verify-sunao/features/discoverability.md
Original file line number Diff line number Diff line change
@@ -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 `<title>`, 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>[^<]*</title>|<link rel="canonical"[^>]*>|<meta property="og:[^"]*"[^>]*>'`
- `curl -s <base>/library | grep -io '<meta name="robots" content="noindex[^"]*"'`
- `IMG=$(curl -s <base>/ | grep -o 'property="og:image" content="[^"]*"' | sed 's/.*content="//;s/"//'); curl -s -o /dev/null -w '%{http_code} %{content_type}\n' "$IMG"`
- `curl -s <base>/playlist/p1 | grep -o '"@type":"[^"]*"'`
- **Assert.** Each route returns 200 with a unique title/description; canonicals are absolute and start with `<base>`; `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`.
28 changes: 17 additions & 11 deletions .opencode/skills/verify-sunao/features/home-renders.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading