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.
e.stopPropagation() : undefined} > - {toasts.map((toast) => { - const { icon: Icon, color, bar } = LEVEL_STYLE[toast.level]; - const urgent = toast.level === "error" || toast.level === "fatal"; - return ( -
- -
- {toast.title &&
{toast.title}
} -
- {toast.message} - {toast.repeat > 1 && ( - - ×{toast.repeat} - +
+ {toasts.map((toast) => { + const { icon: Icon, color, bar } = LEVEL_STYLE[toast.level]; + const urgent = toast.level === "error" || toast.level === "fatal"; + return ( +
+ +
+ {toast.title &&
{toast.title}
} +
+ {toast.message} + {toast.repeat > 1 && ( + + ×{toast.repeat} + + )} +
+ {toast.action && ( + )}
- {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();