diff --git a/frontend/src/components/ui/Toaster.dom.test.tsx b/frontend/src/components/ui/Toaster.dom.test.tsx
index 149bd759..3188b412 100644
--- a/frontend/src/components/ui/Toaster.dom.test.tsx
+++ b/frontend/src/components/ui/Toaster.dom.test.tsx
@@ -83,7 +83,7 @@ describe("Toaster — handing the toasts to another host", () => {
render();
act(() => void notify({ level: "info", message: "saved" }));
const overlay = render();
- const region = overlay.container.firstElementChild!;
+ const region = overlay.container.querySelector("[aria-live]")!;
expect(region.textContent).toContain("saved"); // it took the toast over…
expect(region.getAttribute("aria-live")).toBe("off"); // …while still off
await act(async () => void (await new Promise((r) => setTimeout(r, 0))));
@@ -170,7 +170,7 @@ describe("Toaster — handing the toasts to another host", () => {
// docked bar, above them at z-overlay, and took their clicks for as long as a toast was up.
render();
act(() => void notify({ level: "error", message: "boom" }));
- const host = hosts()[0]!;
+ const host = hosts()[0]!.closest("[data-toast-region]")!;
expect(host.className).toContain("bottom-24");
expect(host.className).toContain("pointer-events-none");
expect(screen.getByText("boom").closest(".toast-card")!.className).toContain(
diff --git a/frontend/src/components/ui/Toaster.tsx b/frontend/src/components/ui/Toaster.tsx
index 2afccd73..b3e4b4fb 100644
--- a/frontend/src/components/ui/Toaster.tsx
+++ b/frontend/src/components/ui/Toaster.tsx
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { X } from "lucide-react";
import { dismiss, getActiveToasts, subscribe } from "../../lib/notifications";
import { createStore } from "../../lib/store";
+import { useToastDock } from "../../lib/toastDock";
import { useOverlayOrder } from "./overlayOrder";
import { LEVEL_STYLE } from "./toastLevels";
@@ -51,6 +52,7 @@ export default function Toaster({
}, [overlay, id, rank]);
const top = useSyncExternalStore(overlayHosts.subscribe, topHost, topHost);
const active = useSyncExternalStore(subscribe, getActiveToasts, getActiveToasts);
+ const Docked = useToastDock();
const isTop = top === (overlay ? id : null);
const toasts = isTop ? active : [];
// A host that takes over (an overlay opening, or the app's own once it closes) receives the toasts
@@ -113,68 +115,87 @@ export default function Toaster({
// uses for the same reason. Clicks between the cards go through to what is underneath.
// Its width is capped by its HOST (100%), not the viewport: it sits in the content column, right
// of the sidebar, and a 100vw cap pushed it past the right edge of a phone (prod UAT 2026-09-19).
+ //
+ // Under the stack sits whatever is docked (lib/toastDock — the debug recorder's pill), in the top
+ // host only, and OUTSIDE the live region: a ticking timer inside it would be read out every second.
- {toast.action && (
-
+
+ {toast.duration && (
+ // Keyed by `ts`: a coalesced repeat re-arms the dismiss timer, so its bar restarts too.
+
)}
-
- {toast.duration && (
- // Keyed by `ts`: a coalesced repeat re-arms the dismiss timer, so its bar restarts too.
-
- )}
-
- );
- })}
+ );
+ })}
+
+ {isTop && Docked && (
+
+
+
+ )}
);
}
diff --git a/frontend/src/lib/toastDock.ts b/frontend/src/lib/toastDock.ts
new file mode 100644
index 00000000..35665d5a
--- /dev/null
+++ b/frontend/src/lib/toastDock.ts
@@ -0,0 +1,27 @@
+import type { ComponentType } from "react";
+import { createStore } from "./store";
+
+// A control that lives where the toasts live, under them (E16: the debug recorder's pill).
+//
+// The toast stack already solves the hard part of "always visible, never in the way": it has a host
+// in the page and one inside every overlay — the dialogs, both players, the fullscreen player — the
+// topmost host shows it, and inside an overlay it sits above the player's control band. A control
+// that must stay reachable while the user reproduces a bug anywhere needs exactly that, so it rides
+// the same hosts instead of rebuilding them: `ui/Toaster` renders whatever is docked here below its
+// stack, in the host that is on top.
+//
+// One slot, not a list: one surface uses it, and a second would have to decide how they share the
+// corner before this grows.
+const docked = createStore(null);
+
+/** Dock `component` under the toasts until the returned function undocks it. */
+export function dockUnderToasts(component: ComponentType): () => void {
+ // Always the updater form: a component IS a function, so `set(component)` would be taken for one
+ // and called with the previous value instead of stored.
+ docked.set(() => component);
+ return () =>
+ docked.set((current: ComponentType | null) => (current === component ? null : current));
+}
+
+/** What is docked (the Toaster reads it). */
+export const useToastDock = (): ComponentType | null => docked.use();