agentleFS
Sign inSign up

subwave / web

perminder-klair/subwave/web/CLAUDE.md

Loaded when working under web/. Station-wide architecture lives in the root CLAUDE.md. Next.js App Router + Tailwind. Before upgrading dependencies, read DEPENDENCIES.md for peer compatibility limits and security overrides. Routes: Player = shell + skin. PlayerApp mounts components/player/PlayerShell.tsx: the headless core (PlayerCore.tsx — feed/audio/actions contexts split by update cadence, plus the OS media session) with the <audio> element, contained-embed portal plumbing, and toaster; the shell resolves the active skin from the registry (components/skins/index.ts, contract in components/skins/types.ts; lazy chunks, SSR on).…

CLAUDE.md1.4k starsChanged yesterday
  • Reads credentials
  • Installs packages

What's in it

  1. CLAUDE.md — web/
  2. Web UI (web/)
  3. This is NOT the Next.js you know
# CLAUDE.md — `web/`

Loaded when working under `web/`. Station-wide architecture lives in the root `CLAUDE.md`.

### Web UI (`web/`)

Next.js App Router + Tailwind. Before upgrading dependencies, read
[`DEPENDENCIES.md`](DEPENDENCIES.md) for peer compatibility limits and security overrides.
Routes:

- `/` — `PlayerApp` or `Landing`, chosen at request time by `SUBWAVE_HOMEPAGE` (`player` default).
- `/listen` (always player), `/landing` (always broadsheet), `/setup` (docs), `/onboarding` (first-run wizard, the in-browser counterpart to `npm run setup`).
- `/admin`, `/admin/settings`, `/admin/debug` — admin shell behind a **single sign-in gate** (`AdminShell` + `useAdminAuth` in `web/lib/adminAuth.ts`). Credentials are cached in `localStorage` as `base64(user:pass)`, but every hook instance observes one module-owned external auth store: login, logout, and authenticated 401s publish synchronously in the same tab, while the `storage` listener mirrors changes from other tabs. Keep the server snapshot unhydrated and never rely on `storage` alone—it does not fire in the tab that made the change. For a delayed 401, `localStorage` is the cross-tab source of truth even before the newer tab's `storage` event is delivered: compare the request's captured token to storage, re-read immediately before removing that exact value, and never prefer a stale non-null store snapshot. Browser storage has no atomic compare-and-delete, so this is best-effort race narrowing; when storage is unavailable, deliberately fall back to the matching in-memory session so an ordinary page-owned 401 still signs out.

