# 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/.ts` (see §4). - **Build-time plugins**: `web/vite-plugins/*.ts` — Vite plugins that run in NODE, not in the browser bundle, and are type-checked under `tsconfig.node.json` (never `tsconfig.app.json`, which must stay free of `@types/node`). This is the seam for anything a test needs that only Node can answer — `trackedSourceFiles.ts` reads `git ls-files` so `pageSizeCallSites.guard.test.ts`, the one SPA guard that derives a file population, takes it from the git index rather than a directory walk (#819). - **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. !== ;` 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 `` 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. ## 3b. Paged list endpoints clamp server-side — page to completeness ONLY for bounded lists; a media-library picker searches instead Every paged `/api/v1` list endpoint (rerun-collections, multi-collections, library/browse, search, trakt-lists, …) clamps `pageSize` to its own controller's `MaxPageSize` (100, as of #644) regardless of what the client requests. A screen that asks for `pageSize: 1000` to "get everything in one call" gets only the first `MaxPageSize` rows back, silently — no error, no truncation indicator, no paging UI to notice the gap. This was issue #644 (following on from #634, which fixed the first instance — `SchedulesScreen`'s rerun-collections picker load). **Two classes of call site, treated differently** (decision record: `docs/decisions/records/spa/library-pickers-resolve-by-search.md`, `spa.library-pickers-resolve-by-search`, superseding `spa.list-completeness-vs-bounded-pickers` — the #644 follow-up got Class A right and Class B only half right): - **Class A — bounded-by-construction lists** (collections, multi-collections, smart collections, playlists — admin-created, hundreds of rows at most): genuinely need the complete list, and completeness is cheap. Use the shared `loadAllPages` helper (`web/src/api/paging.ts`, re-exported via `web/src/api/index.ts`) instead of an inflated `pageSize`: ```ts const { items, complete } = await loadAllPages(getMultiCollections); // pages against totalCount, cap defaults to 100 ``` It pages `pageNum` from 0 (per §"paging-zero-based" in `api-conventions.md`) against the response's `totalCount`, stopping — and reporting `complete: false` — on an empty page (defensive guard against a `totalCount` that never converges) or on an aborted `signal`. **Always check `complete`**: a caller that needs the full list must not treat a resolved promise as proof the list is whole (a partial result is otherwise silently indistinguishable from a complete one — the same defect class as #644 itself, since `GetLibraryBrowseItemsHandler.HydrateMediaItems` can legitimately drop stale Lucene hits and produce a short/empty page in normal operation). Render `complete: false` as its own copy ("List may be incomplete — retry to reload"), never through search-narrowing text — a `loadPickerOptions` result that can come from either class carries a `hint: 'incomplete' | 'none'` discriminator, not a boolean shared with an unrelated condition (#644 follow-up round-3 review F1). Pass an `AbortSignal` (4th arg) from the caller's effect cleanup so a superseded load stops issuing further page requests, and gate any `console.warn` on `!signal?.aborted` — a superseded or user-aborted load returns `complete: false` too, and that's expected, not a defect. **Do not raise the server-side cap to work around any of this** — the `api.search-allitems-paging` precedent is that the client bounds itself and the server stays bounded. - **Class B — media-library pickers** (Episode / Song / Image / Movie / MusicVideo / TelevisionShow / TelevisionSeason / Artist / OtherVideo / RemoteStream — the largest tables in an install, tens of thousands of rows possible): **resolve by search, do not window the type at all** (#651). Neither `loadAllPages` (~200 serial requests for a 20k-row library, each more expensive than the last since `LuceneSearchIndex.Search` computes `hitsLimit = skip + limit`) nor a single bounded page (an arbitrary alphabetical prefix, unusable as a picker even once the truncation is made visible) is acceptable. Render the shared `SearchPicker` (`web/src/schedules/pickers.tsx`) over `searchLibraryPickerOptions` (`web/src/api/libraryBrowse.ts`): ```ts const searchLibrary = useCallback( // memoize: SearchPicker lists `search` in its effect deps (q: string) => searchLibraryPickerOptions('Episode', q), [] ); ``` The helper owns both bounds: at most ONE `getLibraryBrowseItems` request per settled query, at most `LIBRARY_PICKER_RESULTS` (25) rows, and no request at all below `LIBRARY_PICKER_MIN_QUERY` (2) characters. Selecting a media-library type must issue **zero** requests. The per-kind `LIBRARY_PICKER_RESULTS` cap is the only truncation this class has — there is no whole-type window left to hint at, so the old `Showing the first 100 of 5000 — use search to narrow.` copy is gone from these pickers along with the window it described. Surfacing the per-kind cap is *permitted* wherever it is reachable, and *required* only where bulk selection makes the count actionable — see the `AddItemsDialog` sub-bullet below, which sums the cap across kinds and renders a `Showing N of M matches` hint for exactly that reason. Prove the bound with a **request-count assertion against a large (20k-row) fixture**, not by inspection. - **`SearchPicker` is the single-select SHAPE, not the rule itself.** A MULTI-select picker (`CollectionsScreen`'s `AddItemsDialog` — checkbox rows, many items added at once, fanned out over several kinds) cannot render `SearchPicker` and must not be forced to. It satisfies this section by taking the same *constraints* the helper enforces for single-select — the gate on `LIBRARY_PICKER_MIN_QUERY`, `titleContainsQuery` the typed text, `LIBRARY_PICKER_RESULTS` per kind — via `searchLibraryBrowseItems` (`web/src/api/libraryBrowse.ts`), a sibling of `searchLibraryPickerOptions` that returns full `LibraryBrowseItem` rows plus `totalCount` instead of `{id, name}`, so the bound lives in the helper rather than the caller (#685 review finding 2). There is no post-fetch `slice`, but the per-kind cap can still truncate the real match count — this is a bulk multi-select add, where "add the 40 matching episodes" is a first-class use, so `AddItemsDialog` sums each kind's `totalCount` and renders a `Showing N of M matches` hint once it exceeds the rendered rows (finding 4 — an earlier revision of this bullet called the truncation nothing left to hint at). **The gate's home is the shared HELPER, not the screen — however single-sink the screen's own function looks.** #685 got this wrong twice in a row, and the second time is the instructive one: the check sat inside `runSearch`, which genuinely IS the one sink both entry paths route through, so it read as correct. It was still a duplicate of the helper's gate, and the two masked each other: as of `4be3f247d` — which had no unit tests on the helper — deleting EITHER copy left the whole suite green, so the min-query boundary test pinned nothing. Removing the screen's copy is what made the helper's gate load-bearing. **The invariant, not the count: every gate must have at least one test that reddens when that gate ALONE is removed.** A guard you cannot redden is not a guard, and "it's the single sink" is not evidence that it is the only one. **The async guards below reach this screen too** (#740), and debouncing is not what triggers either of them — **the two guards have different triggers and neither subsumes the other**: - `seqRef` is triggered by **concurrent reachability**: two or more entry points into one async sink, so a superseded response can settle after a newer one. `AddItemsDialog` has exactly that — a form submit fanning out over `DEFAULT_SEARCH_KINDS`, and a kind-chip click issuing a single request — so `runSearch` carries it even though it is an explicit Search-button submission rather than the debounced typeahead "Debounced typeaheads" mandates it for. - `useIsMountedRef()` is triggered by **unmount-reachability**, which is present with a single entry point too. Check whether the surface can close mid-request through a path that does not consult the in-flight flag: a disabled submit button does not settle it, because `Dialog` closes on Escape and on a backdrop click without consulting anything. Both `runSearch` and `submit` carry it for that reason. Apply the guard to this component's OWN state only. A parent callback invoked after the await — `onAdded`, reloading the list after a write that succeeded — belongs to the parent, which owns its own guarding; dropping it because THIS component went away leaves the list stale. **`searching` is written under the seq guard too**, which is the half easily missed: a superseded search's `finally` otherwise clears the spinner while the newer request is still in flight, and the dialog claims to be idle mid-request. So is the **catch** path — an error from a superseded search must not wipe the results a newer, successful one has already painted. - **Compile typed text; never forward raw Lucene.** Send `titleContainsQuery(text)` → `title:**`. The index's default field does not match bare title words (`Alpha` finds nothing for "Show Alpha" — see `e2e-local.md`), so a raw forward looks broken in a *name* picker. Reuse the helper; do not re-implement the escaping (same rule as the #440 Auto-Tune typeahead, same shape `builder/rules/compile.ts` emits for `contains`). The escaped set includes `&` and `|`, because Lucene's boolean operators are `&&`/`||` and a title like `Rock & Roll` otherwise compiles to something Lucene parses as syntax. **Drive the escaping test from the exported character set** (`LIBRARY_PICKER_LUCENE_SPECIALS`), one character per case — a test carrying its own hand-copied "every special" sample cannot see what is missing from that sample. - **The bound belongs to the helper, not the caller.** `searchLibraryPickerOptions` *clamps* `pageSize` to `LIBRARY_PICKER_RESULTS`; a documented bound a caller can exceed by passing a bigger number is not a bound. - **Render the current selection from the owning record, not from the result set.** An item already selected but outside the current results must still display — losing it on edit is data loss, not a cosmetic defect. Rerun collections and playlist items carry `selectedName` on their own DTOs; `FillerPresetFullResponseModel` stores only an id, so its edit path resolves the name with a single by-id detail read (`getShow`/`getSeason`/`getArtist`) and degrades to `#id` on failure — never to a cleared field. - **A read model that derives an id and its name from the same eager-loaded navigation reports *no selection at all* when that navigation isn't loaded** — a successful 200 indistinguishable from "the user cleared it". (`RerunCollectionsController.ProjectToResponseModel` does exactly this, and `MediaCollections/Mapper` maps RemoteStream through `_ => null`; tracked as **#671**.) An earlier revision of this section required a client-side guard that preserved the id across such a response. **That guard is gone and must not be rebuilt** — it only ever preserved a value seeded from the list row, which is itself null in production for every row, and the reconciliation it required is what the initialize-once rule below replaced. The correct handling is to show the server's answer honestly: no selection, Save disabled, and the validation badge saying why. - **An id NEVER travels without its namespace — in results, in options, in cached result sets.** A media/collection id only means anything inside the type that produced it, so any structure holding ids must hold the type too, and identity is compared as `(type, id)`. Three places this bites, all the same bug: 1. A typeahead's cached results must be keyed on `(source, query)`, not the query text. Same query, different source ⇒ the results are not *stale*, they are *wrong*: hide them and re-query. Keying on text alone lets a re-query guard **suppress** the new source's request and leave the old namespace's hit clickable under the new label. 2. List-backed `` is fully keyboard-operable; swapping in a listbox-and-input is an accessibility *regression* unless it implements the ARIA combobox pattern — `role="combobox"` + `aria-expanded`/`aria-controls`/ `aria-autocomplete` on the input, ArrowUp/ArrowDown to move a virtual cursor exposed via `aria-activedescendant`, Enter to commit, Escape to dismiss, options as non-tab-stops (`tabIndex={-1}`) marked with `aria-selected`. Note this changes what `getAllByRole('combobox')` matches in tests: count `` options, and the selection restored from a detail read — so a check added to whichever one surfaced the defect leaves the others open (this is how #651 produced the same finding in two consecutive rounds). Share one predicate (`isSelectionId` / `selectionIdOrNull` in `web/src/api/selectionId.ts`) and apply it on every path — including the ones that don't look like pickers, such as a `playlistGroupId` seeded from the wire into a create dialog. **Treat an unbindable id as ABSENT, never coerce it** — rounding `1.5` to `1` would submit a *different* record — and **clear its label with it**: a row still reading "Blade Runner" over a null id makes two contradictory statements about the same item. Drop rather than render an option that cannot be selected safely. "Surfaces as no selection" is only true if that screen's Save gate actually checks for one — on `PlaylistsScreen` it did not, so this claim was false there for a full round after being written here. **Verify an invariant on every screen it names before writing it down.** Prove it per ingress by asserting zero writes are reachable *after attempting the write*: a write-count assertion on a path that never attempts one is trivially true. And unit-test the predicate's INCLUSIVE endpoints directly — once it is the single point of failure for every ingress, a `>` for `>=` slip passes an entire screen suite. - **A caller-supplied promise needs a deadline, and a 2xx body is not a contract.** `client.ts` turns malformed JSON into `undefined` rather than rejecting, so `setResults(undefined)` throws on the next render. Validate the **elements, not just the container**: `Array.isArray` accepts `[null]`, which then throws on `option.id` during render, and an element with a wrong-typed `id` commits an invalid value through `onSelect`. Treat any malformed payload as a failed attempt (so it stays retryable), not as an empty answer. And a `search` prop carries no abort signal, so race it against a timeout — otherwise a never-settling request leaves the picker spinning with no way back. - **A stale result set must not be committable — by ANY modality.** Between a keystroke and its response, `results` still describe the *previous* query, so highlighting an option, retyping, and pressing Enter commits the old option while the box reads the new text. Drop the highlight on **input change** (not when the next response arrives) and put the guard in the single `choose()` sink rather than on each call site — gating Enter and leaving `onClick` open is the same defect in another modality, and the next path added would be ungated too. Keep the stale list *visible* (hiding it flickers on every keystroke) but genuinely inert: `aria-disabled` plus a dimmed style, not merely a handler that silently no-ops on a normal-looking button. - **Escape must not strand the user.** Closing the popup while focus stays in the input means `onFocus` can never re-arm it, so typing does nothing and the user has to blur and refocus to recover. Typing and ArrowDown must both reopen it — and reopening onto results that are already current must **not** re-query: the duplicate response lands later and resets the cursor the user has since moved, so Enter silently does nothing. Reopening also places the cursor (ARIA APG) rather than swallowing the keypress. **The web typecheck gate is `npm run typecheck`, never `npx tsc --noEmit`.** `web/tsconfig.json` is solution-style (`"files": []` + `references`), so a bare `tsc --noEmit` resolves to zero input files and exits 0 **without checking anything** — a green that means "I looked at nothing". CI runs `npm run typecheck` (`tsc -b --pretty false`), which builds the referenced projects and includes the test files. Verified by planting a deliberate type error: `--noEmit` stayed green, `-b` caught it. **Testing an is-mounted guard: React 19 does not warn on a setState-after-unmount, and an unmounted tree renders nothing either way** — so no DOM assertion can distinguish "the guard stopped it" from "React discarded it". Prove the *mechanism* (a `useIsMountedRef` unit test, with a StrictMode double-invoke for the re-arm) **and** the *integration* (mock the hook module and assert the component actually read `current` — and saw `false` — when the late response landed). Verify each by removing the mechanism and confirming the test fails. ## 3c. A write failure must outlive the dismissible surface that started it (#830) `Dialog`, `SlideOver` and `ConfirmDialog` are all dismissible **mid-request** through three paths that never consult a busy flag: Escape and a backdrop/scrim click (both via `useOverlayBehavior`), and the header close button. Disabling the footer Cancel button — which nearly every dialog here does — looks like it closes that hole and does not. So an error rendered from the dismissible component's own state has nowhere to go once the user dismisses it. The request still runs, still fails, and the screen reloads unchanged: the user reads that as success. Guarding the `setError` with an is-mounted check (§3b) is necessary but **not sufficient** — it makes the drop deliberate rather than accidental, which is still a drop. **Use `useDismissSafeError` (`web/src/hooks.ts`).** It renders the message inline while the surface is mounted — the better surface, since it keeps the user's context — and hands it to a caller-supplied `onFailed` once the surface is gone: ```tsx const { inlineError, reportFailure, setInlineError } = useDismissSafeError(onFailed); // ... } catch (err) { reportFailure(messageFromXError(err, 'Unable to …')); } ``` The surviving surface belongs to the **parent**, so `onFailed` is a prop contract, not a rendering decision. `CollectionsScreen` reports into its screen-level `role="alert"` banner — the only wired site today. The other candidate surface is the `notice` + `Toast` pair that `MediaBrowseScreen` and `SearchScreen` already use for success. There is no global toast host in this SPA; do not add one for a single screen. The two are **not equivalent**, so pick deliberately if you wire the second: `Toast` is `role="status"` (polite, announced less assertively than `role="alert"`) and each screen has a single `notice` slot, so a later success Toast would overwrite a pending failure one. Nothing diverts to those screens today — they receive no reporting callback. **Both halves of the outcome must reach the surviving caller**, and they are not the same kind of callback. Report the OUTCOME (`onAdded` / `onDone` / `onFailed`) unguarded — gating a success callback on an is-mounted check makes a completed write silent, exactly like the failure case. But gate the DISMISS request (`onClose`): it means "close me", and after dismissal "me" is whatever the user opened next. The four `media/addTo/` dialogs currently gate BOTH (measured on `AddToCollectionDialog`: `onAdded` called 0 times after dismissal) and are tracked in #877 — separating the two there needs the parent screens to stop nulling the dialog themselves, so it is a design change rather than a one-line fix. When you add a success callback to a dialog, add its failure counterpart, and keep the close decision distinct from the report. Make the reporting prop **required** where the host screen has a surface for it — `AddItemsDialog.onAddFailed` is, so the failure cannot be dropped by forgetting to wire it. Make it optional only when some host genuinely has nowhere to report (the `media/addTo/` layer's eventual one will be, because `MediaDetailScreen` wires no outcome callbacks at all); there, an omitted `onFailed` drops the failure exactly as today — a known gap, not coverage (#877). Rationale and the full call-site sweep: `docs/decisions/records/spa/dismissible-write-failure-reporting.md`. **Pin the arm the CALL SITE reaches, not only the hook's.** A unit test of `useDismissSafeError` proves the inline branch exists; it cannot see a call site that reports through `onFailed` directly and so pushes the message onto the parent banner while the surface is still up — behind a `createPortal` panel with `aria-modal="true"`, i.e. covered for sighted users and hidden from AT. Add an integration assertion for each arm: fail the write with the surface still open and assert the message is inside `[role="dialog"]`, and fail it again across a dismissal and assert the message is on the screen and not in a dialog. ## 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(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(path, options)` returns `{ data, etag }` (reads the `ETag` response header). `request` 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). ## 4b. Full-replace request bodies are built as `Complete` (#807) A full-replace endpoint writes the WHOLE entity, so a field the builder never sets is not left alone — it is written as its default. Build every full-replace body against `Complete` (`web/src/api/completeRequest.ts`), which maps a generated request type so **every** member is required: ```ts function toReplaceRequest(draft: Draft): Complete { … } items.map((item): Complete => ({ … })) ``` Two rules, and the second is the one that gets skipped: - **Annotate the wrapper parameter** (`body: Complete`) so every caller — including ones written later by someone who never read this — inherits the check. - **Annotate each construction site too, including every `.map` callback's return type.** The wrapper annotation catches a *missing* field anywhere. The *phantom* direction (a field the schema does not accept) relies on TypeScript's excess-property check, which fires only on a **fresh object literal in a contextually typed position** — and a literal returned from a generic `.map` callback is not one, because `map` infers `U` from the callback's return rather than from the target element type. A spread or an inferred local loses it too, but the `.map` callback is the common case and the easy one to misread as safe: several construction sites accepted a phantom field before #807, and three of them contained neither a spread nor an inferred local. An optional member may be written `field: undefined`. The point is not to forbid omitting a value, it is to forbid omitting the *decision*: an unmentioned field is an oversight, an explicit `undefined` is a choice a reviewer can see. **Do NOT apply `Complete` to a schema whose optional members are computed server-side.** `ArtworkContentTypeModel` (reachable from `PUT /channels/{id}` via `UpdateChannelRequest.logo`) has `isExternalUrl` / `hasContentType` / `urlWithContentType` as get-only properties derived from `path`. Nothing deserializes them, so omitting them drops nothing — and annotating the site would force you to invent server-computed values in an outbound request. Check the schema's disposition in `scripts/tests/test_optional_request_members.py` before annotating a new site. Why this is needed even though most builders already typecheck: a member is omittable exactly when the generated type marks it `?:`, which comes from the `required` array of the schema in `ErsatzTV/wwwroot/openapi/v1.json` — the generator script only passes it through. Most nullable properties emit as required-and-nullable, so the gap looks closed on inspection while a minority sits unchecked inside it. Do not trust a list of which schemas those are: two hand-written ones were wrong (#807). `scripts/tests/test_optional_request_members.py` derives the set on every run and fails until each has a stated disposition. See `docs/decisions/records/testing/full-replace-asserts-field-list.md`, and `web/src/api/completeRequest.guard.test.ts` for the executed proof. **What is machine-checked, exactly (#820).** `web/src/api/completeAnnotations.guard.test.ts` scans the SPA with the TypeScript compiler API and asserts, per SCHEMA: every schema that can silently drop a member and is dispositioned as needing an annotation carries a `Complete<…>` naming it in some **production** file — a `*.test.*` or `*.spec.*` file does not count, nor does the setup file `vite.config.ts` names (a SECOND `setupFiles` entry would, and is a stated residual); the schemas where annotating would be a bug carry none; and every `Complete` resolves to an alias of `components['schemas'][…]` rather than a hand-written mirror — annotating a mirror proves only that the caller filled in the mirror, which is the #754 mechanism wearing the annotation meant to prevent it. Its disposition table is cross-checked against `scripts/tests/test_optional_request_members.py` by `test_complete_annotation_dispositions.py`, so the two cannot quietly disagree about a schema they both rule on. **What is NOT checked — read this before assuming the guard has your back.** - **Neither of the two rules above is enforced per SITE.** The check is that *a* production annotation naming the schema exists. Deleting the `Complete<…>` from an API wrapper stays green as long as some other production file still names that schema. Write both annotations anyway; the guard catches wholesale deletion, not relocation. - **It is token presence, not liveness.** A `Complete` that nothing uses satisfies it. - **The phantom direction is not checked at all.** That needs a fresh object literal in a contextually typed position, which a generic `.map` callback's return is not — the population is sites-in-code (#777). A guard that derived the write WRAPPERS and required the annotation on the wrapper parameter was built for #820 and REMOVED before merge, after four review rounds each found a fresh way past it — the last being an `export function` to `export const` refactor that removed real protection while the scanner and its cross-check went blind together. Do not rebuild it without reading that history in `docs/guard-inventory.md`; the cheap version of that idea does not work. ## 5. Artwork rendering Render `item.artwork` / `item.poster` (or whatever the DTO field is named) **directly as an ``** — 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 `