diff --git a/docs/decisions/README.md b/docs/decisions/README.md
index 89994e5b7..b024b729a 100644
--- a/docs/decisions/README.md
+++ b/docs/decisions/README.md
@@ -195,6 +195,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
| `spa.collection-custom-order-ui` | Collection custom ordering uses per-row Move up/Move down buttons (not drag) and is offered for any manual collection with custom order enabled, not just movies-only. | 2026-07-09 | [link](records/spa/collection-custom-order-ui.md) |
| `spa.datetime-local-input` | The channel-mode date/time input uses a native `` instead of free-text Chronic natural-language parsing. | 2026-07-09 | [link](records/spa/datetime-local-input.md) |
| `spa.deco-templates-table` | The deco-templates editor also renders its day/deco assignment as a table, extending (not replacing) the templates-editor-table convention. | 2026-07-09 | [link](records/spa/deco-templates-table.md) |
+| `spa.dismissible-write-failure-reporting` | A component that starts an async WRITE from inside a dismissible surface (`Dialog`, `SlideOver`, `ConfirmDialog`) must report failure through `useDismissSafeError` (`web/src/hooks.ts`), which renders the message INLINE while the surface is mounted and hands it to a caller-supplied `onFailed` once the surface is gone. Guarding the `setError` with an is-mounted check and stopping there is NOT sufficient: it converts a silent data-loss risk into a silent NO-OP the user reads as success. All three primitives dismiss through Escape and a backdrop/scrim click (`useOverlayBehavior`) plus a header close button, and NONE of those consult a busy flag — disabling the footer Cancel button, which every one of these dialogs does, looks like it closes the hole and does not. Report the OUTCOME past dismissal in both directions — a success callback gated on an is-mounted check makes a completed write silent too (measured on `AddToCollectionDialog`, #877) — but gate the DISMISS request (`onClose`) separately, because closing a surface that is no longer yours closes whatever replaced it. The surviving surface belongs to the PARENT, so the mechanism is a PROP CONTRACT rather than a rendering decision; as of 2026-08-29 exactly ONE screen is wired (`CollectionsScreen`, into its screen-level `role="alert"` banner), and the `notice`+`Toast` pair on `MediaBrowseScreen`/`SearchScreen` is a CANDIDATE second surface, not a wired one; there is no global toast host in this SPA and this decision does not add one. The reporting prop is REQUIRED where the host has a surface (`AddItemsDialog.onAddFailed`), so a failure cannot be dropped by forgetting to wire it; it is optional only where some host genuinely has nowhere to report, and there an omitted callback drops the failure exactly as before — a KNOWN remaining gap, not a claim of coverage. | 2026-08-29 | [link](records/spa/dismissible-write-failure-reporting.md) |
| `spa.download-sample-gate` | The SPA disables both Download Media Sample and Download Results while a troubleshooting session is starting/running (Blazor only gated Download Results). | 2026-07-09 | [link](records/spa/download-sample-gate.md) |
| `spa.field-progressive-disclosure` | A consequential settings field explains itself through one shared `FieldHelp` icon trigger beside the field name — never the label itself, never a widened `Tooltip` — with the paragraph declared `as const` in the screen's own `FIELD_HELP` record and the panel portalled to `document.body`. | 2026-08-26 | [link](records/spa/field-progressive-disclosure.md) |
| `spa.legacy-redirect-matcher` | `LegacyUiRedirects.TryGetRedirect` is a two-tier matcher — an exact `OrdinalIgnoreCase` `Map` (Tier 1) then an ordered segment-template pattern list (Tier 2, first-match-wins) — collision-free by construction, with a guard invariant that no rule may prefix-match `/api`, `/artwork`, `/docs`, `/openapi`, `/iptv`, `/app`, or `/media/sources`. | 2026-07-11 | [link](records/spa/legacy-redirect-matcher.md) |
diff --git a/docs/decisions/records/spa/dismissible-write-failure-reporting.md b/docs/decisions/records/spa/dismissible-write-failure-reporting.md
new file mode 100644
index 000000000..be11af505
--- /dev/null
+++ b/docs/decisions/records/spa/dismissible-write-failure-reporting.md
@@ -0,0 +1,100 @@
+---
+key: spa.dismissible-write-failure-reporting
+title: '2026-08-29 — A write failure reports to a surface that OUTLIVES the dismissible surface that started it (#830)'
+status: active
+since: '2026-08-29'
+supersedes: none
+superseded-by: none
+rule: 'A component that starts an async WRITE from inside a dismissible surface (`Dialog`, `SlideOver`, `ConfirmDialog`) must report failure through `useDismissSafeError` (`web/src/hooks.ts`), which renders the message INLINE while the surface is mounted and hands it to a caller-supplied `onFailed` once the surface is gone. Guarding the `setError` with an is-mounted check and stopping there is NOT sufficient: it converts a silent data-loss risk into a silent NO-OP the user reads as success. All three primitives dismiss through Escape and a backdrop/scrim click (`useOverlayBehavior`) plus a header close button, and NONE of those consult a busy flag — disabling the footer Cancel button, which every one of these dialogs does, looks like it closes the hole and does not. Report the OUTCOME past dismissal in both directions — a success callback gated on an is-mounted check makes a completed write silent too (measured on `AddToCollectionDialog`, #877) — but gate the DISMISS request (`onClose`) separately, because closing a surface that is no longer yours closes whatever replaced it. The surviving surface belongs to the PARENT, so the mechanism is a PROP CONTRACT rather than a rendering decision; as of 2026-08-29 exactly ONE screen is wired (`CollectionsScreen`, into its screen-level `role="alert"` banner), and the `notice`+`Toast` pair on `MediaBrowseScreen`/`SearchScreen` is a CANDIDATE second surface, not a wired one; there is no global toast host in this SPA and this decision does not add one. The reporting prop is REQUIRED where the host has a surface (`AddItemsDialog.onAddFailed`), so a failure cannot be dropped by forgetting to wire it; it is optional only where some host genuinely has nowhere to report, and there an omitted callback drops the failure exactly as before — a KNOWN remaining gap, not a claim of coverage.'
+signals: 'failed add silently swallowed · dialog closed mid-request · Escape backdrop close button ignore adding flag · error banner unmounts with the dialog · useDismissSafeError inline vs onFailed · success outlives dismissal but failure does not · onDone has no failure counterpart · paths: `web/src/hooks.ts`, `web/src/screens/CollectionsScreen.tsx`, `web/src/media/addTo/`, `web/src/components/overlay.tsx` · issues: #830, #877, #740, #685'
+mechanics: 'Pinned five ways. (1) `web/src/hooks.test.tsx` → "useDismissSafeError (#830)" pins both branches directly: mounted reports INLINE and does not call `onFailed`, unmounted calls `onFailed`, and the report goes through the LATEST callback rather than the one captured on first render. (2) `CollectionsScreen.test.tsx` → "reports a failed add on the screen when the dialog was dismissed before the request settled (#830)" drives the whole path — it parks the POST in flight, dismisses via Escape, then settles the request and asserts the message is on the screen and NOT inside a dialog. Executed: deleting `reportRef.current(message)` alone reddens it on `Unable to find an element with the text: Request failed with status 500`. That one clause is shared, so the same mutation reddens THREE tests (this one plus the two divert tests in (1)) — expect three reds, not one, when re-running it. (3) `CollectionsScreen.test.tsx` → "a late SUCCESS does not close the dialog the user reopened after dismissing (#830)" pins the OTHER half of the split: it dismisses mid-request, reopens the picker, then settles the request 204, and asserts the reopened dialog is still there — with an anti-vacuity check that the late response was actually processed (`onAdded` is `load`, so a second GET of the items endpoint must have happened), because otherwise "the dialog is still open" holds trivially. Executed: deleting the `if (mountedRef.current)` around `onClose()` reddens it alone. The POSITIVE direction is pinned separately, in the Song add test, because the negative one does not cover it: review measured that deleting the `onClose()` call ENTIRELY — so an ordinary successful add never closes the picker — left the whole suite green, so that test now asserts the dialog closes. (4) `CollectionsScreen.guards.test.tsx` counts is-mounted reads and moved from 2 to 1 when the catch''s guard migrated into the hook; its `...actual` module mock cannot see the hook''s internal `useIsMountedRef()`, which is why (1) exists. Executed: removing the surviving `finally` guard takes that count to 0 and reddens. (5) `CollectionsScreen.test.tsx` → "renders a failed add INSIDE the dialog while it is still open (#830)" pins which ARM the call site reaches, which (1) cannot: (1) proves the hook HAS an inline branch, not that this screen calls into it. It fails the POST with the dialog still up and asserts the message sits inside `[role="dialog"]` and appears exactly once in the tree. Executed 2026-09-04: swapping `reportFailure(...)` for `onAddFailed(...)` in the catch — the divert-while-open shape this record names as its own defect — reddens that test and NOTHING else (1 failed / 490 passed across the 42 `src/screens` files).'
+---
+
+**This record applies to ONE site, and the reason the rest were dropped is the useful part.**
+An earlier draft claimed SUCCESS already outlives dismissal "at every one of these sites", reasoning
+from `AddItemsDialog` — which does report success past dismissal, and says so in a comment — and
+generalising to the four `web/src/media/addTo/` dialogs without reading them. Adversarial review
+probed `AddToCollectionDialog` and MEASURED `onAdded` called 0 times after dismissal; the other
+three carry a visibly identical gate (read, not probed). So those four gate BOTH halves of the
+outcome behind their own `activeRef`, and dismiss-then-succeed is as silent there as
+dismiss-then-fail.
+
+Extending the mechanism to them was attempted and WITHDRAWN, which is why they are #877 and not this
+record. Removing that gate reports success correctly but also un-gates `onClose()`, and the two mean
+different things: `onAdded` is "tell the parent what happened", `onClose` is "close me" — addressed
+to a surface that no longer exists. Measured against the real `AddToMenu`: a late success from a
+DISMISSED dialog closed a dialog the user had since reopened. Read, not measured: the success
+handlers on `SearchScreen` / `MediaBrowseScreen` also call `clearSelection()` unconditionally, so
+the same late success would wipe a multi-select the user had rebuilt. Un-gating both is wrong,
+gating both is wrong, and gating only `onClose` still
+needs the parents to stop nulling the dialog themselves — three coupled edits across five files,
+plus an unresolved question about whether `clearSelection()` should fire for a write the user walked
+away from. That is a design decision, not a bug fix, so it goes to #877 with the measurements
+attached rather than riding along here.
+
+`AddToMenu` also has `onDone` and no failure counterpart at all, which is why a failed "Add to
+collection" from a media card reports nowhere. Same issue.
+
+**The outcome and the dismissal are different callbacks, and the exemplar proves it.** Review
+measured the cost of conflating them twice: gating both makes a completed write silent, un-gating
+both makes a late success close the dialog the user reopened to retry. `AddItemsDialog` now does
+each correctly — `onAdded()` unguarded, because it reloads the parent's list and that must survive;
+`onClose()` guarded, because after dismissal it is `setPickerOpen(false)` aimed at whatever the user
+opened next. That split is only this simple where the parent has no competing closer: here
+`onAdded` is `load`, which never touches `pickerOpen`. In the `media/addTo/` layer
+`AddToMenu.handleAdded` closes the dialog itself, so the same one-line gate is NOT sufficient there
+— measured — which is the concrete reason that half is #877 and not this record.
+
+**Why the inline branch is kept rather than always reporting to the parent.** While the dialog is
+up, inline is the better surface: it keeps the user's selections and the context they are looking
+at. Diverting to a parent banner in that case would be its own defect — the message would surface
+somewhere else while the dialog the user is staring at stays blank. Behind the dialog, in fact:
+the panel is a `createPortal` with `aria-modal="true"`, so the screen banner is hidden from AT and
+covered for everyone else. Pinned rather than argued — mechanics (5).
+
+**Why not simply gate dismissal on the busy flag.** That was considered and rejected: gating
+Escape/backdrop/close on `adding` traps the user behind an in-flight request with no cancel path,
+which is exactly why `Dialog` does not do it by default. The request is also genuinely still
+running — cancelling the UI would not cancel the write.
+
+**#740 guarded this and did not fix it, correctly.** That issue added the is-mounted guard so the
+`setError` could not fire on an unmounted tree. That is right and stays. Its scope was the async
+guards, and it deliberately did not change the UX — which left the guard making the drop
+*deliberate* rather than accidental. A deliberate drop is still a user who believes twelve items
+were added when none were, and that is the half this record closes.
+
+**Two limits of the mechanism, recorded rather than left to be rediscovered.** (1) `useIsMountedRef`
+clears its flag in a PASSIVE effect cleanup, so there is a narrow window in which the DOM node is
+detached but the flag still reads true — the message then renders inline into a dead tree instead of
+diverting. `useLayoutEffect` would close it deterministically, but that hook is shared by every
+async caller in the SPA (#578) and changing its timing is not something to do inside a bug fix;
+tracked with the rest in #877. (2) A limit the SECOND surface will have, stated now so it is
+not discovered when it is wired: `CollectionsScreen` reports into `role="alert"`, but the candidate
+surface on `MediaBrowseScreen`/`SearchScreen` is `Toast`, which is `role="status"` (polite) and a
+single last-writer-wins slot — so a failure diverted there would be announced less assertively and
+could be overwritten by a later success. Nothing diverts to those screens today; they receive no
+reporting callback.
+
+**The hook has exactly one consumer, and that is worth saying.** `useDismissSafeError` is a shared
+module with a single call site today. It earns the shape three ways: it is unit-tested directly in
+`hooks.test.tsx`, in a way that would be awkward to keep if the hook were inlined into
+`CollectionsScreen`; §3c prescribes it for the next site; and #877 is a queued second consumer. The
+caveat:
+#877 may conclude that A2's right answer is a shared reporting SURFACE rather than a per-site prop,
+in which case this hook may never gain that second consumer and stays a one-off with a convention
+section attached. That is an accepted risk, not an oversight.
+
+**Scope, stated rather than implied.** A sweep of all 68 `Dialog`/`ConfirmDialog`/`SlideOver` call
+sites (2026-08-29; 67 caller-owned plus `ConfirmDialog`'s own internal `
- {results.length === 0 && submittedQuery === '' && !error ? (
+ {results.length === 0 && submittedQuery === '' && !inlineError ? (
// Keyed to submittedQuery, not the live input: without it, backspacing the query back
// below the min-query length after a successful search wiped the rendered rows AND their
// checkmarks while `selected` (and the Add button's count) still held them (#685) — and,
@@ -457,8 +471,8 @@ function AddItemsDialog({
Type at least {LIBRARY_PICKER_MIN_QUERY} characters to search.
- ) : results.length === 0 && !searching && !error ? (
- // Also suppressed on `error`: "No results" asserts a search that COMPLETED and found
+ ) : results.length === 0 && !searching && !inlineError ? (
+ // Also suppressed on `inlineError`: "No results" asserts a search that COMPLETED and found
// nothing, which is false when the request failed. The role="alert" banner above is the
// whole message in that state (#685).
No results — try a search above.
@@ -821,6 +835,9 @@ function ManualItemsView({
collection={collection}
key={`add-${pickerOpen}`}
onAdded={load}
+ // #830: the surviving surface. This screen's own `role="alert"` banner outlives the dialog,
+ // so a failure that lands after dismissal is still reported where the user actually is.
+ onAddFailed={setError}
onClose={() => setPickerOpen(false)}
open={pickerOpen}
/>