# SPA conventions — "Add a screen" playbook

Purpose: a precise playbook for adding a new screen (or sub-path editor) to the ChicoryTV React SPA
(`web/`), for an agent with no prior context in this repo. **Update this doc in the same PR that
changes any convention below.**

Companion to `api-conventions.md` (the API surface the SPA talks to) and `docs/contributing.md`
(general repo conventions).

## 1. Stack & layout

Vite + React + TypeScript, builds to `ErsatzTV/wwwroot/app` (see `web/vite.config.ts`:
`base: '/app/'`, `build.outDir: '../ErsatzTV/wwwroot/app'`), served by the ASP.NET host at `/app`.

- **Composition root**: `web/src/App.tsx` — owns the active route, approved Libraries sub-path,
  navigation guard integration, and theme; it composes the shell with the matched screen.
- **Routes + nav**: `web/src/app/routes.tsx` — the ONE stable module-level array of `ScreenRoute`
  objects (`path`, `label`, `title`, `kicker`, `icon`, etc.) plus `allowSubPaths?: boolean`, the
  matcher, and sidebar group definitions. Route objects must never be rebuilt per render.
- **Shell + screen dispatch**: `web/src/app/AppShell.tsx` owns Sidebar/TopBar/Connect/version/theme
  chrome; `web/src/app/ScreenContent.tsx` exhaustively maps a matched route to its screen and owns
  the Media/Libraries route wrappers.
- **Screens**: `web/src/screens/*.tsx`, one file per top-level screen, generally with a colocated
  `*.test.tsx`.
