Replace sonner toasts with one top-centre notification stack

The app said things in two places: sonner's bottom-right toasts and the
custom top-centre chrome. This is the second of those, generalised — a
module-level store anyone can call, drawn as a stack of frosted cards that go
on their own unless raised as persistent, with room for buttons and for a
message longer than a line.

useCustomToast keeps its signatures, so the forty-odd call sites behind it and
handleError are untouched. The component and its store are byte-identical with
the portal's copy and checked by `make design-check`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-27 16:00:47 +02:00
co-authored by Claude Opus 5
parent 121cb2e8f0
commit 44c8825927
7 changed files with 249 additions and 60 deletions
-6
View File
@@ -42,7 +42,6 @@
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"monaco-editor": "^0.56.0", "monaco-editor": "^0.56.0",
"motion": "^13.0.0", "motion": "^13.0.0",
"next-themes": "^0.4.6",
"radix-ui": "^1.6.7", "radix-ui": "^1.6.7",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.2.3", "react-dom": "^19.2.3",
@@ -50,7 +49,6 @@
"react-grid-layout": "^2.2.4", "react-grid-layout": "^2.2.4",
"react-hook-form": "^7.68.0", "react-hook-form": "^7.68.0",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.17", "tailwindcss": "^4.1.17",
"uplot": "^1.6.32", "uplot": "^1.6.32",
@@ -787,8 +785,6 @@
"neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="], "neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="],
"next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="],
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="], "node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
"node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
@@ -873,8 +869,6 @@
"seroval-plugins": ["seroval-plugins@1.4.2", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-X7p4MEDTi+60o2sXZ4bnDBhgsUYDSkQEvzYZuJyFqWg9jcoPsHts5nrg5O956py2wyt28lUrBxk0M0/wU8URpA=="], "seroval-plugins": ["seroval-plugins@1.4.2", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-X7p4MEDTi+60o2sXZ4bnDBhgsUYDSkQEvzYZuJyFqWg9jcoPsHts5nrg5O956py2wyt28lUrBxk0M0/wU8URpA=="],
"sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="],
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
-2
View File
@@ -48,7 +48,6 @@
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"monaco-editor": "^0.56.0", "monaco-editor": "^0.56.0",
"motion": "^13.0.0", "motion": "^13.0.0",
"next-themes": "^0.4.6",
"radix-ui": "^1.6.7", "radix-ui": "^1.6.7",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.2.3", "react-dom": "^19.2.3",
@@ -56,7 +55,6 @@
"react-grid-layout": "^2.2.4", "react-grid-layout": "^2.2.4",
"react-hook-form": "^7.68.0", "react-hook-form": "^7.68.0",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.17", "tailwindcss": "^4.1.17",
"uplot": "^1.6.32", "uplot": "^1.6.32",
@@ -0,0 +1,124 @@
/**
* The one place the app says something back.
*
* A single stack at the top centre, over whatever is below it. Newest on top,
* each card going on its own after a few seconds unless it was raised as
* persistent — an offline notice stays until the installation answers again.
*
* Raised from anywhere through `notify()` in `lib/notificationStore`, which is
* where the semantics live; this only draws them.
*
* Byte-identical with the portal's copy — see DESIGN-GUIDELINES.md § Sync rule.
*/
import { CircleAlert, CircleCheck, Info, TriangleAlert, X } from "lucide-react"
import { AnimatePresence, motion } from "motion/react"
import { useEffect, useState, useSyncExternalStore } from "react"
import { Button } from "@/components/ui/button"
import { duration, easeStandard, transitions } from "@/lib/motion"
import {
type AppNotification,
dismiss,
type NotificationSeverity,
notificationStore,
} from "@/lib/notificationStore"
import { cn } from "@/lib/utils"
/** Long enough to read a sentence, short enough not to sit in the way. */
const AUTO_HIDE_MS = 5_000
const SEVERITY: Record<
NotificationSeverity,
{ icon: typeof Info; accent: string }
> = {
error: { icon: CircleAlert, accent: "text-destructive" },
warning: { icon: TriangleAlert, accent: "text-primary" },
success: { icon: CircleCheck, accent: "text-status-success" },
info: { icon: Info, accent: "text-muted-foreground" },
}
const exitTransition = { duration: duration.fast, ease: easeStandard }
export function Notifications() {
const items = useSyncExternalStore(
notificationStore.subscribe,
notificationStore.snapshot,
// The portal prerenders its shell, where there is no store to read yet.
notificationStore.snapshot,
)
return (
// Over dialogs, which sit at z-50: a notification is often what explains
// why the dialog did nothing. Not interactive except on the cards, so the
// page underneath stays clickable through the empty column.
<div className="pointer-events-none fixed inset-x-0 top-4 z-[100] flex flex-col items-center gap-2 px-4">
<AnimatePresence initial={false}>
{[...items].reverse().map((item) => (
<Card key={item.id} item={item} />
))}
</AnimatePresence>
</div>
)
}
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 (
<motion.div
layout
role="alert"
onMouseEnter={() => 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"
>
<div className="flex items-start gap-3">
<Icon className={cn("mt-0.5 size-5 shrink-0", accent)} />
{/* Wraps rather than truncates: a message worth interrupting for is
worth reading whole. */}
<div className="min-w-0 flex-1 text-sm">{item.message}</div>
<Button
variant="ghost"
size="icon-sm"
aria-label="Dismiss"
className="-mr-1 -mt-1 shrink-0"
onClick={() => dismiss(item.id)}
>
<X className="size-4 text-muted-foreground" />
</Button>
</div>
{/* Under the message rather than beside it, so the text keeps the whole
card width however long the labels are. */}
{item.actions?.length ? (
<div className="flex flex-wrap justify-end gap-2">
{item.actions.map((action) => (
<Button
key={action.label}
variant={action.variant ?? "outline"}
size="sm"
className="h-auto max-w-full whitespace-normal py-1.5 text-left"
onClick={() => action.onClick()}
>
{action.label}
</Button>
))}
</div>
) : null}
</motion.div>
)
}
-40
View File
@@ -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 (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }
+11 -7
View File
@@ -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 useCustomToast = () => {
const showSuccessToast = (description: string) => { const showSuccessToast = (description: string) => {
toast.success("Success!", { notify(description, "success")
description,
})
} }
const showErrorToast = (description: string) => { const showErrorToast = (description: string) => {
toast.error("Something went wrong!", { notify(description, "error")
description,
})
} }
return { showSuccessToast, showErrorToast } return { showSuccessToast, showErrorToast }
+109
View File
@@ -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
},
}
+5 -5
View File
@@ -8,13 +8,13 @@ import { createRouter, RouterProvider } from "@tanstack/react-router"
import { MotionConfig } from "motion/react" import { MotionConfig } from "motion/react"
import { StrictMode } from "react" import { StrictMode } from "react"
import ReactDOM from "react-dom/client" import ReactDOM from "react-dom/client"
import { toast } from "sonner"
import { ApiError, OpenAPI } from "./client" import { ApiError, OpenAPI } from "./client"
import { PageLoading } from "./components/Common/Loading" import { PageLoading } from "./components/Common/Loading"
import { Notifications } from "./components/Common/Notifications"
import { ThemeProvider } from "./components/theme-provider" import { ThemeProvider } from "./components/theme-provider"
import { Toaster } from "./components/ui/sonner"
import "./index.css" import "./index.css"
import { connectionStore, offlineDetail } from "./lib/connectionStore" import { connectionStore, offlineDetail } from "./lib/connectionStore"
import { notify } from "./lib/notificationStore"
import { apiToken, appPath, appRoute, portalConfig } from "./lib/portal" import { apiToken, appPath, appRoute, portalConfig } from "./lib/portal"
import { routeTree } from "./routeTree.gen" import { routeTree } from "./routeTree.gen"
@@ -74,10 +74,10 @@ const handleApiError = (error: Error) => {
} }
if (isForbidden(error) && !appRoute().startsWith("/panel")) { if (isForbidden(error) && !appRoute().startsWith("/panel")) {
// The session stands, so stay put — but a refused change that says nothing // 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 // 403 there is a dashboard it was just unassigned from, which its next read
// of the panel corrects on its own. // 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(
<MotionConfig reducedMotion="user"> <MotionConfig reducedMotion="user">
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<RouterProvider router={router} /> <RouterProvider router={router} />
<Toaster richColors closeButton /> <Notifications />
</QueryClientProvider> </QueryClientProvider>
</MotionConfig> </MotionConfig>
</ThemeProvider> </ThemeProvider>