From 3839f92f1db9dde5dfd9b33e9df8c2da91a6e82e Mon Sep 17 00:00:00 2001 From: Oleh Omelchenko Date: Fri, 5 Jun 2026 11:19:50 +0300 Subject: [PATCH] Add notification toasts and surface persistence failures instead of failing silently --- src/app/App.tsx | 5 + src/app/components/Toaster.module.css | 134 ++++++++++++++++++++++ src/app/components/Toaster.tsx | 89 ++++++++++++++ src/app/orchestration/persistence.test.ts | 128 +++++++++++++++++++++ src/app/orchestration/persistence.ts | 14 ++- src/app/orchestration/startup.ts | 28 ++++- src/app/services/storage-errors.test.ts | 45 ++++++++ src/app/services/storage-errors.ts | 75 ++++++++++++ src/app/stores/NotificationStore.test.ts | 47 ++++++++ src/app/stores/NotificationStore.ts | 84 ++++++++++++++ 10 files changed, 644 insertions(+), 5 deletions(-) create mode 100644 src/app/components/Toaster.module.css create mode 100644 src/app/components/Toaster.tsx create mode 100644 src/app/orchestration/persistence.test.ts create mode 100644 src/app/services/storage-errors.test.ts create mode 100644 src/app/services/storage-errors.ts create mode 100644 src/app/stores/NotificationStore.test.ts create mode 100644 src/app/stores/NotificationStore.ts diff --git a/src/app/App.tsx b/src/app/App.tsx index 59a8b4b..7eae827 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -4,6 +4,7 @@ import { ResizeHandle } from './components/ResizeHandle'; import { SnippetLibrary } from './components/SnippetLibrary'; import { SpecEditor } from './components/SpecEditor'; import { ThemeToggle } from './components/ThemeToggle'; +import { Toaster } from './components/Toaster'; import { usePanesStore } from './stores/PanesStore'; import styles from './App.module.css'; @@ -50,6 +51,10 @@ export function App() { {/* Global confirmation layer — sits above the (future) feature-modal shell so a discard-changes prompt can appear over an open modal. */} + + {/* Non-blocking notifications (failed saves, etc.) — top-right toasts, + layered above the confirm backdrop so a failure stays visible. */} + ); } diff --git a/src/app/components/Toaster.module.css b/src/app/components/Toaster.module.css new file mode 100644 index 0000000..119013d --- /dev/null +++ b/src/app/components/Toaster.module.css @@ -0,0 +1,134 @@ +.region { + position: fixed; + top: var(--space-5); + right: var(--space-5); + /* Above the confirm backdrop (z 1000) so a failure stays visible and + dismissible even with a confirmation open; top-right won't block the + centered dialog. */ + z-index: 1100; + display: flex; + flex-direction: column; + gap: var(--space-3); + width: min(24rem, calc(100vw - 2 * var(--space-5))); + /* The region is only a positioner; individual toasts re-enable pointer events. */ + pointer-events: none; +} + +.toast { + pointer-events: auto; + display: flex; + align-items: flex-start; + gap: var(--space-3); + padding: var(--space-4); + background: var(--layer-02); + /* Design language → Toasts: 1px border in the support colour, square. The + accent (per-kind) is set by the modifier classes below. */ + border: var(--border-width) solid var(--toast-accent); + border-left-width: 3px; + border-radius: var(--radius); + box-shadow: 0 2px 12px rgb(0 0 0 / 0.3); + animation: toast-in var(--dur-moderate) var(--ease); +} + +.error { + --toast-accent: var(--support-error); +} +.warning { + --toast-accent: var(--support-warning); +} +.success { + --toast-accent: var(--support-success); +} +.info { + --toast-accent: var(--support-info); +} + +@keyframes toast-in { + from { + opacity: 0; + transform: translateX(8px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +.body { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.title { + margin: 0; + font-size: 14px; + font-weight: 600; + color: var(--text); +} + +.message { + margin: 0; + font-size: 13px; + line-height: 1.4; + color: var(--text-secondary); +} + +.detail { + margin-top: var(--space-1); +} + +.detailSummary { + font-size: 12px; + color: var(--text-secondary); + cursor: pointer; + user-select: none; +} + +.detailText { + margin: var(--space-2) 0 0; + padding: var(--space-3); + font-family: 'IBM Plex Mono', ui-monospace, 'SF Mono', Menlo, monospace; + font-size: 12px; + line-height: 1.4; + color: var(--text); + background: var(--layer-01); + border: var(--border-width) solid var(--border); + white-space: pre-wrap; + word-break: break-word; +} + +.detailMeta { + margin: var(--space-2) 0 0; + font-size: 11px; + color: var(--text-secondary); +} + +.close { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + border: none; + border-radius: var(--radius); + background: transparent; + color: var(--text-secondary); + font-size: 12px; + cursor: pointer; + transition: background var(--dur-fast) var(--ease); +} + +.close:hover { + background: var(--layer-01); + color: var(--text); +} + +.close:focus-visible { + outline: 2px solid var(--focus); + outline-offset: 2px; +} diff --git a/src/app/components/Toaster.tsx b/src/app/components/Toaster.tsx new file mode 100644 index 0000000..341c642 --- /dev/null +++ b/src/app/components/Toaster.tsx @@ -0,0 +1,89 @@ +/** + * Toaster — renders the NotificationStore as a stack of toasts (spec §10, + * design language → Toasts). Mounted once at the app root, beside ConfirmDialog. + * + * The non-blocking counterpart to ConfirmDialog: top-right, stacked newest-last, + * each dismissible. Error/warning toasts persist until dismissed (Carbon: a + * critical message shouldn't vanish on a timer); success/info auto-dismiss. A + * failure that carries diagnostic `detail` exposes it under a collapsed + * "Technical details" disclosure — available to report, without shouting. + */ + +import { useEffect } from 'react'; +import { + useNotificationStore, + type Notification, + type NotificationKind, +} from '../stores/NotificationStore'; +import styles from './Toaster.module.css'; + +/** Auto-dismiss delay for the non-critical kinds (ms). Errors/warnings persist. */ +const AUTO_DISMISS_MS = 6000; + +/** Kinds that fade on their own; errors and warnings wait for the user. */ +const AUTO_DISMISS: Record = { + success: true, + info: true, + warning: false, + error: false, +}; + +const KIND_CLASS: Record = { + error: styles.error, + warning: styles.warning, + success: styles.success, + info: styles.info, +}; + +function Toast({ notification }: { notification: Notification }) { + const dismiss = useNotificationStore((s) => s.dismiss); + const { id, kind, title, message, detail } = notification; + + // Positive/informational toasts clear themselves after a beat; the close + // button still works for an early dismissal. + useEffect(() => { + if (!AUTO_DISMISS[kind]) return; + const timer = setTimeout(() => dismiss(id), AUTO_DISMISS_MS); + return () => clearTimeout(timer); + }, [id, kind, dismiss]); + + return ( +
+
+

{title}

+

{message}

+ {detail !== undefined && ( +
+ Technical details +
{detail}
+

Astrolabe v{__APP_VERSION__}

+
+ )} +
+ +
+ ); +} + +export function Toaster() { + const notifications = useNotificationStore((s) => s.notifications); + if (notifications.length === 0) return null; + return ( +
+ {notifications.map((n) => ( + + ))} +
+ ); +} diff --git a/src/app/orchestration/persistence.test.ts b/src/app/orchestration/persistence.test.ts new file mode 100644 index 0000000..1018edc --- /dev/null +++ b/src/app/orchestration/persistence.test.ts @@ -0,0 +1,128 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createSnippet, type Snippet } from '@core/snippet'; + +/** + * The persistence subscribers are the subtlest glue in the app: a debounced + * auto-save and a reference-diff write-through that decides what to upsert vs. + * delete. They run against a mocked IndexedDB adapter so we assert the *wiring* + * (when each adapter call fires, with what, and that a rejection surfaces) with + * no real database. + */ + +// Mock the adapter, but keep the real StorageQuotaError so the error-mapping +// path (instanceof check) behaves exactly as in production. +vi.mock('../infrastructure/snippet-store', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, saveSnippet: vi.fn(), deleteSnippet: vi.fn() }; +}); + +import { saveSnippet, deleteSnippet, StorageQuotaError } from '../infrastructure/snippet-store'; +import { useNotificationStore } from '../stores/NotificationStore'; +import { useSnippetStore } from '../stores/SnippetStore'; +import { AUTOSAVE_DEBOUNCE_MS, wirePersistence } from './persistence'; + +const save = vi.mocked(saveSnippet); +const del = vi.mocked(deleteSnippet); + +let teardown: () => void = () => {}; + +/** Flush pending microtasks (e.g. a `.catch` on a rejected adapter promise). */ +const flush = () => Promise.resolve().then(() => Promise.resolve()); + +function snippetWith(id: string, spec: string): Snippet { + return createSnippet({ id, spec }); +} + +beforeEach(() => { + vi.useFakeTimers(); + save.mockReset().mockResolvedValue(undefined); + del.mockReset().mockResolvedValue(undefined); + useNotificationStore.getState().clear(); + useSnippetStore.getState().reset(); +}); + +afterEach(() => { + teardown(); + teardown = () => {}; + vi.clearAllTimers(); + vi.useRealTimers(); +}); + +describe('wireDraftAutoSave', () => { + it('persists a settled, valid edit after the debounce', async () => { + useSnippetStore.getState().hydrate([snippetWith('a', '{"a":1}')]); + teardown = wirePersistence(); + save.mockClear(); + + useSnippetStore.getState().updateDraft('{"a":2}'); + expect(save).not.toHaveBeenCalled(); // nothing yet — waiting for the pause + + await vi.advanceTimersByTimeAsync(AUTOSAVE_DEBOUNCE_MS); + + expect(save).toHaveBeenCalledTimes(1); + expect(save.mock.calls[0][0]).toMatchObject({ id: 'a', draftSpec: '{"a":2}' }); + }); + + it('does not persist a half-typed, unparseable buffer', async () => { + useSnippetStore.getState().hydrate([snippetWith('a', '{"a":1}')]); + teardown = wirePersistence(); + save.mockClear(); + + useSnippetStore.getState().updateDraft('{ "a":'); + await vi.advanceTimersByTimeAsync(AUTOSAVE_DEBOUNCE_MS); + + expect(save).not.toHaveBeenCalled(); + }); +}); + +describe('wireWriteThrough', () => { + it('persists a newly created snippet', () => { + useSnippetStore.getState().hydrate([]); + teardown = wirePersistence(); + save.mockClear(); + + useSnippetStore.getState().createSnippet({ id: 'n', spec: '{}' }); + + expect(save).toHaveBeenCalledTimes(1); + expect(save.mock.calls[0][0]).toMatchObject({ id: 'n' }); + expect(del).not.toHaveBeenCalled(); + }); + + it('deletes a removed snippet without re-saving the survivors', () => { + useSnippetStore.getState().hydrate([snippetWith('a', '{}'), snippetWith('b', '{}')]); + teardown = wirePersistence(); + save.mockClear(); + + useSnippetStore.getState().removeSnippet('a'); + + expect(del).toHaveBeenCalledTimes(1); + expect(del).toHaveBeenCalledWith('a'); + // 'b' kept its object reference through the filter, so it is not rewritten. + expect(save).not.toHaveBeenCalled(); + }); + + it('ignores state changes that do not touch the snippets array', () => { + useSnippetStore.getState().hydrate([snippetWith('a', '{}')]); + teardown = wirePersistence(); + save.mockClear(); + + useSnippetStore.getState().setEditorView('published'); + + expect(save).not.toHaveBeenCalled(); + expect(del).not.toHaveBeenCalled(); + }); + + it('surfaces a failed save as an error notification instead of losing it silently', async () => { + useSnippetStore.getState().hydrate([]); + teardown = wirePersistence(); + save.mockClear(); + save.mockRejectedValueOnce(new StorageQuotaError()); + + useSnippetStore.getState().createSnippet({ id: 'n', spec: '{}' }); + await flush(); + + const notes = useNotificationStore.getState().notifications; + expect(notes).toHaveLength(1); + expect(notes[0]).toMatchObject({ kind: 'error', title: 'Storage full' }); + }); +}); diff --git a/src/app/orchestration/persistence.ts b/src/app/orchestration/persistence.ts index f21a976..c5b077d 100644 --- a/src/app/orchestration/persistence.ts +++ b/src/app/orchestration/persistence.ts @@ -14,6 +14,8 @@ */ import { deleteSnippet, saveSnippet } from '../infrastructure/snippet-store'; +import { storageErrorNotification } from '../services/storage-errors'; +import { notify } from '../stores/NotificationStore'; import { useSnippetStore } from '../stores/SnippetStore'; /** Delay before a settled edit is committed to the stored draft (spec §03B). */ @@ -40,12 +42,20 @@ function wireWriteThrough(): Unsubscribe { const prev = prevSnippets; prevSnippets = next; + // A failed write must surface, not vanish — these promises are otherwise + // fire-and-forget, so a rejection becomes a toast rather than silent loss + // (spec §10 → "told when a save fails rather than losing data silently"). for (const old of prev) { - if (!next.some((n) => n.id === old.id)) void deleteSnippet(old.id); + if (!next.some((n) => n.id === old.id)) { + deleteSnippet(old.id).catch((err) => notify(storageErrorNotification('delete', err))); + } } for (const n of next) { const old = prev.find((p) => p.id === n.id); - if (old !== n) void saveSnippet(n); // new, or a changed reference + if (old !== n) { + // new, or a changed reference + saveSnippet(n).catch((err) => notify(storageErrorNotification('save', err))); + } } }); } diff --git a/src/app/orchestration/startup.ts b/src/app/orchestration/startup.ts index 81ac72a..e6a301a 100644 --- a/src/app/orchestration/startup.ts +++ b/src/app/orchestration/startup.ts @@ -7,8 +7,10 @@ * the UI renders immediately and fills in when hydration completes. */ -import { createSnippet } from '@core/snippet'; +import { createSnippet, type Snippet } from '@core/snippet'; import { loadSnippets, saveSnippet } from '../infrastructure/snippet-store'; +import { storageErrorNotification } from '../services/storage-errors'; +import { notify } from '../stores/NotificationStore'; import { useSnippetStore } from '../stores/SnippetStore'; import { wirePersistence } from './persistence'; @@ -18,11 +20,31 @@ export async function initApp(): Promise { if (started) return; // idempotent — guard against double-invocation (StrictMode) started = true; - let snippets = await loadSnippets(); + // If storage can't be opened (private-browsing, blocked storage), don't fail + // startup silently: warn, then run this session in memory so the app is still + // usable. `storageOk` gates the seed-save below — there's no point trying to + // persist a seed into storage we just failed to read. + let snippets: Snippet[] = []; + let storageOk = true; + try { + snippets = await loadSnippets(); + } catch (err) { + storageOk = false; + notify(storageErrorNotification('load', err)); + } + if (snippets.length === 0) { const sample = createSnippet(); - await saveSnippet(sample); // ensure the seed survives even before the first edit snippets = [sample]; + if (storageOk) { + // Ensure the seed survives even before the first edit; a failure here is + // surfaced, not swallowed. + try { + await saveSnippet(sample); + } catch (err) { + notify(storageErrorNotification('save', err)); + } + } } useSnippetStore.getState().hydrate(snippets); diff --git a/src/app/services/storage-errors.test.ts b/src/app/services/storage-errors.test.ts new file mode 100644 index 0000000..6a20f15 --- /dev/null +++ b/src/app/services/storage-errors.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { StorageQuotaError } from '../infrastructure/snippet-store'; +import { storageErrorNotification } from './storage-errors'; + +describe('storageErrorNotification', () => { + describe('a failure the user can fix (storage full)', () => { + it('tells them the step to take and carries no diagnostic detail', () => { + const n = storageErrorNotification('save', new StorageQuotaError()); + expect(n.kind).toBe('error'); + expect(n.title).toBe('Storage full'); + expect(n.message.toLowerCase()).toContain('delete'); + // Nothing to report — the fix is in the user's hands. + expect(n.detail).toBeUndefined(); + }); + }); + + describe('a failure the user cannot fix', () => { + it('explains a load failure and attaches a reportable diagnostic', () => { + const n = storageErrorNotification('load', new DOMException('blocked', 'SecurityError')); + expect(n.kind).toBe('error'); + expect(n.title).toBe("Couldn't open your library"); + expect(n.detail).toBeDefined(); + // The diagnostic names the operation and the underlying error. + expect(n.detail).toContain('load'); + expect(n.detail).toContain('SecurityError'); + expect(n.detail).toContain('blocked'); + }); + + it('uses the right title for a failed save vs delete and includes detail', () => { + const save = storageErrorNotification('save', new Error('boom')); + expect(save.title).toBe("Couldn't save your changes"); + expect(save.detail).toContain('save failed'); + expect(save.detail).toContain('boom'); + + const del = storageErrorNotification('delete', new Error('boom')); + expect(del.title).toBe("Couldn't delete the snippet"); + expect(del.detail).toContain('delete failed'); + }); + + it('describes a non-Error throw without crashing', () => { + const n = storageErrorNotification('save', 'weird string'); + expect(n.detail).toContain('weird string'); + }); + }); +}); diff --git a/src/app/services/storage-errors.ts b/src/app/services/storage-errors.ts new file mode 100644 index 0000000..3792af0 --- /dev/null +++ b/src/app/services/storage-errors.ts @@ -0,0 +1,75 @@ +/** + * Translate a persistence failure into a user-facing notification (spec §10 → + * "the user is told when a save fails rather than losing data silently"). + * + * Pure and framework-free so the wording is unit-tested directly. The mapping + * encodes the product's actionability rule (Carbon: a user action is mandatory + * on an error message — but only the user's *own* action belongs in the copy): + * + * - A failure the user CAN resolve (storage full) → tell them the step to + * take, and carry no `detail` (there is nothing for them to report). + * - A failure the user CANNOT resolve (storage blocked, an unexpected DB + * error) → explain it plainly AND attach diagnostic `detail` (the operation + * that failed + the underlying error) so it can be reported and traced. + */ + +import { StorageQuotaError } from '../infrastructure/snippet-store'; +import type { NotifyOptions } from '../stores/NotificationStore'; + +/** Which persistence operation failed — shapes both the wording and the trace. */ +export type StorageOp = 'load' | 'save' | 'delete'; + +/** Compact, reportable description of an unexpected error for the `detail` field. */ +function diagnostic(op: StorageOp, err: unknown): string { + const base = `Storage ${op} failed`; + if (err instanceof Error) { + // Include the error name when it adds signal (DOMException subtypes, etc.). + return err.name && err.name !== 'Error' + ? `${base}: ${err.name}: ${err.message}` + : `${base}: ${err.message}`; + } + return `${base}: ${String(err)}`; +} + +/** + * Map a thrown persistence error to the notification to show for it. `op` is the + * operation that failed; `err` is whatever the adapter threw. + */ +export function storageErrorNotification(op: StorageOp, err: unknown): NotifyOptions { + // Storage full — the one failure the user can act on themselves. No detail: + // there's nothing to report, the next step is in the message. + if (err instanceof StorageQuotaError) { + return { + kind: 'error', + title: 'Storage full', + message: + "This snippet couldn't be saved because snippet storage is full. " + + 'Delete snippets you no longer need to free space, then edit again to retry.', + }; + } + + // Couldn't read the library on startup — almost always blocked storage. + if (op === 'load') { + return { + kind: 'error', + title: "Couldn't open your library", + message: + "Astrolabe couldn't open local storage, so your saved snippets aren't available yet. " + + 'This usually means the browser is blocking storage — for example, private-browsing mode. ' + + 'Reload to try again.', + detail: diagnostic(op, err), + }; + } + + // An unexpected save/delete failure the user can't do anything about. Plain + // explanation for them; diagnostic detail for whoever they report it to. + const title = op === 'delete' ? "Couldn't delete the snippet" : "Couldn't save your changes"; + return { + kind: 'error', + title, + message: + 'A storage error stopped Astrolabe from completing the last change, so it may not survive a reload. ' + + 'If this keeps happening, your browser may be blocking local storage (such as private-browsing mode).', + detail: diagnostic(op, err), + }; +} diff --git a/src/app/stores/NotificationStore.test.ts b/src/app/stores/NotificationStore.test.ts new file mode 100644 index 0000000..ddbd14c --- /dev/null +++ b/src/app/stores/NotificationStore.test.ts @@ -0,0 +1,47 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { notify, useNotificationStore } from './NotificationStore'; + +afterEach(() => useNotificationStore.getState().clear()); + +describe('NotificationStore', () => { + it('appends a notification carrying its options and returns the id', () => { + const id = notify({ kind: 'error', title: 'Storage full', message: 'Free space.' }); + const list = useNotificationStore.getState().notifications; + expect(list).toHaveLength(1); + expect(list[0]).toMatchObject({ + id, + kind: 'error', + title: 'Storage full', + message: 'Free space.', + }); + }); + + it('coalesces an identical notification instead of stacking duplicates', () => { + const first = notify({ kind: 'error', title: 'Couldn’t save', message: 'A storage error.' }); + const second = notify({ kind: 'error', title: 'Couldn’t save', message: 'A storage error.' }); + expect(second).toBe(first); + expect(useNotificationStore.getState().notifications).toHaveLength(1); + }); + + it('does not coalesce when the content differs', () => { + notify({ kind: 'error', title: 'A', message: 'one' }); + notify({ kind: 'error', title: 'A', message: 'two' }); + notify({ kind: 'warning', title: 'A', message: 'one' }); + expect(useNotificationStore.getState().notifications).toHaveLength(3); + }); + + it('dismiss removes only the matching notification', () => { + const a = notify({ kind: 'info', title: 'A', message: 'a' }); + notify({ kind: 'info', title: 'B', message: 'b' }); + useNotificationStore.getState().dismiss(a); + const list = useNotificationStore.getState().notifications; + expect(list).toHaveLength(1); + expect(list[0].title).toBe('B'); + }); + + it('clear empties the list', () => { + notify({ kind: 'success', title: 'Saved', message: 'Done.' }); + useNotificationStore.getState().clear(); + expect(useNotificationStore.getState().notifications).toHaveLength(0); + }); +}); diff --git a/src/app/stores/NotificationStore.ts b/src/app/stores/NotificationStore.ts new file mode 100644 index 0000000..7491041 --- /dev/null +++ b/src/app/stores/NotificationStore.ts @@ -0,0 +1,84 @@ +/** + * Notifications — the app's non-blocking feedback channel (toasts). + * + * The sibling of ConfirmStore: ConfirmStore is the *blocking* layer (the user + * must choose before continuing), this is the *non-blocking* one (an outcome is + * announced and the user carries on). Per the design language "non-blocking + * outcomes are toasts" (docs/architecture/09 → Toasts); per spec §10 a failed + * save must *tell the user* rather than lose data silently. + * + * A notification's `kind` carries Carbon's status meaning (error/warning/ + * success/info) and drives both colour and urgency. The product distinction — + * can the user fix it or not — is encoded in the copy plus the optional + * `detail`: a user-fixable failure puts the next step in `message` and omits + * `detail`; a failure the user can't fix carries diagnostic `detail` so the + * problem can be reported (see services/storage-errors). + * + * The store holds only the list + add/dismiss; timed auto-dismiss for the + * positive kinds lives in the Toaster component, keeping this store pure and + * deterministic to test. Callable from non-React code (orchestration) via the + * imperative `notify` export, exactly like `confirm`. + */ + +import { create } from 'zustand'; + +/** Carbon status levels: colour carries meaning (docs/architecture/09). */ +export type NotificationKind = 'error' | 'warning' | 'success' | 'info'; + +export interface NotifyOptions { + kind: NotificationKind; + /** What happened, in a few words (Carbon: "tell users what stopped"). */ + title: string; + /** One or two sentences: the consequence and, when fixable, the next step. */ + message: string; + /** + * Technical diagnostics for a failure the user cannot fix, surfaced so the + * issue can be reported and traced. Omit for user-actionable or positive + * notifications — there is nothing for the user to report. + */ + detail?: string; +} + +export interface Notification extends NotifyOptions { + /** Stable id for React keys and dismissal. */ + id: string; +} + +/** True when two notifications say the same thing — used to coalesce repeats. */ +function sameContent(a: NotifyOptions, b: NotifyOptions): boolean { + return a.kind === b.kind && a.title === b.title && a.message === b.message; +} + +export interface NotificationState { + notifications: Notification[]; + /** + * Announce a notification; returns its id. An identical one already showing is + * coalesced (its id is returned, nothing is appended) so a repeatedly-failing + * auto-save can't stack a tower of the same toast. + */ + notify: (options: NotifyOptions) => string; + /** Dismiss one notification by id (the close button, or auto-dismiss). */ + dismiss: (id: string) => void; + /** Remove all notifications. */ + clear: () => void; +} + +export const useNotificationStore = create((set, get) => ({ + notifications: [], + + notify: (options) => { + const existing = get().notifications.find((n) => sameContent(n, options)); + if (existing) return existing.id; + const id = crypto.randomUUID(); + set((s) => ({ notifications: [...s.notifications, { ...options, id }] })); + return id; + }, + + dismiss: (id) => set((s) => ({ notifications: s.notifications.filter((n) => n.id !== id) })), + + clear: () => set({ notifications: [] }), +})); + +/** Imperative entry point for non-React callers (orchestration, services). */ +export const notify = (options: NotifyOptions): string => + useNotificationStore.getState().notify(options);