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
@@ -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 }