/** Shared shell/canvas notifications. Sonner owns layout, gestures and announcements; * our clock also pauses for app overlays, hidden queue entries and Save dialogs. */ import { type CSSProperties, type ReactNode, useEffect, useRef, useState, useSyncExternalStore, } from 'react'; import { Toaster, toast } from 'sonner'; export type NoticeKind = 'info' | 'success' | 'error' | 'warning' | 'undo'; export interface Notice { id?: string; title: string; description?: string; content?: ReactNode; kind?: NoticeKind; group?: string; duration?: number; /** Change only for a new lifecycle phase, never for a progress update. */ timerKey?: string; paused?: boolean; action?: { label: string; onClick: () => void }; onDismiss?: () => void; } type Entry = Notice & { id: string }; let entries: Entry[] = []; let sequence = 0; const listeners = new Set<() => void>(); const publish = () => { for (const listener of listeners) listener(); }; const subscribe = (listener: () => void) => { listeners.add(listener); return () => { listeners.delete(listener); }; }; const snapshot = () => entries; export function notify(notice: Notice): string { const id = notice.id ?? `notice-${++sequence}`; const existing = entries.findIndex((entry) => entry.id === id); if (existing < 0) entries = [...entries, { ...notice, id }]; else entries = entries.map((entry) => (entry.id === id ? { ...notice, id } : entry)); publish(); return id; } let canvasSlot = 0; let canvasWindowStart = -Infinity; let canvasWindowCount = 0; /** Untrusted canvas traffic gets two reusable slots, leaving room for shell notices. * Limit publications as well as retention; editing can emit dozens per second. */ export function notifyCanvasText(title: string, kind: NoticeKind = 'info'): string | null { const now = Date.now(); if (now < canvasWindowStart || now - canvasWindowStart >= 1000) { canvasWindowStart = now; canvasWindowCount = 0; } if (canvasWindowCount >= 5) return null; canvasWindowCount++; const previousUndo = kind === 'undo' ? entries.find((entry) => entry.group === 'canvas' && entry.kind === 'undo') : undefined; const id = previousUndo?.id ?? `canvas-notice-${canvasSlot++ % 2}`; return notify({ id, title: title.slice(0, 4000), kind, group: 'canvas', timerKey: `${++sequence}`, }); } /** Programmatic removal (unmount/navigation) does not acknowledge the notice. */ export function dismissNotice(id: string): void { entries = entries.filter((entry) => entry.id !== id); publish(); } function acknowledge(id: string) { const entry = entries.find((item) => item.id === id); dismissNotice(id); entry?.onDismiss?.(); } export function noticeDuration(entry: Notice): number { return ( entry.duration ?? (entry.action || ['error', 'warning', 'undo'].includes(entry.kind ?? '') ? 10_000 : 5_000) ); } /** Keep full diagnostics in their owning panel, including for screen readers. */ export function summarizeNoticeText(text: string): string { const compact = text.trim().replace(/\s+/g, ' '); return compact.length > 280 ? `${compact.slice(0, 279)}…` : compact; } function NoticeCard({ entry }: { entry: Entry }) { return (
{summarizeNoticeText(entry.title)}
{entry.description && (
{summarizeNoticeText(entry.description)}
)} {entry.content} {entry.action && ( )}
); } function MountedNotice({ entry, visible, paused, }: { entry: Entry; visible: boolean; paused: boolean; }) { const duration = noticeDuration(entry); const presentationId = useRef(null); const remaining = useRef(duration); // biome-ignore lint/correctness/useExhaustiveDependencies: a new lifecycle phase gets a fresh duration. useEffect(() => { remaining.current = duration; }, [duration, entry.timerKey]); // Keep the clock outside Sonner's card: hiding a group must preserve its time. // biome-ignore lint/correctness/useExhaustiveDependencies: restart the clock when its lifecycle phase changes. useEffect(() => { if (!visible || paused || entry.paused || duration === Infinity) return; const started = Date.now(); const timer = setTimeout(() => acknowledge(entry.id), Math.max(0, remaining.current)); return () => { clearTimeout(timer); remaining.current = Math.max(0, remaining.current - (Date.now() - started)); }; }, [visible, paused, entry.paused, duration, entry.timerKey, entry.id]); // Sonner invokes onDismiss even for programmatic removal. Invalidate the // presentation before hiding so opening history never acknowledges a job. useEffect(() => { if (visible) { const token = presentationId.current ?? `${entry.id}:view-${++sequence}`; presentationId.current = token; toast.custom(() => , { id: token, duration: Infinity, onDismiss: () => { if (presentationId.current === token) acknowledge(entry.id); }, }); } else if (presentationId.current) { const token = presentationId.current; presentationId.current = null; toast.dismiss(token); } }, [entry, visible]); useEffect( () => () => { const token = presentationId.current; presentationId.current = null; if (token) toast.dismiss(token); }, [] ); return null; } const NOTICE_CSS = ` .maude-notifications [data-sonner-toaster] { --width: min(360px, calc(100vw - 32px)); z-index: 8000; } .maude-notifications [data-sonner-toast] { width: var(--width); } .maude-notifications [data-sonner-toast][data-expanded=false][data-front=false] { overflow: hidden; border-radius: var(--radius-md, 8px); } .maude-notifications[data-paused=true] { visibility: hidden; pointer-events: none; } .maude-notice { box-sizing: border-box; position: relative; width: 100%; padding: 16px; border: 1px solid var(--border-default, #45454c); border-left: 3px solid var(--accent, #d63b1f); border-radius: var(--radius-md, 8px); background: var(--bg-2, #26262b); color: var(--fg-0, #fafafa); box-shadow: 0 8px 28px #0004; font: 13px/1.5 var(--font-sans, system-ui, sans-serif); display: flex; flex-direction: column; gap: 8px; overflow-wrap: anywhere; } .maude-notice[data-kind=error], .maude-notice[data-kind=warning] { border-left-color: var(--status-error, #d65b4d); } .maude-notice-title { font-weight: 600; padding-right: 20px; } .maude-notice-summary, .maude-notice-title, .maude-notice .st-export-degraded { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; overflow: hidden; overflow-wrap: anywhere; } .maude-notice-summary { color: var(--fg-1, #ddd); } .maude-notice-close { position: absolute; right: 8px; top: 8px; border: 0; border-radius: 4px; background: transparent; color: inherit; cursor: pointer; font-size: 18px; padding: 0 6px; } .maude-notice-action { align-self: flex-start; border: 0; background: transparent; color: inherit; font: inherit; text-decoration: underline; cursor: pointer; padding: 2px 0; } .maude-notice button:focus-visible { outline: 2px solid var(--accent, #d63b1f); outline-offset: 2px; } .maude-notice .st-toast-actions { flex-wrap: wrap; } @media (prefers-reduced-motion: reduce) { .maude-notifications [data-sonner-toast], .maude-notifications [data-sonner-toaster] { animation: none !important; transition: none !important; } }`; const NO_GROUPS: string[] = []; export function NotificationHost({ paused = false, hiddenGroups = NO_GROUPS, }: { paused?: boolean; hiddenGroups?: string[]; }) { const notices = useSyncExternalStore(subscribe, snapshot, snapshot); const hostRef = useRef(null); const [hovered, setHovered] = useState(false); const [focused, setFocused] = useState(false); const [hidden, setHidden] = useState(() => typeof document !== 'undefined' && document.hidden); useEffect(() => { const change = () => setHidden(document.hidden); document.addEventListener('visibilitychange', change); return () => document.removeEventListener('visibilitychange', change); }, []); useEffect(() => { if (!notices.length) { setHovered(false); setFocused(false); } // Removing a focused DOM node does not reliably emit blur in browsers. if (focused && !hostRef.current?.contains(document.activeElement)) setFocused(false); }, [notices, focused]); const visible = notices.filter((entry) => !hiddenGroups.includes(entry.group ?? '')); // Publish only the latest three; older entries keep their clocks while waiting. // This also keeps visibility and clocks aligned when a hidden group returns. const timedIds = new Set(visible.slice(-3).map((entry) => entry.id)); return (
setHovered(true)} onPointerLeave={() => setHovered(false)} onFocusCapture={() => setFocused(true)} onBlurCapture={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setFocused(false); }} > {notices.map((entry) => (
); }