From 2d9595b0e53b2321c02989d6deca4d6a6dd3f9b3 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 19 Sep 2026 22:08:10 +0200 Subject: [PATCH] feat(ui): a control can dock under the toasts and ride every toast host The toast stack already has a host in the page and in every overlay (dialogs, both players, the fullscreen player), the topmost one shows it, and inside an overlay it sits above the player's control band. lib/toastDock lets one control use exactly that placement; it renders outside the live region, so a ticking control is not read out every second. --- .../src/components/ui/Toaster.dom.test.tsx | 4 +- frontend/src/components/ui/Toaster.tsx | 123 ++++++++++-------- frontend/src/lib/toastDock.ts | 27 ++++ 3 files changed, 101 insertions(+), 53 deletions(-) create mode 100644 frontend/src/lib/toastDock.ts 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();