# 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). - **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. **Outstanding on this screen**: `AddItemsDialog` still lacks the monotonic `seqRef` stale-response guard and `useIsMountedRef()` — the same class of guard "Debounced typeaheads" below mandates there, applied to a debounced-while-typing fetch; `AddItemsDialog` is an explicit Search-button submission, not a typeahead, so that mandate doesn't reach it directly, but the same race (a superseded search settling after a newer one) can still occur here — tracked in **ersatztv#740**, not yet fixed here. - **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. ## 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). ## 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 `