- **API clients**: `web/src/api/<domain>.ts` (see §4).
- **Styling**: `web/src/shell.css` (+ `web/src/components/components.css`) — utility classes with a
  `ctv-` prefix (~690 occurrences across those two files). Reuse an existing `ctv-*` class before
  inventing a new one. `shell.css` carries the only base reset — `html, body { margin: 0 }` plus
  `body { background: var(--surface-app) }` (the 8px default body margin otherwise frames every
  full-viewport layout with a light border, #373). **There is no global `box-sizing` reset**
  (the SPA is authored under the default `content-box`), so any element that combines `width: 100%`
  with padding/border must set `box-sizing: border-box` locally or it overflows its container — e.g.
  `.ctv-nav-item` (#377). Prefer `width: auto` (shrink-to-fit) over `width: 100%` + padding where you can.

## 2. CRITICAL: sub-path screens must own their own pathname state

If a route sets `allowSubPaths: true` (e.g. so `/app/blocks/{id}` works under the `/app/blocks` nav
entry), **the screen component itself must track `window.location.pathname` and listen for
`popstate`** — do not rely on the composition root re-rendering `ScreenContent` when the sub-path
changes.

**Why**: `app/routes.tsx`'s `routeFromLocation()` matches an `allowSubPaths` route by prefix
(`pathname.startsWith(\`${route.path}/\`)`) and returns the **same `ScreenRoute` object reference**
for the base path and every sub-path under it. `App`'s state update is
`setActiveRoute(routeFromLocation())`; React's `useState` setter bails via `Object.is` when the new
value is reference-equal to the old one — so navigating from `/app/blocks` to `/app/blocks/42` (or
between `/app/blocks/42` and `/app/blocks/17`) **never re-invokes `ScreenContent`** at the `App`
level. See the comment block directly above `PlayoutsRouteScreen` in
`screens/PlayoutsScreen.tsx` for the canonical explanation, and its implementation
(`useState(() => window.location.pathname)` +
`useEffect` with a `popstate` listener local to the wrapper component) for the fix.

Exemplars of screens that already do this correctly: `BlocksScreen.tsx`, `TemplatesScreen.tsx`,
`DecosScreen.tsx`, `DecoTemplatesScreen.tsx`, and the `PlayoutsRouteScreen` wrapper colocated in
`PlayoutsScreen.tsx` (which owns two sibling sub-paths, `/playouts/{id}/alternate-schedules` and
`/playouts/{id}/templates`, dispatching internally via `parsePlayoutSubRoute`).

**Exception — a guarded sub-path route defers pathname ownership to App.** When a sub-path route's
screens ALSO register a dirty guard (§8), the wrapper must **not** self-listen for `popstate`; App
owns the pathname and passes the approved sub-path down as a prop. The `LibrariesRouteScreen` wrapper
in `app/ScreenContent.tsx` is the exemplar (sub-path parsed by `parseLibrariesSubRoute`, dispatched
via a flat `switch`, sub-path supplied by App's `librariesSubPath` state). See §8 for why child-before-parent
effect order makes the self-listening pattern unsafe here.

## 3. Data loading pattern

Reference implementation: `web/src/screens/LogsScreen.tsx`. Structure to copy for any screen that
fetches from the API:

- A **discriminated-union state type** covering loading/success/error, e.g.
  `type LogsState = { status: 'loading'; ... } | { status: 'success'; ... } | { status: 'error'; ... }`.
- A `seqRef` (monotonically incremented request counter) + `activeRef` (mount-tracking boolean,
  flipped in a mount/unmount `useEffect`) pair — guards against a stale, slower request overwriting
  a newer one's result, and against setting state after unmount.
- The actual fetch lives in a `useCallback` (`load`), called from a **separate** `useEffect(() => {
  load(); }, [load])`.
- **Lint rule — `react-hooks` "no set-state-in-effect"**: never call `setState` **synchronously in
  the body** of a `useEffect`. State transitions happen only inside event handlers or promise
  `.then()`/`.catch()` callbacks (as in `LogsScreen`'s `load`). This is enforced by
  `eslint-plugin-react-hooks` in `web/eslint.config.js` — a synchronous `setState` in an effect body
  will fail `npm run lint`.

## 3a. "Keep results visible during refetch" ⇒ gate mutations + show a refreshing cue

Some grid screens deliberately keep the **previous** successful result set rendered while a refetch
is in flight (no full-screen loading state on a query/kind/page change), so the grid doesn't flash
empty. `SearchScreen.tsx` and `MediaBrowseScreen.tsx` do this. If such a screen also carries
**mutation surfaces** (per-card Add-to menu, Select/select-mode, a selection action bar, "Add all",
"Save as smart collection"), those surfaces would otherwise stay live over a **stale** result set —
an add/select action then targets the about-to-be-replaced items, or (worse) a query-wide "Add all"
bulk request resolves against the previous query. This was issue #221 (adversarial-reviewer#18).

Convention — when a screen keeps stale results visible during a refetch:

- **Key the success state to the request params that produced it.** Store the identifying params on
  the `status: 'success'` variant (`SearchScreen`: the `query`; `MediaBrowseScreen`: a
  `kind|query|page` `key`), set in the seq-guarded `.then`. Derive
  `const refreshing = state.status === 'success' && state.<key> !== <current params>;` in render.
  Prefer this over a synchronously-set `refreshing` flag: setting state synchronously from the load
  path trips the `react-hooks` "no set-state-in-effect" rule (§3).
  **Invariant, not a guarantee**: the derivation is only self-correcting when *every* value the
  current params can take will actually trigger a fetch. If `load()` early-returns for some param
  value (e.g. `SearchScreen`'s blank-query guard), `state` never updates for that value and a stale
  `status: 'success'` variant lingers — so the comparison must exclude params that suppress
  fetching, or gate the whole flag on the same condition that gates the fetch (`SearchScreen`:
  `const refreshing = hasQuery && state.status === 'success' && state.query !== query.trim();` —
  fixed post-review in #222 after the naive derivation got stuck `true` once the query was cleared
  to empty, see PR discussion for #221).
- **While `refreshing`:** show a visible cue (a `role="status"` "Refreshing…" row with `<Spinner>`
  plus the `.ctv-media-grid-dim` opacity class on the grid) and **disable every mutation surface** —
  per-card Add-to menu (withhold the `actions` node), select toggle + in-grid selection
  (`const canSelect = selectMode && !refreshing;` gates `onToggleSelect`), the selection action bar,
  "Add all", "Save as smart collection". Card navigation (`onOpen`) **may** stay live — but only
  outside select mode: while `selectMode && refreshing`, `MediaPosterCard` falls back to `onOpen`
  whenever `onToggleSelect` is undefined, so both props must be withheld together or a mid-select
  click navigates away instead of no-op'ing. The select-mode toggle itself should only be disabled
  while refreshing when *entering* select mode (`refreshing && !selectMode`) — exiting only clears
  selection, not a mutation, so it must stay available.
- **Bind async bulk completions to their request params, not just mount.** A whole-query/whole-set
  request (e.g. `getSearchAllItems`) must, on resolve, check that its snapshotted params are still
  current (compare against a ref that always holds the committed value — `SearchScreen` reuses
  `lastQueryRef`) and **discard** otherwise. Checking only `activeRef` (mounted) is insufficient.

## 4. API client modules

One file per domain in `web/src/api/`, e.g. `logs.ts`, `blocks.ts`, `playouts.ts`. Pattern (see
`web/src/api/logs.ts`):

- Re-export the generated response/DTO types from `./generated/v1`:
  `export type LogEntry = components['schemas']['LogEntryResponseModel'];`
- A typed params interface for the endpoint's query string (e.g. `GetLogsParams`).
- The fetch function builds a `URLSearchParams` from only the params that are set, then calls the
  shared `request<T>(url)` helper from `./client`.
- An error-message helper (e.g. `messageFromLogsError`) that narrows `unknown` → `ApiError` (from
  `./client`) → a human string, with a fallback message — screens use this instead of stringifying
  errors themselves.
- `web/src/api/index.ts` re-exports everything so screens import from `'../api'`, not from the
  individual domain file directly.

**Trust the generated key casing — it mirrors the runtime.** Since #198 the OpenAPI spec is generated
to match the runtime Newtonsoft serializer exactly (a schema transformer runs the same contract
resolver; see `api-conventions.md` §5a), so the generated types carry the real wire keys — including
Newtonsoft's acronym quirks like `ffmpegProfileId` (channel FFmpeg-profile id) and `ffmpegProfile`
(channel FFmpeg-profile display name). **Do not** hand-cast responses to "fix" a key or dual-read a
spec-cased vs runtime-cased key (the old `PlaybackTroubleshootingScreen` `#198` escape hatch that read
`data.channel.fFmpegProfileId` has been removed — read `ffmpegProfileId` straight off the typed
response). When you mock an API response in a test, use the generated (runtime) casing.

## 4a. Optimistic-concurrency editors (ETag / If-Match / 412)

Replace-all editors (Blocks is the reference; see `api-conventions.md` §7a for the server contract,
issue #253) must round-trip the aggregate's concurrency ETag so a stale tab can't silently overwrite a
fresher edit:

- **Transport seam** (`web/src/api/client.ts`): `requestWithMeta<T>(path, options)` returns
  `{ data, etag }` (reads the `ETag` response header). `request<T>` delegates to it and drops the meta —
  keep using `request` for endpoints without a concurrency token.
- **Domain module** (`web/src/api/blocks.ts`): expose a `…WithMeta` load
  (`getBlockItemsWithMeta` → `{ data, etag }`) and make the replace accept the last-seen ETag and return
  the new one: `replaceBlock(id, body, ifMatch?)` → `requestWithMeta(..., { headers: ifMatch ? {'If-Match': ifMatch} : undefined })`.
- **Editor**: hold the ETag in a `useRef`; set it from the load GET, and **replace it from the PUT
  response's ETag on every successful save** (a same-tab second save otherwise 412s against its own
  write). Keyed reload: a `reloadKey` state in the load `useEffect` dep array lets the conflict flow
  re-fetch.
- **412 UX**: catch `error instanceof ApiError && error.status === 412` on save and open a blocking
  "changed elsewhere — reload (unsaved changes discarded)" `ConfirmDialog` (Reload bumps `reloadKey`),
  distinct from a 409 ("build in progress — retry shortly"). All other errors stay the generic save-error
  path. Reference: `web/src/screens/BlocksScreen.tsx` `BlockEditor`.
- **List-derived editors** (Multi/Rerun collections open the editor from a paged-list row, not a per-record
  GET): fetch the single record via the `…WithMeta` by-id helper (`getMultiCollectionWithMeta` /
  `getRerunCollectionWithMeta`) when the editor mounts, and build the draft from **that** response — so the
  ETag and the draft data come from one read and stay consistent (the fail-safe ordering; don't pair a
  list-row draft with a separately-fetched ETag). Editors that navigate back to the list on save (Playout,
  Multi/Rerun, Collection reorder) need only the 412 branch — no post-save ETag rotation.
- **Replace-all PUT → round-trip every persisted field, even ones the editor doesn't visibly edit.** A
  replace-all update (Multi/Rerun collection, schedule items, block items) submits the *whole* item list,
  so any field the draft mapping drops is silently reset to its default server-side. The multi-collection
  editor's per-source `weight` (#404) is the canonical trap: it must be read in `itemsFromMultiCollection`
  and written in `toItemRequest`, or every source resets to weight 1 on the next save of an unrelated edit
  (rename, add a source). When you add a field to a replace-all DTO, add it to **both** the response→draft
  and draft→request mappings and cover the round-trip in a test.
- **Numeric inputs with API bounds**: hold the value as a string in the draft so the field edits smoothly
  (transient empty/partial), pass `type="number"` + `min`/`max`/`inputMode="numeric"` to `Input` for the
  native stepper, clamp to the API's validator range on `onBlur` (immediate feedback) **and** again at save
  (authoritative) — never let an out-of-range value reach the server and surface a raw 400. Mirror the
  server's bound as a shared const (e.g. multi-collection weight `WEIGHT_MIN`/`WEIGHT_MAX` = 1..1000, #404).

## 5. Artwork rendering

Render `item.artwork` / `item.poster` (or whatever the DTO field is named) **directly as an `<img
src>`** — since PR #181, API responses already return rooted, directly-usable URLs (see
`api-conventions.md` §4). **Do not** client-side-prefix artwork paths (no `/artwork/posters/` string
building in SPA code) — if you see that pattern, it's stale/wrong.

## 5b. HLS video preview

Screens that preview an ErsatzTV HLS stream use the reusable `HlsPlayer` component
(`web/src/media/HlsPlayer.tsx`, introduced with #145). Pass it a `src` (the `.m3u8` URL, or `null`
for idle) and — when the manifest GET itself starts a server-side session (e.g. troubleshooting
`playback.m3u8`) — a `playToken` you increment per play, so a repeat play with an identical URL
still tears down and re-attaches (an unchanged `src` alone is a state no-op that never issues a new
request); it attaches `hls.js` when Media Source Extensions are available and falls back to native
HLS (`video.canPlayType('application/vnd.apple.mpegurl')`, i.e. Safari) otherwise, and tears down the
`hls.js` instance on `src` change and unmount. Its config mirrors the legacy `_Host.cshtml`
`previewChannel` (`liveDurationInfinity: true` + an unbounded manifest `maxTimeToFirstByteMs`) because
the troubleshooting `playback.m3u8` endpoint blocks until segments exist before it 302s to the live
manifest. **In tests, mock `hls.js` wholesale** (`vi.mock('hls.js', …)` with a class exposing
`static isSupported()`, `static Events`, and `loadSource`/`attachMedia`/`on`/`destroy`) so jsdom never
touches a real `MediaSource`; assert the manifest URL via the mocked `loadSource` spy (see
`PlaybackTroubleshootingScreen.test.tsx`).

It also takes an optional `onError(message)` wired to `Hls.Events.ERROR` (fatal errors only) and,
on the Safari native path, the `<video>` element's own `error` event. Fatal errors are reported,
never auto-recovered — a diagnostic surface must show the fault rather than retry past it. Pass a
**stable** `onError` (`useCallback`); it is in the attach effect's dependency array, so an unstable
identity restarts the stream every render. When mocking `hls.js` in a test that exercises errors,
include `ERROR` in the mock's `static Events`.

It also takes an optional `onPlaying()`, called once playback has actually started — wired to the
`<video>` element's own `playing` event on **both** the hls.js/MSE path and the Safari native-HLS
path. It is deliberately **not** wired to `Hls.Events.MANIFEST_PARSED`: that fires before any media
has decoded (the manifest for an `HttpLiveStreamingDirect` channel always parses, even over a black
video), so it only issues the `video.play()` kick. `onPlaying` is purely additive (omitting it is
safe) and mirrors `onError`'s requirements exactly: pass a **stable** `onPlaying` (`useCallback`),
since it too sits in the attach effect's dependency array and an unstable identity would restart the
stream every render.

**Resolving a `/iptv/*` src under JWT auth (#552).** Before feeding an `/iptv/*` manifest URL to
`HlsPlayer`, pass it through `withIptvToken(url)` (`web/src/media/iptvToken.ts`): under a JWT-enabled
deployment it appends the short-lived `?access_token=` the `/iptv/*` scheme requires (the `ctv-session`
cookie does not satisfy it), and it is a **no-op** when JWT is off (the endpoint answers 204, cached).
It is async, so resolve it into state and gate the player on the resolved src — the channel-preview
panel does this in an effect keyed on `playToken`; the troubleshooting screen awaits it inline in its
`onPlay` handler. Only the top-level manifest needs it (segments and the multi-variant→variant hop
carry or bypass the token server-side). **In tests, mock `../media/iptvToken` to identity**
(`withIptvToken: (url) => Promise.resolve(url)`) so opening a preview fires no real `iptv-token` fetch;
the append logic itself is unit-tested in `media/iptvToken.test.ts`.

## 5c. Media "Add to…" affordances

Screens that let the user add media items to a collection/playlist/schedule use the shared layer in
`web/src/media/addTo/` — `AddToMenu` (popover for a `MediaPosterCard` `actions` slot or a detail
page's action row) and the `AddToCollectionDialog` / `AddToPlaylistDialog` / `AddToScheduleDialog` /
`SaveAsSmartCollectionDialog` it drives. Do **not** build screen-local target pickers. Multi-select
on grid screens is an explicit "Select" toggle (see `docs/decisions.md` 2026-07-10 for the rationale
and the accepted deviations from Blazor).

## 5d. Client-local preferences: `localStorage`, namespaced `ctv-*` keys

Per-browser UI preferences (theme, an auth token, a screen's remembered page size) live in
`window.localStorage` under a namespaced `ctv-` key, **not** a round-trip through the API — the
established pattern is `designSystem.ts`'s `getStoredDesignSystemTheme`/`applyDesignSystemTheme`
(`ctv-theme`): a small `getStorage()` helper that returns `window.localStorage` wrapped in a
try/catch (so a disabled/unavailable storage API degrades to the default instead of throwing), a
getter that validates the stored value against the known option set before trusting it, and a
setter that writes straight through. `LogsScreen.tsx`'s page-size persistence (`ctv-logs-page-size`,
#213) follows the same shape. Reserve this for state that's genuinely local to the browser/user
session — if a preference needs to be shared across devices or is really server/business state
(e.g. Blazor's `ConfigElement`-backed settings), it belongs behind an API endpoint instead; see
`docs/decisions.md` 2026-07-11 for the specific reasoning on logs page-size.

## 5e. Session auth: the boot gate, the machine-key screen + the global 401 signal (#295)

Since #295 the browser authenticates with a **session cookie**, not an API key. The whole `/api`
surface still answers a missing/expired session with **401**, and mutating verbs additionally require a
CSRF header. The former keyless-bootstrap `ApiKeyScreen` (the `X-Api-Key`/`localStorage` model of #197)
is gone. The SPA seams now are:

- **The request client sends no API key; it relies on the cookie and adds CSRF automatically**
  (`web/src/api/client.ts`): the session cookie rides along with every same-origin request, and every
  mutating verb (POST/PUT/PATCH/DELETE) gets an `X-Csrf` header set automatically — screens making a
  POST get CSRF for free. There is **no** `getStoredApiKey`/`X-Api-Key` path anymore; the only residual
  `localStorage` touch is `clearLegacyStoredApiKey()` (`web/src/api/auth.ts`), a one-shot cleanup the
  boot gate runs once to purge any stale `ctv-api-key`.
- **The boot gate owns login/setup** (`web/src/AuthGate.tsx`), wrapping `<App/>` outside the
  shell/router. It asks the PUBLIC `/api/v1/auth/config`, then `/api/v1/auth/session`, and renders Setup /
  Login / the app. It also publishes `AuthContext` (`{ username, method, signOut, requireLogin }`) —
  read `method` via `useContext(AuthContext)` to branch on the auth kind (e.g. `'local'` vs OIDC).
- **The machine-key screen is a normal authenticated screen** (`web/src/screens/ApiKeyScreen.tsx`,
  route `/app/api-key`, System nav group). It loads the server machine key from `/api/v1/auth/machine-key`
  (via `getMachineKey()`) and displays it **masked** (a masked `<code>` that never puts the key in the
  DOM until Reveal) with Reveal + Copy affordances — the key is for MCP / external REST clients; the
  browser no longer uses it. For **local** accounts only (`AuthContext.method === 'local'`) it also
  renders a "Local admin password" card that calls `changePassword(current, new)`, mapping a wrong-
  current-password **401** to the server ProblemDetails detail inline.
- **File-download endpoints go through a fetch-blob helper, never a browser tab.** Endpoints that
  return a binary body (the troubleshooting archive / media-sample POSTs) must **not** go through
  `request()` (it JSON-parses) and must **not** be opened with `window.open` (a session-cookie GET in a
  new tab can't carry the CSRF header and bypasses error handling). Instead `fetch(path, { method:
  'POST', headers: { 'X-Csrf': '1' } })`, then `response.blob()` → object URL → anchor-click →
  revoke, reading the filename from `Content-Disposition`; a non-ok response throws an `ApiError`. See
  `downloadTroubleshootingArchive` / `downloadTroubleshootingMediaSample` in `web/src/api/troubleshoot.ts`.
- **401 UX is one shell-level banner, not per-screen handling.** Rather than special-casing 401 in
  every screen's error path, the request client emits a single app-wide signal on 401
  (`auth.ts` `notifyUnauthorized` / `subscribeUnauthorized`), and `web/src/UnauthorizedBanner.tsx`
  (rendered once in the App shell) subscribes and prompts re-login. This is the DRY seam for "the server
  rejected us" — reuse it instead of adding bespoke 401 branches. Auth flows that expect a 401 as an
  inline answer (login, change-password) pass `suppressUnauthorizedSignal` so they don't trip it.

## 6. Tests

- **vitest**, colocated `*.test.ts` / `*.test.tsx` next to the source file.
- Every screen with meaningful logic gets a screen test; every API client module gets a
  param-mapping / URL-building test (e.g. `logs.test.ts` next to `logs.ts`).
- `web/src/App.test.tsx` covers composition, navigation, shell behavior, and integration regressions
  such as sub-path-to-sub-path rendering and guarded popstate restoration. Detailed screen behavior
  belongs in the screen's colocated test. `web/src/app/routes.test.tsx` independently pins stable
  route-object identity and query/path matching.
- **Nav-label test-selector care**: `getByRole('link'/'button', { name: /Regex/ })` matches by
  substring by default — a loose regex can match more than one nav item. Verified example: the
  System nav button is matched with an **anchored** regex (`name: /^System/`) rather than a bare
  `/System/`, specifically to avoid ambiguous matches against other labels that start with or
  contain "System". Anchor (`^`/`$`) or use exact strings in `getByRole` name matchers whenever a
  new label could be a substring of (or share a substring with) an existing one — check
  `app/routes.tsx`'s nav `label:` list for collisions before picking a new label.
- **Extracted-screen tests own their own fetch mock** (Schedules #207, Channels #244, Playouts
  #245): when a
  screen is pulled out of `App.tsx` into `web/src/screens/<Name>Screen.tsx`, its colocated
  `<Name>Screen.test.tsx` builds a **self-contained** `vi.spyOn(window, 'fetch')` mock scoped to
  that screen's own endpoints (plus local `jsonResponse`/fixture-factory helpers) and renders the
  screen component **directly** — it must not import from `App.test.tsx` or reuse the monolithic
  `mockDashboardApi()`. `App.test.tsx` keeps only a thin **nav-smoke** test for the extracted
  screen (route to it, assert it mounted and hit its own endpoint) plus genuinely cross-cutting
  shell concerns (route table, sub-path ownership §2, the unsaved-changes/popstate guard §8, the
  TopBar action wiring). Detailed screen behavior lives in the screen's own test file. See
  `ChannelsScreen.test.tsx` / `SchedulesScreen.test.tsx` / `PlayoutsScreen.test.tsx` for the shape.

## 7. Verification gate — run before every commit touching `web/`

From `web/`:
```bash
npm test          # vitest
npm run lint      # eslint .
npm run build     # tsc -b && vite build
```
Also run `npm run check:api` if you touched anything OpenAPI-relevant (see `api-conventions.md` §5)
— it regenerates `src/api/generated/v1.d.ts` and fails the build if it's out of sync with what's
committed.

## 8. Unsaved-changes navigation guard

Screens with a **draft / explicit-Save** model (edits accumulate locally, one Save flushes them —
e.g. the schedules editor, `web/src/screens/SchedulesScreen.tsx`) must guard against losing the
draft to navigation. The shared module `web/src/navigationGuard.ts` is the seam:

- The screen **registers a guard** on mount: `registerNavigationGuard(() => !dirtyRef.current ||
  window.confirm(...))`, returning the unregister fn from the mount `useEffect` for cleanup. Only one
  guard is active at a time (the mounted screen); a stale unregister only clears its own guard.
- `App.tsx`'s `navigate` handler calls `canLeaveCurrentScreen()` before `pushState` — a `false`
  return aborts the in-app sidebar/nav click.
- **Browser Back/Forward (`popstate`) is also covered.** `App.tsx`'s `popstate` handler consults
  `canLeaveCurrentScreen()` too. A `popstate` cannot be cancelled — by the time it fires the URL has
  already changed — so on a veto the handler **re-pushes the pre-pop path** (tracked in a
  `currentPathRef` updated on every approved navigation) via `history.pushState` and leaves
  `activeRoute` untouched, effectively undoing the browser's URL change. This same handler also runs
  for the **synthetic** pop `navigateToPath()` dispatches, so programmatic in-app navigation is
  guarded as well. The re-push does **not** re-fire `popstate`; that's safe because only one screen
  is mounted at a time.

- **Guarded sub-path routes: App owns pathname/popstate (resolves the old §2 caveat).** A sub-path
  wrapper that BOTH tracks its own pathname (§2) AND whose sub-screens register a dirty guard cannot
  self-listen for `popstate`: React commits **child** passive effects before the **parent**, so a
  wrapper-owned `popstate` listener fires *before* App's guard-restore handler and would switch
  sub-screen before App could veto and re-push — desyncing the two. **Resolution (design #202 §D.2,
  finding 4):** App is the single `popstate` owner. On a pop it consults `canLeaveCurrentScreen()`,
  and **only on approval** does it update a `currentSubPath` state value (`librariesSubPath`) that it
  passes **down** into the wrapper; on a veto it re-pushes the pre-pop path and updates nothing, so
  the wrapper never sees a vetoed path. The wrapper (`LibrariesRouteScreen`) derives its sub-path
  purely from that prop — never from the raw event — so effect-commit order is irrelevant. **Scope
  the state write to the guarded route only:** the `setLibrariesSubPath` call is gated on the arrived
  route being `libraries`, so unguarded sub-path routes (Playouts/Media) skip it entirely and stay
  byte-identical (they still bail App's `setActiveRoute` via `Object.is` and self-own their pathname
  per §2). The regression test is the App-owned-popstate case in `App.test.tsx` (dirty editor at
  `/app/libraries/local/3`: `confirm→false` keeps URL + mounted sub-screen; `confirm→true`
  navigates). Unguarded sub-path screens (`PlayoutsRouteScreen`, `MediaRouteScreen`) keep
  self-owning their pathname — only screens that register a dirty guard defer to App.
- The screen **also** guards the paths the module still can't see — an in-screen action that would
  replace the draft (schedule switch, opening the properties editor) uses its own
  `window.confirm(...)`, and a `beforeunload` listener (installed while `dirty`) covers full-page
  unloads (reload / close tab / external link).

Keep the guard predicate reading a **ref** (`dirtyRef`), not the `dirty` state value, so
`canLeaveCurrentScreen()` sees the current dirtiness synchronously at click time.

- **Successful save + same-callback navigation:** `useDirtyGuard` returns a `markClean` callback for
  the narrow case where a successful save queues the new draft/baseline and immediately calls
  `navigateToPath(...)` in that same promise callback. React has not committed the clean render before
  the synthetic `popstate`, so call `markClean()` after the durable save succeeds and immediately before
  navigating. Still queue the real clean draft/baseline state; the callback only transfers the synchronous
  guard truth across that one event boundary. Only do this while the successful request still owns the
  current draft: disable or otherwise gate draft mutations for the entire in-flight save, or revision-check
  the completion before marking clean. Otherwise the completion can discard edits made after the request
  started. Do not replace the callback with a timer or a forced render. Screens that remain mounted after
  saving do not need the callback.

## 9. Review checklist — temporal semantics

- **For every effect / timer / async completion, ask: *when* does it fire (mount, dependency change,
  unmount, StrictMode double-invoke) and *which* render/request does it still own?** A debounce timer
  fires on mount too (§3, the `lastQueryRef` no-change guard exists precisely for that); a `.then`
  can resolve after the params it was launched for have moved on (§3a, the `refreshing` gate and the
  Add-all query binding exist for that). A guard that only checks "still mounted" (`activeRef`) does
  not answer "still current".
- **For any "make X consistent with Y" change, re-validate the exemplar Y's temporal behavior before
  copying it.** #221 came from copying a fetch model that keeps stale results visible onto screens
  that had gained mutation surfaces — the exemplar was safe read-only, the copy was not. Copying a
  pattern copies its *assumptions*; confirm they still hold in the new context.

## 10. TopBar primary-action button (`usePrimaryAction`)

The shell TopBar (`app/AppShell.tsx`) renders at most **one** primary-action button (top-right, Plus
icon) for the active screen. `App.tsx` wraps the shell and matched screen in the minimal
`PrimaryActionProvider` from `web/src/primaryAction.ts`: the active screen explicitly registers one
handler, and the TopBar reads that matching registration directly from React context. No window event,
string-keyed dispatcher, or generic screen-action framework participates in normal screen actions.

- **A screen opts in** by calling `usePrimaryAction(routeId, handler)` at the **top of the component, before
  any early return** (it's a hook). The handler must be reachable there — a create/navigate handler or a
  `useState` setter, not something defined below a loading/error `return`. Reference: `SchedulesScreen`
  (`usePrimaryAction('schedules', () => setForm('create'))`).
- **The provider owns exactly one registration.** Each mounted hook gets an opaque owner token; registering a
  newer screen replaces the previous registration, and cleanup clears only the registration owned by that
  hook. A stale unmount therefore cannot erase the newer screen's action. The hook keeps the latest handler in
  a ref, so ordinary screen renders update behavior without reclaiming/churning ownership. Directly-rendered
  screen tests outside the provider remain harmless; there is simply no shell action consumer.
- **The route must ALSO declare a matching non-empty `primaryAction` label** in the stable
  `app/routes.tsx` table. The TopBar renders the button only when that label is non-empty **and** the active
  route owns a matching registration — metadata alone can no longer produce a dead button, while a
  registration without a label remains intentionally invisible. Keep the two in lockstep.
  The `#238` tests in `App.test.tsx` guard this: a data-driven `it.each` asserts each URL-navigating create
  screen's banner actually navigates (so a typo'd route id → a button that navigates nowhere → red), plus a
  drop test that an action screen shows no banner button. `primaryAction.test.tsx` separately pins matching,
  latest-handler, route-change, cleanup, and stale-owner semantics. The dialog/editor create screens
  (schedules, multi/rerun collections, trakt) are exercised by their own create-flow tests.
- **When to WIRE vs DROP (issue #238).** The Plus icon makes the button semantically a *"create new item"*
  affordance. **Keep + wire it only on list screens with a single, unambiguous create flow** ("Add Channel",
  "Add Schedule", "Add Multi-Collection", "Add Rerun Collection", "Add Trakt List", "Add Filler Preset", "Add
  Profile", "Add Watermark"). **Drop it** (`primaryAction: ''`) where the primary action is **not** a create
  (Save / Refresh / Play / Validate / Reset / Scan — the "+" is wrong, and those screens already carry the
  correct in-body control), or where a single banner action would be **ambiguous** (Collections has two create
  types behind tabs), **a silent no-op** (the Channel builder's Create is disabled until the form is valid),
  or **semantically misplaced** (Dashboard is a status page; Libraries "Scan" is per-row). Coexisting with an
  in-body create control is fine (Schedules has both) — the banner is a convenience, not the sole entry point.
  See `docs/decisions.md` 2026-07-12 for the full rationale.

## 11. Slide-over panels + the shared advanced-options model

- **`SlideOver` (right-edge detail/edit panel)** lives in `web/src/components/overlay.tsx` alongside
  `Dialog`. Use it for a per-item edit/detail surface layered over a screen (the Auto-Tune "Configure"
  panel is the reference, #386); use `Dialog` for a centered confirm/short form. Both share one
  private `useOverlayBehavior(open, onClose, panelRef)` hook (focus + body-scroll lock on the
  closed→open transition, Escape-to-close via a latest-`onClose` ref, scrim-click dismiss). It
  portals to `document.body` and takes `title`/`subtitle`/`children`/`footer`/`width`. Don't build a
  bespoke edge panel — extend `SlideOver`.
- **Draft-until-commit slide-overs need the §8 guard at the SCREEN, not the panel.** When per-item
  edits accumulate in the parent screen's state and are flushed by one later action (Auto-Tune's
  bulk-create), closing the panel does **not** lose data (it's still in screen state) — so the
  `window.confirm` guard belongs on *screen navigation / unload* while any uncommitted edit exists,
  not on panel close. Show an "Edited" badge on customised rows so the pending edits are visible.
- **Per-source correction rows: weight stepper + include/exclude toggle, keyed by the server's source id**
  (#440 wiring #425's backend into the Auto-Tune DetailPanel's Content-sources pane). The row model is
  reusable wherever an item list carries per-item rotation weights:
  - The row's draft (`SourceDraft` in `AutoTuneScreen.tsx`) holds `weight` as a **string** per §4a, plus a
    boolean `excluded`; the bound `WEIGHT_MIN`/`WEIGHT_MAX` = 1..1000 mirrors the server's
    `MultiCollectionItemWeight` validator — the same 1..1000 the multi-collection editor enforces (#404), but
    via its **own screen-local pair**: same values, separate consts, not a shared module. It is clamped on
    `onBlur` **and** again in the draft→request mapping.
  - **Only genuinely customised rows go on the wire.** The draft→request mapper (`sourcesRequest`) drops any
    row that is still fair-share (weight 1, not excluded, not added) and returns `undefined` when nothing is
    left, so the field is **omitted** rather than sent as an all-default array. That predicate mirrors the
    server's own `customized` check exactly — an all-default array is a documented no-op there, and omitting
    it keeps the channel on the cheaper single-SmartCollection shape. The same predicate drives the row's
    "Edited" badge and the §8 dirty guard, so a value touched and set back to the default is correctly *not*
    an edit (same rule as a name cleared back to empty).
  - **A library picker compiles typed text; it never forwards raw Lucene.** The "add a source not in the
    base set" typeahead sends `title:*<escaped>*` (`titleContainsQuery`), not the user's literal text: the
    search index's default field does **not** match bare title words (`Alpha` finds nothing for "Show Alpha"
    — see `e2e-local.md`), so a raw forward like `SearchScreen`'s (where the box is explicitly a query box)
    would look broken in a *name* picker. Escape every Lucene special + whitespace so the boundary stars are
    the only live wildcards — the same shape `builder/rules/compile.ts` emits for `contains`.
  - Rows are read-keyed by the **server's** id (`GET /api/v1/channels/auto-tune/members`), and an added id
    that turns out to already be a base member patches the existing row instead of appending a duplicate.
- **Advanced channel-options overrides are shared, not duplicated** (`web/src/builder/advancedOptions.tsx`).
  The `CreateChannelFromLineupAdvancedOptionsRequest` override model — the enum catalogs,
  `ADVANCED_KEYS`, `effectiveValue`, and the field adapters (`useAdvancedOverrides`) — is one module
  consumed by both the Channel Builder and the Auto-Tune DetailPanel, and must not be re-implemented
  per screen. Each screen writes its own field JSX over the shared hook; `playbackOrder`/`playoutMode`
  are surfaced as dedicated controls (Builder state / Auto-Tune's Shuffle + Always-playing toggles) and
  merged into `advanced` at create, not carried in the override map.
- **Three per-field states: INHERIT / set / CLEAR** (#135, `api.from-lineup-clear-to-none`). A select on
  `INHERIT` (or a cleared text input) **omits** the field so the create handler coalesces it with the
  template value. A concrete value overrides the template. The **`CLEAR` sentinel** ("None") is stored in
  the overrides map like any override but is folded into the request's `advanced.clear` list at build
  time by `applyOverridesToRequest`/`collectClears` — so the sentinel never reaches the wire as a field
  value, and `effectiveValue` reads a `CLEAR` override as none. **Build the request only through
  `applyOverridesToRequest`**, never a raw spread of the overrides map (a spread leaks the `CLEAR`
  sentinel). Today only the five id selects (watermark + fillers) expose a "None" option; the backend
  `clear` enum also covers the preferred audio/subtitle language strings for machine clients, but the SPA
  text inputs keep "empty = inherit" (a tri-state text control is deferred) — API-ahead-of-UI, matching
  the "REST API is a real audience" posture.

## 12. Reusable rule builder (`web/src/builder/rules/`)

A visual rule builder for Lucene-backed queries, introduced for the SmartCollection create/edit
dialog (#176) and built as a **standalone, controlled component module** — not a SmartCollection
screen concern — so it can be embedded by later screens without duplicating the rule model.

- **`types.ts`** — the rule tree: `Rule` (field/operator/value), `Group` (`match: all|any` over
  `Rule`s and/or **recursively nested** sub-`Group`s), `Operator`, `FieldType`, an `isGroup`
  narrowing helper, and **`MAX_GROUP_DEPTH`** — the authoring cap on nesting (root group = depth 0,
  so up to `MAX_GROUP_DEPTH` levels of sub-groups below it; currently 5). #436 replaced #176's
  one-level "Kodi" model with bounded-arbitrary depth — see the `spa.rulebuilder-nesting` decision.
  `MAX_GROUP_DEPTH` is the **single** source of truth: the UI's "Add group" gate
  (`RuleBuilder.tsx`), `parse.ts` (anything deeper is out of subset ⇒ `null` ⇒ raw-text fallback)
  and `roundtrip.test.ts`'s generator all read it — never re-hardcode a depth.
- **`compile.ts`** / **`parse.ts`** — `compile(group)` turns a rule tree into a query string covering
  a **closed subset** of the Lucene grammar; `parse(input, fieldTypes)` is its exact inverse, returning
  `null` (never a lossy best-effort tree) for any query outside that subset. Escaping is total, so a
  compile→parse round-trip is lossless for any builder-authored value, including Lucene special
  characters — pinned by a 500-tree property test (`roundtrip.test.ts`, which generates trees to the
  full `MAX_GROUP_DEPTH` and asserts the corpus actually reached that depth).
- **`fieldCatalog.ts`** — `useSearchFields()` wraps `GET /api/v1/search/fields` and exposes a clean,
  non-null `RuleField[]` (plus a `fieldTypes` lookup and a `byGroup` grouping) for field pickers; this
  is the only place the raw API response shape is unwrapped.
- **`RuleBuilder.tsx`** — the controlled component itself: `{ group, onChange }` in, add/remove
  rule and sub-group UI out. It holds no query-string state — the embedding screen owns the compiled
  `query` string (`compile(group)` on change) and, symmetrically, seeds `group` via `parse(query,
  fieldTypes)` on load (falling back to raw-text editing on a `null` parse).

**Usage pattern**: a screen owns two things — the raw `query` string (what actually gets saved) and
the parsed `Group | null` (what the builder edits). Toggling between "Builder" and raw-text modes is
just switching which of the two is the source of truth for that render, re-deriving the other via
`compile`/`parse` at the toggle boundary. See `SmartCollectionDialog` for the reference integration.

- **`SmartCollectionDialog.tsx`** (`web/src/builder/`) — the name + query authoring dialog wrapping
  `RuleBuilder` (Builder/Advanced toggle + a live preview count), extracted from CollectionsScreen so
  it is a **shared, self-contained editor** (#437). Its only coupling to a host is the interface
  `{ open, busy, error, initial, onCancel, onSubmit({name, query}) }` — the parent owns the `onSubmit`
  side effect (persist, add to a lineup, …). Consumed by **CollectionsScreen** (create/edit) and the
  **ChannelBuilder** (`/app/new-channel`, "New smart query" in the Collections source): the Builder
  authors an ad-hoc query, `createSmartCollection` persists it as a real named SmartCollection, and the
  new collection is added to the lineup by its `smartCollectionId` — no new REST/MCP surface (the MCP
  already exposes `ersatztv_create_smart_collection`).

Generalizing **Auto-Tune's** fixed axis picker into an arbitrary-field builder is a *different*
primitive (group-by, not single-query filtering) and a backend epic — designed separately in
`docs/superpowers/specs/2026-07-23-auto-tune-arbitrary-field-design.md`, tracked as its own issue,
not wired here.

- **Relative-date operators** (#435, `rulebuilder.relative-date-macros`) — `release_date`/`added_date`
  gain `inLast`/`notInLast` alongside `before`/`after`/`between`, each with a numeric value plus a
  `day|week|month|year` `unit` picker (`types.ts`'s `unit?: DateUnit`). `dateMacro.ts` is the single
  seam: a `field↔macro-prefix` table (`release_date↔released`, `added_date↔added`) plus the
  `inLast/notInLast ↔ inthelast/notinthelast` suffix maps, delegated to from `compile.ts`/`parse.ts`
  for these two fields — the compiled query is the existing `released_inthelast:"7 day"`-style
  `CustomMultiFieldQueryParser` macro, so nothing downstream changes. `validation.ts`'s `ruleError`
  requires the value to parse as a positive integer before it's compiled.
- **Facet-value typeahead** (#434, `api.search-field-values`) — the value input for a `text` field
  (not enum) is a combobox backed by `getSearchFieldValues` (`web/src/api/search.ts` →
  `GET /api/v1/search/fields/{name}/values?q=&limit=`), debounced on keystroke, prefix-matching the
  in-progress value against distinct terms already in the index. It always allows free-text entry as a
  fallback — a 404 (non-text field) or an empty result list (e.g. ElasticSearch backend) degrades to a
  plain text input rather than blocking the rule.
- **Single-child-group normalization** (#438) — `normalizeGroup` (`validation.ts`) coerces a group's
  `match` to `all` whenever it has fewer than two children, recursively. A one-child `any` group is
  semantically identical to `all` but doesn't round-trip through `compile`→`parse` (the compiled Lucene
  for a lone child carries no `AND`/`OR`), so the builder normalizes on every change rather than let the
  UI and the compiled query silently diverge; `roundtrip.test.ts`'s property test asserts against
  `normalizeGroup(tree)`, not the raw generated tree.

## 13. Collapsible sidebar + nav-group accordions (#396)

The shell sidebar (`web/src/app/AppShell.tsx`) supports two independent, persisted collapse states.
Both are shell chrome — no screen participates.

- **State + persistence** live in `web/src/app/sidebarState.ts` (`useSidebarState()`), consumed by
  `AppShell` alone. It follows the §5d client-local-prefs pattern (a try/catch `getStorage()`, a
  validating getter, a write-through setter) over two namespaced keys:
  - `ctv-sidebar-collapsed` — `"1"`/`"0"`; is the sidebar collapsed to the 60px **icon rail**?
  - `ctv-sidebar-groups` — JSON `{groupKey: boolean}` where the boolean is **collapsed**. A labeled
    group with **no stored entry defaults to collapsed**, so a fresh load shows only the always-open
    **Primary** group. (Keys are the `ctv-` hyphen form, not the prototype's `ctv.sidebar.*` — see
    `decisions.md` 2026-07-18.)
- **`AppShell` stamps `ctv-app-shell-collapsed` on the shell root** when collapsed; the rail look is
  entirely CSS-driven from that one class (`shell.css` narrows the tracked `--sidebar-w` to 60px and
  transitions `grid-template-columns`; `@media (prefers-reduced-motion: reduce)` drops the transition).
- **Nav is inventory-driven** from `sidebarNavGroups` (`app/routes.tsx`). Only **labeled** groups are
  collapsible; each has an explicit stable `key` (`'media'`, `'system'`) used for the persisted map —
  don't derive the key from the label (a rename would orphan persisted state). The unlabeled Primary
  group is always rendered.
- **Accordions apply only in the expanded sidebar.** In the rail, group-collapse is ignored: every
  item renders (icon-only), groups separated by a `.ctv-nav-divider`. The nav item's **label stays in
  the a11y tree** (visually hidden via CSS, not `display:none`) so the accessible name — and every
  `getByRole('link', { name })` test — still resolves; the label is also passed as the native `title`
  tooltip (`NavItem` gained a `title` prop). Numeric badges collapse to a corner dot. The active-route
  indicator works in both states.
- **Testing note**: `App.test.tsx`'s shell/nav suite clicks Media/System nav links directly, so its
  `beforeEach` **seeds both groups open** (`ctv-sidebar-groups`); the default-collapsed / accordion /
  rail behavior is covered in its own `describe('collapsible sidebar (#396)')`, and the persistence
  helpers have a colocated `sidebarState.test.ts`.

## 14. Server-derived rollup verdict: one filter, per-item fault badges (#415)

When the server folds several fault classes into one verdict object (see `api-conventions.md` →
"server-derived rollup verdict" and `decisions.md` → `api.channel-health-object`), the SPA gets **one**
list filter/count off the rollup `status`, never a filter per individual fault:

- **One rollup filter, not N fault filters.** `ChannelsScreen.tsx`'s `problems` filter/count reads only
  `health.status === 'Problems'` (`hasProblems(channel)`); it replaced the earlier single-fault
  `willNeverPlay`/"No playout" filter now that the taxonomy is real (`#415` superseded the "don't
  broaden the label" comment that guarded against a false all-clear). Do not add a second filter per
  fault class — that re-fragments the one signal the rollup exists to unify.
- **Per-row badges still name the specific fault(s)**, spelling out `health.faults` (`No playout` /
  `Never built` / `Build failed` / `Empty` / `Broken source`) rather than a generic "Problems" chip —
  the rollup unifies the *filter*, not the *detail* a user reads on a given row. `Unknown` renders no
  badge (it is deliberately not `Healthy` and not `Problems`).
- **Defensive coercion at the read boundary, even though the generated type is non-null.** `health` is in
  the OpenAPI `required` list, so the generated `ChannelSummary['health']` types as
  `{status, faults, playoutCount, brokenSourceItemCount}` — not nullable (contrast
  `core-dtos-generate-nullable-in-spa`, which covers *other* Core DTO fields that do generate
  `| null | undefined`). Still always read it through optional chaining with an explicit fallback at the
  point of use (`channel.health?.status`, `channel.health?.faults ?? []`) rather than asserting it
  non-null — a genuinely absent `health` (e.g. an older cached response predating this field) must read
  as "no verdict", never crash or silently read as `Healthy`.
- **Status/fault string unions are hand-maintained**, matching `ChannelPreviewAvailability`: the server
  emits plain strings backed by a `const string` class (`ChannelHealthStatus`, `ChannelFault`), not a
  generated enum, so the SPA's local TS union (`'Healthy' | 'Problems' | 'Unknown'`, and the fault
  union) is kept in sync by hand when the server adds a value.
