+ );
+}
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);