+ )
+}
+
+function Card({ item }: { item: AppNotification }) {
+ const [hovered, setHovered] = useState(false)
+
+ useEffect(() => {
+ // Hovering holds it: somebody is reading it, or about to press one of its
+ // buttons. Leaving starts the full countdown again rather than resuming a
+ // second of it.
+ if (item.persistent || hovered) return
+ const timer = setTimeout(() => dismiss(item.id), AUTO_HIDE_MS)
+ return () => clearTimeout(timer)
+ }, [item.id, item.persistent, hovered])
+
+ const { icon: Icon, accent } = SEVERITY[item.severity]
+
+ return (
+ setHovered(true)}
+ onMouseLeave={() => setHovered(false)}
+ initial={{ opacity: 0, y: -16 }}
+ animate={{ opacity: 1, y: 0, transition: transitions.emphasized }}
+ exit={{ opacity: 0, y: -16, transition: exitTransition }}
+ className="pointer-events-auto flex w-[min(28rem,calc(100vw-2rem))] flex-col gap-2 rounded-lg border border-border bg-card/80 p-3 shadow-e2 backdrop-blur-md"
+ >
+
+
+ {/* Wraps rather than truncates: a message worth interrupting for is
+ worth reading whole. */}
+
{item.message}
+
+
+ {/* Under the message rather than beside it, so the text keeps the whole
+ card width however long the labels are. */}
+ {item.actions?.length ? (
+
+ {item.actions.map((action) => (
+
+ ))}
+
+ ) : null}
+
+ )
+}
diff --git a/frontend/src/components/ui/sonner.tsx b/frontend/src/components/ui/sonner.tsx
deleted file mode 100644
index 9b20afe..0000000
--- a/frontend/src/components/ui/sonner.tsx
+++ /dev/null
@@ -1,40 +0,0 @@
-"use client"
-
-import {
- CircleCheckIcon,
- InfoIcon,
- Loader2Icon,
- OctagonXIcon,
- TriangleAlertIcon,
-} from "lucide-react"
-import { useTheme } from "next-themes"
-import { Toaster as Sonner, type ToasterProps } from "sonner"
-
-const Toaster = ({ ...props }: ToasterProps) => {
- const { theme = "system" } = useTheme()
-
- return (
- ,
- info: ,
- warning: ,
- error: ,
- loading: ,
- }}
- style={
- {
- "--normal-bg": "var(--popover)",
- "--normal-text": "var(--popover-foreground)",
- "--normal-border": "var(--border)",
- "--border-radius": "var(--radius)",
- } as React.CSSProperties
- }
- {...props}
- />
- )
-}
-
-export { Toaster }
diff --git a/frontend/src/hooks/useCustomToast.ts b/frontend/src/hooks/useCustomToast.ts
index ab03265..aeac002 100644
--- a/frontend/src/hooks/useCustomToast.ts
+++ b/frontend/src/hooks/useCustomToast.ts
@@ -1,16 +1,20 @@
-import { toast } from "sonner"
+import { notify } from "@/lib/notificationStore"
+/**
+ * The two things most call sites have to say.
+ *
+ * A thin front on `notify` — the description is the whole message now, since a
+ * card that leads with "Success!" spends its first line saying nothing. Kept as
+ * a hook because that is how it is called everywhere; anything needing a
+ * severity, a button or a persistent card calls `notify` directly.
+ */
const useCustomToast = () => {
const showSuccessToast = (description: string) => {
- toast.success("Success!", {
- description,
- })
+ notify(description, "success")
}
const showErrorToast = (description: string) => {
- toast.error("Something went wrong!", {
- description,
- })
+ notify(description, "error")
}
return { showSuccessToast, showErrorToast }
diff --git a/frontend/src/lib/notificationStore.ts b/frontend/src/lib/notificationStore.ts
new file mode 100644
index 0000000..007d12a
--- /dev/null
+++ b/frontend/src/lib/notificationStore.ts
@@ -0,0 +1,109 @@
+/**
+ * What the app has to say to whoever is looking at it.
+ *
+ * One stack, top centre, for both shells. Anything can call `notify(...)`; the
+ * component that draws them subscribes. Kept out of React so a query's error
+ * handler and a socket callback can raise one without a hook.
+ *
+ * Same hand-rolled external store as `connectionStore`, for the same reason: a
+ * `useSyncExternalStore` snapshot is the whole requirement.
+ *
+ * Byte-identical with the portal's copy — see DESIGN-GUIDELINES.md § Sync rule.
+ */
+
+export type NotificationSeverity = "error" | "warning" | "info" | "success"
+
+export interface NotificationAction {
+ label: string
+ onClick: () => void
+ variant?: "destructive"
+}
+
+export interface NotifyOptions {
+ /** Naming one that already exists replaces it, rather than stacking a second. */
+ id?: string
+ /** Stays until it is dismissed. Otherwise it goes on its own. */
+ persistent?: boolean
+ actions?: NotificationAction[]
+}
+
+export interface AppNotification {
+ id: string
+ message: string
+ severity: NotificationSeverity
+ persistent?: boolean
+ actions?: NotificationAction[]
+}
+
+/**
+ * How many transient cards are kept.
+ *
+ * A storm of failures says the same thing five times over; past that the stack
+ * is taller than the screen and the newest — the one being read — is the one
+ * pushed off it.
+ */
+const MAX_TRANSIENT = 5
+
+let state: AppNotification[] = []
+let counter = 0
+const listeners = new Set<() => void>()
+
+function emit(next: AppNotification[]) {
+ state = next
+ for (const listener of listeners) listener()
+}
+
+/**
+ * Raise one. Returns its id, so a caller that wants to update or take it back
+ * later can hold on to it.
+ */
+export function notify(
+ message: string,
+ severity: NotificationSeverity = "info",
+ options: NotifyOptions = {},
+): string {
+ const id = options.id ?? `n${++counter}`
+ const entry: AppNotification = {
+ id,
+ message,
+ severity,
+ persistent: options.persistent,
+ actions: options.actions,
+ }
+
+ const existing = state.findIndex((item) => item.id === id)
+ if (existing >= 0) {
+ // In place, so a card that keeps being told the same thing — an offline
+ // notice counting up — does not re-enter and restart its own timer.
+ const next = [...state]
+ next[existing] = entry
+ emit(next)
+ return id
+ }
+
+ const appended = [...state, entry]
+ // Persistent ones are somebody's to take back, so the trim only reaches the
+ // rest — a stack of five errors cannot push out the offline notice.
+ const transient = appended.filter((item) => !item.persistent)
+ const dropped = new Set(
+ transient.slice(0, Math.max(0, transient.length - MAX_TRANSIENT)),
+ )
+ emit(dropped.size ? appended.filter((item) => !dropped.has(item)) : appended)
+ return id
+}
+
+/** Take one back. Unknown ids are a no-op: it may have expired already. */
+export function dismiss(id: string): void {
+ if (!state.some((item) => item.id === id)) return
+ emit(state.filter((item) => item.id !== id))
+}
+
+export const notificationStore = {
+ subscribe(listener: () => void) {
+ listeners.add(listener)
+ return () => listeners.delete(listener)
+ },
+ snapshot(): AppNotification[] {
+ return state
+ },
+}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index 1e5ddf9..b3a52a1 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -8,13 +8,13 @@ import { createRouter, RouterProvider } from "@tanstack/react-router"
import { MotionConfig } from "motion/react"
import { StrictMode } from "react"
import ReactDOM from "react-dom/client"
-import { toast } from "sonner"
import { ApiError, OpenAPI } from "./client"
import { PageLoading } from "./components/Common/Loading"
+import { Notifications } from "./components/Common/Notifications"
import { ThemeProvider } from "./components/theme-provider"
-import { Toaster } from "./components/ui/sonner"
import "./index.css"
import { connectionStore, offlineDetail } from "./lib/connectionStore"
+import { notify } from "./lib/notificationStore"
import { apiToken, appPath, appRoute, portalConfig } from "./lib/portal"
import { routeTree } from "./routeTree.gen"
@@ -74,10 +74,10 @@ const handleApiError = (error: Error) => {
}
if (isForbidden(error) && !appRoute().startsWith("/panel")) {
// The session stands, so stay put — but a refused change that says nothing
- // reads as a broken button. On a panel there is nobody to read a toast: a
+ // reads as a broken button. On a panel there is nobody there to read it: a
// 403 there is a dashboard it was just unassigned from, which its next read
// of the panel corrects on its own.
- toast.error("You do not have permission to do that")
+ notify("You do not have permission to do that", "error")
}
}
@@ -121,7 +121,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
-
+