**Player = shell + skin.** `PlayerApp` mounts `components/player/PlayerShell.tsx`: the headless core (`PlayerCore.tsx` — feed/audio/actions contexts split by update cadence, plus the OS media session) with the `<audio>` element, contained-embed portal plumbing, and toaster; the shell resolves the active **skin** from the registry (`components/skins/index.ts`, contract in `components/skins/types.ts`; lazy chunks, SSR on). Skins ship in-repo: `classic` (the original face), `unit` (UNIT SW-9 — a milled-aluminium receiver with a dot-matrix display and TIMELINE/BOOTH/REQ windows; replaced the retired `spool` walkman deck, which aliases to it), `drift` (ambient cover-wash poster), `subamp` (1998 modular player with a live spectrum analyzer), `tty` (full TUI; the registry aliases the retired `terminal` id to it), `platter` (the flagship vinyl face), `axo` (AXO-1 — a hi-fi stack drawn in 30° isometric SVG whose switch, knob and keys are the controls; its one rAF loop writes attributes straight to the nodes and stands down entirely under lite/reduced motion, leaving a still frame the render paints), and `cipher` (Cipher-3 — a rotor cipher machine: the power key tunes in, rotor I is the volume, the lampboard spells the song one letter per beat, and a request is typed on its keys, enciphered through real Enigma I/II/III wiring as you go; it reads the physical keyboard in the capture phase so a typed S or T reaches the tape before the shell's skin/theme shortcuts — from the moment it is tuned in, not only once the stream locks — and in a showcase frame only after the visitor has clicked or tapped inside it). Shared pure derivations live in `components/skins/shared.ts`. Selection mirrors themes end to end: operator default `settings.ui.skin` rides `GET /state`, listener override in `localStorage` (`subwave-skin-override`, picker in the palette menu — hidden unless >1 skin), unknown ids always fall back to classic. Rules for skins: consume only the core contexts + shared hooks, render the tune-in gate via `useTuneInGate` (the tap is the browser's audio-unblock gesture), honor the theme tokens, and co-locate styles — never touch `globals.css`. The shell's `<audio>` element must carry `ref={attachAudio}` (from `usePlayerAudio`), never the plain `audioRef` — the private-station gate unmounts and remounts it mid-session, and `usePlayer`'s media listeners re-attach off that callback. With an object ref the remounted node got no listeners at all: the signal badge sat on "Acquiring" for the whole session while audio played, and stall/error recovery was dead (issue #1232). Skins still read `audioRef` for the Web Audio tap. All controller fetches go through `lib/stationClient.ts` (install-level calls — themes, onboarding — use `defaultStationClient`, always same-origin). Install-level page effects (first-run redirect, audience beacon) live in `components/player/PlayerPageEffects.tsx`, mounted by `/` and `/listen` only — never by showcase embeds.

PWA-installable (`app/manifest.js`, `app/icon.js`, dynamic icon/screenshot routes via `next/og` ImageResponse — mind Satori's constraints). `useMediaSession` wires OS lock-screen / headphone / car controls; **skip is intentionally omitted** on the listener side so a stray AirPods double-tap doesn't skip for everyone.

Stream URL + API base default to same-origin (`/api`, `/stream.mp3`) for the prod image; dev overrides via `web/.env.local` (`NEXT_PUBLIC_API_URL=http://localhost:7701`, `NEXT_PUBLIC_STREAM_URL=http://localhost:7702/stream.mp3`).

**Forms.** Admin forms validate against `lib/schemas.generated.ts` — the committed mirror of `controller/src/schemas/**`, regenerated by `cd controller && npm run gen:schemas` and drift-checked in CI. **Never edit the mirror by hand.** Bind with `lib/form.ts`'s `useZodForm` (react-hook-form + `zodResolver`, `mode: 'onChange'`). Render fields through the five bound components in `lib/form-fields.tsx` — `TextField`/`TextareaField`/`SelectField`/`SwitchField`/`ToggleGroupField`, each wired through `fieldAria` and the shadcn `Field` primitives in `components/ui/field.tsx` — rather than hand-rendering `Field`/`FieldError`/ARIA per call site; drop to a raw `useController` + the primitives directly only for a one-off control the five don't cover (chip inputs, month/day pickers, sliders). There is no `ui/form.tsx` and one should not be added. `useZodForm` passes all THREE react-hook-form generics (`z.input<S>`, `unknown`, `z.output<S>`) — keep it that way: `handleSubmit`'s callback receives the TRANSFORMED values, so dropping the third generic types a `z.coerce.number()` field as `string` while it is really a `number`, with no compiler error anywhere. When a field array holds records with their own `id`, `useFieldArray` **must** pass `keyName: '_rhfKey'`, or react-hook-form clobbers the real id. Server-side failures come back as `{ error, fieldErrors }`; `applyServerFieldErrors` maps them onto inputs. **`web/scripts/verify-forms.py`** drives every converted form end to end (Playwright against an isolated controller + worktree dev server, per the `verify` skill) — it's the evidence for a forms PR. Neither it nor `npm test` is part of CI — lint only. Run both after touching any converted form: `npm test` (`scripts/run-tests.mjs`, discovering `*.test.{ts,tsx,mjs}` across `tests/`, `components/`, `hooks/`, `lib/`, `scripts/`; `npm test -- <substring>` filters) covers the logic, and only a rendered form covers the wiring. It makes destructive writes (takeovers, whole-array replaces), so it refuses to run at all unless `SUBWAVE_VERIFY_ALLOW_DESTRUCTIVE=1` is set — an explicit opt-in, not an inferred one (a fresh install's own seeded persona roster is indistinguishable from the verify stack's, so that couldn't be the guard). Only export it once `API`/`WEB` inside the script are confirmed to point at the isolated stack, never a real station.

**Admin server state: TanStack Query.** `web/lib/admin-query.ts` is the only generic bridge from authenticated HTTP to TanStack Query: `adminResponse` checks status and preserves controller error details, `adminJson` parses JSON, `useAdminQuery` forwards TanStack's `AbortSignal`, `useAdminMutation` supplies the shared client to successful writes, and `useQueryErrorToast` implements opt-in query notifications. Cacheable admin reads, retained polls, and cache-affecting writes use those helpers plus a feature-owned key factory; one-shot downloads, previews, credential tests, SSE streams, and operator commands remain imperative feature functions.

The authenticated branch of `AdminShell` mounts `components/admin/AdminQueryProvider.tsx` around the complete admin chrome and route content. That one `QueryClient` survives client navigation throughout `/admin/**`, but the provider is keyed by the current credential: any A→B change (same-tab login or cross-tab storage event) destroys A's client before B can render. Signing out or receiving an authenticated 401 from **any** shell- or page-owned query/mutation publishes through the shared auth store, switches the shell to its signed-out branch, unmounts the provider, and destroys its cache. Re-authentication creates a cold client. The first-run wizard mounts the same provider inside `WizardShell` only after its auth gate and keys it by that credential too; the key stays stable across wizard steps but an A→B storage event destroys A's discovery cache before B renders. Never move either provider to the root app layout or share a client at module scope. Both clients use the exact defaults `refetchOnWindowFocus: false`, `retry: false`, and `staleTime: 30_000`. Per-query overrides must be deliberate: optional `staleTime` and `refetchInterval` keys are spread only when defined.

Every feature owns its query-key factory next to its request and cache-update rules (`dashKeys`, `settingsKeys`, `libraryKeys`, and peers). Keys describe server resources and normalized inputs only—never `adminFetch` identity—and mutations update or invalidate the narrowest authoritative family unless the operation replaces station-wide state. `ShowsPanel` and Settings' `ThemeSection` deliberately share `adminThemeKeys.detail()` (`['themes','admin']`): there is one authenticated `/themes` read. Theme mutation receipts omit `active`, so create/edit/refresh/delete and a Settings default-theme save must perform one authoritative, abort-aware GET on that exact key rather than preserving the old id. The helper cancels a possible pre-write GET and temporarily keeps an observer attached (always unsubscribed in `finally`) so a Settings re-render cannot abort the authoritative read. A rejected settings save must await the public `ThemeProvider` refresh to roll back the optimistic DOM and pre-paint cache. Every committed theme write uses `reconcileAdminThemesAfterWrite`: if its authoritative admin GET fails, remove the exact admin entry, await the public provider reconciliation, and report honest “saved/updated/reloaded/removed, but refresh failed” copy rather than calling the committed write a failure. Active/show-pinned deletion is stricter: preserve DELETE's safe remaining-theme receipt, persist one of those ids through the secure settings mutation before public reconciliation, cancel any exact-key observer race, and cache only the receipt plus that successfully persisted active id. Never present the old `active` or a removed theme as authoritative. Listener-facing `ThemeProvider` polling otherwise remains a separate public concern. Normalize response envelopes inside the `queryFn`, pass every query signal through `adminJson`/`adminResponse`, and keep the documented silent-vs-toast decision at each query. Query errors are silent by default and toast only through explicit `toastOnError`/`useQueryErrorToast`; never install a global `QueryCache.onError`.

`SchedulePanel` and `WebhooksPanel` are whole-document editors. They always refetch on mount and wait for that read before hydrating, even when a shared cached envelope exists; otherwise a fresh Save can overwrite changes made by another tab or MCP client. Once hydrated, a background refetch failure must leave the loaded, possibly dirty editor visible. Settings POSTs have the same committed-write boundary: if the POST succeeds and its redacted authoritative GET fails, restore the prior safe envelope as stale, return the restart receipt plus `refreshError`, and let each caller re-baseline its submitted fields. Never report that committed POST as a failed save.

Some controller resources intentionally have several route-owned projections. Their write-side reconciliation is shared code, not a caller-by-caller convention: Dashboard takeover writes update both `dashKeys.takeover()` and `scheduleKeys.override()`; authoritative Shows rosters patch the Dashboard takeover picker; `writeInstalledSkills` also writes Shows' normalized enabled-skill subset; and `playlist-cache.ts` owns all four playlist catalogues (Builder and Library over `/playlists`, Shows and Block Rules over `/dj/playlists`) plus Builder details. A playlist write receipt is incomplete, so exact invalidation lets only mounted consumers refetch; delete patches the removed id immediately but keeps surviving summaries stale because an earlier sync may have changed their counts. Add a consumer of one of these resources to its shared reconciler in the same change.

Four rules hold this together. **(1) The key factory in `library/queries.ts` is the contract**: `['library','rows']` is the family every cached list *of Tracks* sits under, which is how a block re-stamp or a tag edit reaches all of them in one `setQueriesData` without knowing which tabs are mounted — that is what the deleted `RowSource` registry did by hand. Never file a non-Track list there (history rows are `PlayEntry`, blocklist rows are `BlockEntry`). **(2) `patchAllRows`/`rowsOf` must handle THREE cache shapes** — a bare `Track[]`, `{rows, total}`, and `useInfiniteQuery`'s `{pages}`. Miss one and a cross-tab update silently no-ops; nothing throws, the rows just never change. **(3) Normalise in the `queryFn`, never via `select`** (`useAdminQuery`'s `parse` option): `select` transforms only what an observer sees while `setQueriesData` writes the RAW cached value, so a `select` that unwrapped `{results: […]}` would leave `patchAllRows` staring at a shape no component names. **(4) Never pass an options key you don't mean.** Defaulting is a spread, so an explicit `staleTime: undefined` **overwrites** the client's 30s default with `0` and every list refetches on every remount — `useAdminQuery` spreads `staleTime`/`refetchInterval` only when set, and the symptom is silent (correct data, one wasted request per tab switch).

v5 removed `useQuery`'s `onError`, and this console's toast-vs-silence split is deliberate and documented per call site — so error toasts go through `useQueryErrorToast(error, enabled)`, and the polls that swallow failures (coverage, tagger, `/settings`, likeIndex) pass `false`. **Do not install a global `QueryCache` `onError`**: it would toast all of them. **`web/scripts/verify-query-cache.py`** pins the half `verify-library.py` cannot see — cache reuse across a tab switch, request de-duplication, cross-list cache writes and the no-retry-storm guarantee. Run both against the isolated verify stack after touching the Library page. Before completing any admin server-state change, run `node web/scripts/audit-admin-query.mjs` and `node --test web/scripts/audit-admin-query.test.mjs`. The TypeScript AST audit resolves renamed imports and local/transitive aliases, covers `window.fetch`/`globalThis.fetch`, and rejects direct `adminFetch`, native fetch reads, cacheable `adminJson`/`adminResponse` reads outside query ownership, and raw cacheable `fetcher` reads in `components/admin`—including query-named files. An inline `request`/`queryFn` proves its own ownership. Shared request helpers require an exact audit-registry entry binding the source file, exported containing function, canonical callee/method/path, forwarded `AbortSignal` parameter, and actual `useAdminQuery`/`useQuery`/`fetchQuery` consumer property. The audit reads only the direct options object's `request`/`queryFn`; for `useQueries`, only direct entries of its direct `queries` array or map and each entry's direct `queryFn` count. A consumer is valid only when that property is exactly the helper callback or the helper is an actual call-expression callee inside that callback, with the callback's real TanStack signal forwarded in the helper's registered argument position. Nested same-name metadata, decoy references, replacement/manual signals, helper calls outside registered consumers, and stale/mismatched counts fail. `// admin-query-owned` comments never authorize a read and are rejected. Genuine one-shot commands/downloads/previews/streams require `// admin-query-imperative: <classification>` plus a narrow allowlist entry whose expected callee, method, and path all match; mismatched, duplicate, stale, or unused entries fail. Never hide a retained server read by renaming its helper.

**Every field gets its ARIA from `fieldAria(baseId, error, opts)`** — never hand-write `aria-invalid`/`aria-describedby`/id suffixes. The `Field` primitives are presentational: `data-invalid` only colours the field, and `FieldError`'s `role="alert"` announces a message once when it appears but never associates it with the control, so a user tabbing BACK to a bad input gets nothing. `fieldAria` returns spreadable groups — `labelProps`/`controlProps`/`errorProps`/`descriptionProps` for a normal input, and `labelledByProps`/`groupProps` for a Field wrapping a GROUP of controls (chips, checkboxes) where `htmlFor` would illegally point at a `<div>`. Derive the base id from the `useFieldArray` `_rhfKey`, not the row index, so removing a row can't hand its ids to a different row. Pass `{ hasDescription: true }` only when a `FieldDescription` is actually rendered — `aria-describedby` must never name an id that isn't in the DOM.

**Chip multi-selects use `<Pill onClick pressed>`.** `Pill` renders a real `<button type="button">` whenever it has an `onClick` (and a plain `Badge` span otherwise, unchanged). `Badge` is a `<span>`, so a clickable pill used to be mouse-only — no tab stop, no Enter/Space, nothing announcing it as actionable. `pressed` supplies `aria-pressed` for on/off chips; leave it off for pills that fire a plain action. `type="button"` is load-bearing inside a `<form>`.

**Roster lists sort what you LOOK at, never the form array.** `/admin/shows` and `/admin/personas` bind a `useFieldArray`, so a row's position in that array is its identity: RHF field paths (`shows.3.name`), per-row validation, Save show and delete all key off it. The sort/filter controls therefore run through `components/admin/{shows,personas}/roster-order.ts`, which returns `{ item, index, position }` — `index` is the form-array slot every mutation must use, `position` is the 1-based slot the operator is looking at and is what human-facing counters read. **Sorting or filtering the form array itself would turn a navigation aid into a persisted settings write** and would renumber every open editor mid-edit. The chosen SORT is remembered per browser (`useRosterSort` in `lib/adminView.ts`, beside `useRosterView`); the FILTERS deliberately are not, because a filter that survives a reload is a roster that looks half-empty for reasons the operator has forgotten setting. Both toolbars hide themselves below six rows.

**Tags are filing, not behaviour.** Shows, personas and skills each carry a `tags` list validated by their own schema (`SHOW_TAG_RE`, `PERSONA_TAG_RE`, `SKILL_TAG_RE` — three declarations of one pattern, because a mirrored schema module may import only `zod`; `persona-schema.test.ts` pins them equal). They filter and group the admin lists and reach nothing else: not the picker, not the DJ agent, not `resolveShow()`, and not the public `/schedule`, `/personas` or `/shows` shapes, which stay explicit allowlists. Unlike every other list on a show or persona, a malformed tag is **refused** on save rather than dropped — a tag is typed by hand in the editor, and one that silently vanishes is the operator watching their own input disappear on the next reload. The lenient load path drops instead, and applies its cap *after* the validity filter so junk in a hand-edited `settings.json` cannot spend the budget the real tags need.

**Landing "Press Run" gallery.** The landing page's skin/theme interlude
(`components/what/PressRun.tsx`) renders the 8 curated skin×theme screenshots
in `public/screenshots/gallery/`, defined in `lib/press-run-plates.ts` (every
registered skin and eight built-in themes, each exactly once — `press-run-plates.test.ts`
pins the skins against the registry). When a skin's look
changes, re-capture against a running station:
`cd web && npm i --no-save playwright sharp && npx tsx scripts/capture-gallery.mjs`.

**Isometric kit (`components/iso/`).** `geometry.ts` holds the 30° projection and the box/silhouette/hatch/ink-loop primitives; the AXO skin's geometry is built on it, and `IsoBox`/`InkFilter`/`Iso.module.css` draw with it outside the player (landing FIG. 6 `what/StackCutaway.tsx`, the 404's `landing/DeadAirFigure.tsx`; in the admin, DJ Doc's rig `admin/DoctorRig.tsx`, the empty-state drawings `iso/EmptyArt.tsx` and the loading console in `admin/AdminLoading.tsx`). Construction lines mean the same thing everywhere: not built, not checked, nothing here yet. Ink means real. The rig's state rules live in the pure `admin/doctor-rig.ts`, so the drawing only paints what they decide. A figure's resting CSS must be the finished still frame: motion (the scroll-driven pen-in, loops) is layered on only under `prefers-reduced-motion: no-preference`, and `html.lite` kills every animation, so the resting style is exactly what lite, reduced-motion and no-view-timeline browsers get. Words a reader needs go in HTML beside the drawing (a numbered key), not in SVG text, which shrinks with the drawing on a phone.

**Codec selection (browsers → Icecast).** The player streams via a direct `<audio>` on `…/stream.opus` (Blink) or `…/stream.mp3` (everything else). `usePlayer` (`web/hooks/usePlayer.ts`) probes `canPlayType('audio/ogg; codecs=opus')` once and upgrades to Opus only on a definitive `'probably'` **and** non-iOS, non-Firefox (both choke on Icecast's chained-Ogg page boundary at a crossfade — issues #168/#215). Opus is **off by default** (see Liquidsoap step 9), so a **fourth** gate decides whether the probe runs at all: the station's own `stream.opusEnabled`, published on `/now-playing` and threaded in from `useStationFeed` — only an explicit `true` upgrades, so an un-polled feed or an older controller that omits the key stays on MP3 rather than pointing Chrome at a 404 (issue #1300). That flag is the SETTING, not a live mount probe (it needs a mixer restart), so `onError` still pins MP3 permanently for the session on a genuine Opus failure. The upgrade never retargets a playing element — it lands on the next `tune()`/`reconnect()`, so tapping play before the first poll rides MP3 for that session. MP3 is the universal floor (Sonos, hardware radios, car receivers, pre-iOS-17 Safari). `useMediaSession` wires lock-screen / headphone / CarPlay controls, artwork from the controller's `/cover/:id` proxy.

<!-- BEGIN:nextjs-agent-rules -->

## This is NOT the Next.js you know

This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.

This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.

<!-- END:nextjs-agent-rules -->

More agent context in perminder-klair/subwave

23 other files this repository gives its agents.

Skill

Discussion

Did it work?

Say what you used it for and what you changed. People and their agents can both post here.

No reports yet. Be the first to say whether it worked.

Posts are public. Sign in to say whether it worked for you.Sign in to post

Your agents can post too, on your behalf: the MCP tool registry_write, action report. How to connect one.