@signal9/era-ui 3.9.0 → 3.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/dist/apps/index.d.ts +19 -0
  2. package/dist/apps/index.js +19 -0
  3. package/dist/apps/notes/editor/bubble-menu.svelte.d.ts +11 -0
  4. package/dist/apps/notes/editor/bubble-menu.svelte.js +106 -0
  5. package/dist/apps/notes/editor/bubble-toolbar.svelte +42 -0
  6. package/dist/apps/notes/editor/bubble-toolbar.svelte.d.ts +22 -0
  7. package/dist/apps/notes/editor/extensions.d.ts +18 -0
  8. package/dist/apps/notes/editor/extensions.js +88 -0
  9. package/dist/apps/notes/editor/floating.d.ts +21 -0
  10. package/dist/apps/notes/editor/floating.js +44 -0
  11. package/dist/apps/notes/editor/link.d.ts +9 -0
  12. package/dist/apps/notes/editor/link.js +22 -0
  13. package/dist/apps/notes/editor/list-cleanup-rule.d.ts +14 -0
  14. package/dist/apps/notes/editor/list-cleanup-rule.js +47 -0
  15. package/dist/apps/notes/editor/slash-command.svelte.d.ts +22 -0
  16. package/dist/apps/notes/editor/slash-command.svelte.js +215 -0
  17. package/dist/apps/notes/editor/slash-menu.svelte +63 -0
  18. package/dist/apps/notes/editor/slash-menu.svelte.d.ts +23 -0
  19. package/dist/apps/notes/index.d.ts +11 -0
  20. package/dist/apps/notes/index.js +13 -0
  21. package/dist/apps/notes/markdown.d.ts +12 -0
  22. package/dist/apps/notes/markdown.js +165 -0
  23. package/dist/apps/notes/note-editor.svelte +456 -0
  24. package/dist/apps/notes/note-editor.svelte.d.ts +35 -0
  25. package/dist/apps/notes/notes-store.svelte.d.ts +64 -0
  26. package/dist/apps/notes/notes-store.svelte.js +234 -0
  27. package/dist/apps/notes/notes.svelte +377 -0
  28. package/dist/apps/notes/notes.svelte.d.ts +22 -0
  29. package/dist/apps/notes/tree.d.ts +70 -0
  30. package/dist/apps/notes/tree.js +185 -0
  31. package/dist/apps/notes/types.d.ts +49 -0
  32. package/dist/apps/notes/types.js +11 -0
  33. package/dist/dev/audit/audits/index.d.ts +2 -1
  34. package/dist/dev/audit/audits/index.js +3 -1
  35. package/dist/dev/audit/audits/resting-gap.d.ts +2 -0
  36. package/dist/dev/audit/audits/resting-gap.js +78 -0
  37. package/dist/docs/notes.md +82 -0
  38. package/dist/era-ui.css +1 -1
  39. package/dist/generated-docs/llms-full.txt +92 -1
  40. package/dist/generated-docs/llms.txt +1 -1
  41. package/dist/generated-docs/manifest.json +16 -1
  42. package/dist/generated-docs/notes.md +86 -0
  43. package/dist/generated-docs/utilities.json +1 -1
  44. package/dist/generated-docs/utilities.md +1 -1
  45. package/dist/styles/index.css +2 -1
  46. package/dist/styles/themes.css +70 -52
  47. package/dist/ui/badge/badge.svelte.d.ts +17 -17
  48. package/dist/ui/bar/bar.svelte.d.ts +11 -11
  49. package/dist/ui/button/variants.d.ts +44 -44
  50. package/dist/ui/card/card.svelte.d.ts +20 -20
  51. package/dist/ui/chip/chip.svelte.d.ts +14 -14
  52. package/dist/ui/pane/pane-root.svelte.d.ts +1 -1
  53. package/dist/ui/pane/pane.svelte.d.ts +1 -1
  54. package/dist/ui/sheet/sheet-content.svelte.d.ts +14 -14
  55. package/dist/ui/skeleton/skeleton.svelte.d.ts +11 -11
  56. package/dist/utils/index.js +11 -0
  57. package/package.json +31 -5
@@ -0,0 +1,234 @@
1
+ /**
2
+ * The Notes data layer — reactive state plus a pluggable persistence adapter.
3
+ *
4
+ * The store owns the notes; the adapter owns where they live. That split is
5
+ * deliberate: era ships a browser-only app (localStorage), but the same
6
+ * component is meant to drop into a host that persists server-side. Such a host
7
+ * supplies its own adapter and changes nothing else — which is why `load`/`save`
8
+ * are allowed to return promises even though the bundled adapters are sync.
9
+ *
10
+ * Writes are debounced and coalesced: every mutation marks the store dirty and
11
+ * re-arms one timer, so a burst of keystrokes costs a single `save`.
12
+ */
13
+ import { tiptapToMarkdown, textContent } from './markdown.js';
14
+ import { EMPTY_DOC } from './types.js';
15
+ export const DEFAULT_STORAGE_KEY = 'era-ui:notes';
16
+ /** Loose structural check — storage is untrusted (stale schema, another tab, a
17
+ * user editing devtools), so a malformed entry is dropped rather than crashing
18
+ * the list on first render. */
19
+ function isNote(value) {
20
+ if (typeof value !== 'object' || value === null)
21
+ return false;
22
+ const n = value;
23
+ return (typeof n.id === 'string' &&
24
+ typeof n.title === 'string' &&
25
+ typeof n.content === 'object' &&
26
+ n.content !== null &&
27
+ Array.isArray(n.content.content));
28
+ }
29
+ /**
30
+ * Persist to `localStorage`. Safe to construct during SSR — it only touches
31
+ * storage inside `load`/`save`, and returns empty / no-ops when there is none.
32
+ */
33
+ export function localStorageAdapter(key = DEFAULT_STORAGE_KEY) {
34
+ const available = () => typeof localStorage !== 'undefined';
35
+ return {
36
+ load() {
37
+ if (!available())
38
+ return [];
39
+ try {
40
+ const raw = localStorage.getItem(key);
41
+ if (!raw)
42
+ return [];
43
+ const parsed = JSON.parse(raw);
44
+ return Array.isArray(parsed) ? parsed.filter(isNote) : [];
45
+ }
46
+ catch {
47
+ return [];
48
+ }
49
+ },
50
+ save(notes) {
51
+ if (!available())
52
+ return;
53
+ // Let a quota/private-mode failure reach the store so the UI can say so,
54
+ // rather than silently dropping the user's writing.
55
+ localStorage.setItem(key, JSON.stringify(notes));
56
+ }
57
+ };
58
+ }
59
+ /** In-memory only — for demos, tests, and SSR snapshots. */
60
+ export function memoryAdapter(seed = []) {
61
+ let notes = seed;
62
+ return {
63
+ load: () => notes,
64
+ save: (next) => {
65
+ notes = next;
66
+ }
67
+ };
68
+ }
69
+ function newId() {
70
+ if (typeof crypto !== 'undefined' && 'randomUUID' in crypto)
71
+ return crypto.randomUUID();
72
+ return `note-${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;
73
+ }
74
+ export class NotesStore {
75
+ #adapter;
76
+ #debounce;
77
+ #statusLinger;
78
+ #saveTimer = null;
79
+ #statusTimer = null;
80
+ notes = $state([]);
81
+ /** True until the first `load()` settles — the list renders a skeleton on it. */
82
+ loading = $state(true);
83
+ status = $state('idle');
84
+ lastError = $state(null);
85
+ /** Pinned first, then most-recently-touched. The order the sidebar renders. */
86
+ ordered = $derived([...this.notes].sort((a, b) => Number(b.pinned) - Number(a.pinned) || b.updatedAt - a.updatedAt));
87
+ constructor(options = {}) {
88
+ this.#adapter = options.adapter ?? localStorageAdapter();
89
+ this.#debounce = options.debounce ?? 400;
90
+ this.#statusLinger = options.statusLinger ?? 1600;
91
+ }
92
+ async load() {
93
+ this.loading = true;
94
+ try {
95
+ const loaded = await this.#adapter.load();
96
+ this.notes = loaded;
97
+ this.lastError = null;
98
+ }
99
+ catch (error) {
100
+ this.notes = [];
101
+ this.#fail(error);
102
+ }
103
+ finally {
104
+ this.loading = false;
105
+ }
106
+ }
107
+ get(id) {
108
+ if (!id)
109
+ return undefined;
110
+ return this.notes.find((n) => n.id === id);
111
+ }
112
+ /**
113
+ * Case-insensitive match over title AND body. Matching the body is why
114
+ * `plainText` is denormalised — the alternative is walking every document's
115
+ * AST on every keystroke.
116
+ */
117
+ search(query) {
118
+ const q = query.trim().toLowerCase();
119
+ if (!q)
120
+ return this.ordered;
121
+ return this.ordered.filter((n) => n.title.toLowerCase().includes(q) || n.plainText.toLowerCase().includes(q));
122
+ }
123
+ create(init = {}) {
124
+ const now = Date.now();
125
+ const content = init.content ?? structuredClone(EMPTY_DOC);
126
+ const note = {
127
+ id: newId(),
128
+ title: init.title ?? 'Untitled',
129
+ icon: init.icon ?? null,
130
+ content,
131
+ plainText: content.content.map(textContent).join(' ').trim(),
132
+ pinned: init.pinned ?? false,
133
+ createdAt: now,
134
+ updatedAt: now
135
+ };
136
+ this.notes.push(note);
137
+ this.#schedule();
138
+ return note;
139
+ }
140
+ update(id, patch) {
141
+ const note = this.get(id);
142
+ if (!note)
143
+ return;
144
+ if (patch.title !== undefined)
145
+ note.title = patch.title;
146
+ if (patch.icon !== undefined)
147
+ note.icon = patch.icon;
148
+ if (patch.pinned !== undefined)
149
+ note.pinned = patch.pinned;
150
+ if (patch.content !== undefined) {
151
+ note.content = patch.content;
152
+ note.plainText = patch.content.content.map(textContent).join(' ').trim();
153
+ }
154
+ note.updatedAt = Date.now();
155
+ this.#schedule();
156
+ }
157
+ remove(id) {
158
+ const index = this.notes.findIndex((n) => n.id === id);
159
+ if (index < 0)
160
+ return;
161
+ this.notes.splice(index, 1);
162
+ this.#schedule();
163
+ }
164
+ togglePin(id) {
165
+ const note = this.get(id);
166
+ if (!note)
167
+ return;
168
+ // Pinning is an ordering change, not an edit — leave `updatedAt` alone so a
169
+ // pin doesn't shuffle the note to the top of the recency list as well.
170
+ note.pinned = !note.pinned;
171
+ this.#schedule();
172
+ }
173
+ setIcon(id, icon) {
174
+ this.update(id, { icon });
175
+ }
176
+ /** The note as markdown — for copy-out, export, or handing to a model. */
177
+ toMarkdown(id) {
178
+ const note = this.get(id);
179
+ if (!note)
180
+ return '';
181
+ const heading = note.title ? `# ${note.title}\n\n` : '';
182
+ return heading + tiptapToMarkdown(note.content);
183
+ }
184
+ /** Persist immediately, cancelling any pending debounce. Safe to call spuriously. */
185
+ async flush() {
186
+ if (this.#saveTimer) {
187
+ clearTimeout(this.#saveTimer);
188
+ this.#saveTimer = null;
189
+ }
190
+ await this.#persist();
191
+ }
192
+ /** Clear timers and write out whatever is pending. Call from `onDestroy`. */
193
+ destroy() {
194
+ if (this.#statusTimer)
195
+ clearTimeout(this.#statusTimer);
196
+ this.#statusTimer = null;
197
+ void this.flush();
198
+ }
199
+ #schedule() {
200
+ this.status = 'saving';
201
+ if (this.#saveTimer)
202
+ clearTimeout(this.#saveTimer);
203
+ this.#saveTimer = setTimeout(() => {
204
+ this.#saveTimer = null;
205
+ void this.#persist();
206
+ }, this.#debounce);
207
+ }
208
+ async #persist() {
209
+ try {
210
+ await this.#adapter.save($state.snapshot(this.notes));
211
+ this.lastError = null;
212
+ this.#flash('saved');
213
+ }
214
+ catch (error) {
215
+ this.#fail(error);
216
+ }
217
+ }
218
+ #flash(status) {
219
+ this.status = status;
220
+ if (this.#statusTimer)
221
+ clearTimeout(this.#statusTimer);
222
+ this.#statusTimer = setTimeout(() => {
223
+ this.#statusTimer = null;
224
+ this.status = 'idle';
225
+ }, this.#statusLinger);
226
+ }
227
+ #fail(error) {
228
+ this.lastError = error instanceof Error ? error.message : String(error);
229
+ this.status = 'error';
230
+ if (this.#statusTimer)
231
+ clearTimeout(this.#statusTimer);
232
+ this.#statusTimer = null;
233
+ }
234
+ }
@@ -0,0 +1,377 @@
1
+ <script lang="ts">
2
+ /**
3
+ * Notes — a complete, client-side notes app.
4
+ *
5
+ * An era port of the term/web notes app: same TipTap editor, same slash menu
6
+ * and selection toolbar, same heading-tree index. Two things changed.
7
+ *
8
+ * PERSISTENCE. The original round-tripped every edit through a server via
9
+ * TanStack Query, which forced a whole dirty-override layer — local edits had
10
+ * to out-rank refetched query data, or an in-flight save clobbered what you
11
+ * were typing. Here the store IS the source of truth and persistence is a
12
+ * debounced write behind it, so the editor writes straight through and there
13
+ * is nothing to reconcile.
14
+ *
15
+ * CHROME. Everything visible is an era primitive (Bar, Button, Input, Badge,
16
+ * Popover, ScrollArea, AlertDialog, Skeleton) on era tokens, so the app
17
+ * follows the density, surface, corners, motion, and font axes like any other
18
+ * component in the library.
19
+ */
20
+ import { onDestroy, onMount, untrack } from 'svelte';
21
+ import FileText from '@lucide/svelte/icons/file-text';
22
+ import Plus from '@lucide/svelte/icons/plus';
23
+ import Search from '@lucide/svelte/icons/search';
24
+ import Pin from '@lucide/svelte/icons/pin';
25
+ import PinOff from '@lucide/svelte/icons/pin-off';
26
+ import Trash2 from '@lucide/svelte/icons/trash-2';
27
+ import { Bar } from '../../ui/bar/index.js';
28
+ import { Badge } from '../../ui/badge/index.js';
29
+ import { Button } from '../../ui/button/index.js';
30
+ import { Input } from '../../ui/input/index.js';
31
+ import { Skeleton } from '../../ui/skeleton/index.js';
32
+ import * as AlertDialog from '../../ui/alert-dialog/index.js';
33
+ import * as Popover from '../../ui/popover/index.js';
34
+ import * as ScrollArea from '../../ui/scroll-area/index.js';
35
+ import { cn, keys } from '../../utils/index.js';
36
+ import NoteEditor from './note-editor.svelte';
37
+ import { NotesStore, localStorageAdapter, type NotesAdapter } from './notes-store.svelte.js';
38
+ import type { NoteInit, TiptapDocument } from './types.js';
39
+
40
+ interface Props {
41
+ /**
42
+ * Bring your own store — for a custom adapter, or to share one set of
43
+ * notes between two mounted views. When supplied, the host owns its
44
+ * lifecycle: this component will not destroy it on unmount.
45
+ */
46
+ store?: NotesStore;
47
+ /** Persistence for the store this component creates. Ignored if `store` is set. */
48
+ adapter?: NotesAdapter;
49
+ /** `localStorage` key for the default adapter. Ignored if `store`/`adapter` is set. */
50
+ storageKey?: string;
51
+ /** The open note. Bindable, so a host can deep-link or restore a session. */
52
+ noteId?: string | null;
53
+ /** Notes created only when storage comes back empty — a first-run sample. */
54
+ seed?: NoteInit[];
55
+ class?: string;
56
+ }
57
+
58
+ let {
59
+ store: providedStore,
60
+ adapter,
61
+ storageKey,
62
+ noteId = $bindable(null),
63
+ seed,
64
+ class: className
65
+ }: Props = $props();
66
+
67
+ // Resolved once, on purpose — untrack() is the declaration of that intent, not
68
+ // a workaround. A store swapped in mid-flight would strand the notes currently
69
+ // on screen and the editor mounted over them.
70
+ const owned = untrack(() => providedStore === undefined);
71
+ const store = untrack(
72
+ () => providedStore ?? new NotesStore({ adapter: adapter ?? localStorageAdapter(storageKey) })
73
+ );
74
+
75
+ let query = $state('');
76
+ let pendingDelete = $state<string | null>(null);
77
+ let iconPickerOpen = $state(false);
78
+
79
+ const visible = $derived(store.search(query));
80
+ const selected = $derived(store.get(noteId));
81
+ const pendingDeleteNote = $derived(store.get(pendingDelete));
82
+
83
+ /** A compact glyph set — enough to tell notes apart at a glance, no picker UI. */
84
+ const ICON_ROWS = [
85
+ ['📝', '📋', '📌', '💡', '🔥', '⭐'],
86
+ ['🎯', '🚀', '🔧', '📦', '🔒', '🔑'],
87
+ ['💻', '🤖', '🧠', '⚡', '🔍', '🌐']
88
+ ];
89
+
90
+ onMount(async () => {
91
+ // `loading` is only true before a first successful load, so this is also the
92
+ // flag for "this mount is the one that hydrated the store". Seeding keys off
93
+ // it rather than off `owned`: a second view sharing an already-loaded store
94
+ // must not seed again, but a HOST-supplied store that comes back empty
95
+ // should still get its first-run notes.
96
+ const hydrating = store.loading;
97
+ if (hydrating) await store.load();
98
+ if (hydrating && seed?.length && store.notes.length === 0) {
99
+ for (const init of seed) store.create(init);
100
+ }
101
+ if (!store.get(noteId)) noteId = store.ordered[0]?.id ?? null;
102
+ });
103
+
104
+ onDestroy(() => {
105
+ if (owned) store.destroy();
106
+ });
107
+
108
+ function createNote() {
109
+ const note = store.create();
110
+ noteId = note.id;
111
+ query = '';
112
+ }
113
+
114
+ function confirmDelete() {
115
+ const id = pendingDelete;
116
+ pendingDelete = null;
117
+ if (!id) return;
118
+ store.remove(id);
119
+ if (noteId === id) noteId = store.ordered[0]?.id ?? null;
120
+ }
121
+
122
+ /** Short relative age — the list has a single cell for it, so it must stay narrow. */
123
+ function formatAge(ts: number) {
124
+ const d = Date.now() - ts;
125
+ if (d < 60_000) return 'now';
126
+ if (d < 3_600_000) return `${Math.floor(d / 60_000)}m`;
127
+ if (d < 86_400_000) return `${Math.floor(d / 3_600_000)}h`;
128
+ return new Date(ts).toLocaleDateString();
129
+ }
130
+
131
+ function onRowKeydown(event: KeyboardEvent) {
132
+ if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
133
+ event.preventDefault();
134
+ const row = event.currentTarget as HTMLElement;
135
+ const next = event.key === 'ArrowDown' ? row.nextElementSibling : row.previousElementSibling;
136
+ (next as HTMLElement | null)?.focus();
137
+ }
138
+ </script>
139
+
140
+ <div
141
+ class={cn('flex h-full min-h-0 text-body text-fg', className)}
142
+ use:keys={{
143
+ // Persistence is already debounced; Ctrl/Cmd-S is the reassurance gesture
144
+ // people press anyway, wired to a real immediate flush.
145
+ '$mod+s': (e) => {
146
+ e.preventDefault();
147
+ void store.flush();
148
+ }
149
+ }}
150
+ >
151
+ <!-- Sidebar: filter + note list -->
152
+ <div class="flex w-56 shrink-0 flex-col border-r border-divider-faded">
153
+ <Bar class="shrink-0 rounded-none border-b border-divider-faded">
154
+ <div
155
+ class="flex h-md w-full items-center gap-gutter rounded-md bg-elevated px-field text-body era-text-trim shadow-well focus-within:bg-highlight"
156
+ >
157
+ <Search class="size-xs shrink-0 text-muted" />
158
+ <input
159
+ class="h-full w-full min-w-0 bg-transparent text-fg outline-none placeholder:text-muted"
160
+ type="text"
161
+ placeholder="Filter…"
162
+ aria-label="Filter notes"
163
+ bind:value={query}
164
+ />
165
+ </div>
166
+ <Button icon aria-label="New note" title="New note" onclick={createNote}>
167
+ <Plus />
168
+ </Button>
169
+ </Bar>
170
+
171
+ {#if store.loading}
172
+ <div class="flex flex-col gap-gutter p-gutter">
173
+ {#each Array.from({ length: 5 }, (_, i) => i) as i (i)}
174
+ <Skeleton class="h-md rounded-item" />
175
+ {/each}
176
+ </div>
177
+ {:else if visible.length === 0}
178
+ <!-- No call to action here: the document pane already carries the primary
179
+ one, and the bar's + is always within reach. Two competing CTAs for
180
+ an empty list reads as indecision. -->
181
+ <div
182
+ class="flex flex-1 flex-col items-center justify-center gap-gutter p-content text-center"
183
+ >
184
+ <p class="text-muted">{query ? 'No matches' : 'No notes yet'}</p>
185
+ </div>
186
+ {:else}
187
+ <ScrollArea.Root class="min-h-0 flex-1 rounded-none">
188
+ <ScrollArea.Viewport>
189
+ <div class="flex flex-col gap-gutter p-gutter" role="listbox" aria-label="Notes">
190
+ {#each visible as note (note.id)}
191
+ {@const current = note.id === noteId}
192
+ <!-- The row is the selectable option; the pin/delete buttons inside it
193
+ are separate actions, so they stop propagation rather than
194
+ selecting the note out from under the click. -->
195
+ <div
196
+ class="group flex h-md cursor-pointer items-center gap-(--era-xs-inset-md) rounded-item px-(--era-xs-inset-md) era-text-trim {current
197
+ ? 'bg-hover text-bright shadow-(--era-shadow-pressed)'
198
+ : 'hover:bg-highlight hover:shadow-highlight'}"
199
+ role="option"
200
+ aria-selected={current}
201
+ tabindex="0"
202
+ onclick={() => (noteId = note.id)}
203
+ onkeydown={(e) => {
204
+ if (e.key === 'Enter' || e.key === ' ') {
205
+ e.preventDefault();
206
+ noteId = note.id;
207
+ return;
208
+ }
209
+ onRowKeydown(e);
210
+ }}
211
+ >
212
+ <span class="flex size-xs shrink-0 items-center justify-center">
213
+ {#if note.icon}
214
+ {note.icon}
215
+ {:else}
216
+ <FileText class="size-xs text-muted" />
217
+ {/if}
218
+ </span>
219
+
220
+ <span class="min-w-0 flex-1 truncate">{note.title || 'Untitled'}</span>
221
+
222
+ <!-- One cell, three occupants: the age at rest, the actions on
223
+ hover. Swapping them in place keeps the row from reflowing. -->
224
+ <span class="shrink-0 text-muted group-hover:hidden">
225
+ {#if note.pinned}<Pin class="size-xs" />{:else}{formatAge(note.updatedAt)}{/if}
226
+ </span>
227
+ <span class="hidden shrink-0 items-center gap-(--era-xs-inset-md) group-hover:flex">
228
+ <Button
229
+ icon
230
+ size="icon"
231
+ aria-label={note.pinned ? 'Unpin note' : 'Pin note'}
232
+ title={note.pinned ? 'Unpin note' : 'Pin note'}
233
+ active={note.pinned}
234
+ onclick={(e: MouseEvent) => {
235
+ e.stopPropagation();
236
+ store.togglePin(note.id);
237
+ }}
238
+ >
239
+ {#if note.pinned}<PinOff />{:else}<Pin />{/if}
240
+ </Button>
241
+ <Button
242
+ icon
243
+ size="icon"
244
+ tone="destructive"
245
+ aria-label="Delete note"
246
+ title="Delete note"
247
+ onclick={(e: MouseEvent) => {
248
+ e.stopPropagation();
249
+ pendingDelete = note.id;
250
+ }}
251
+ >
252
+ <Trash2 />
253
+ </Button>
254
+ </span>
255
+ </div>
256
+ {/each}
257
+ </div>
258
+ </ScrollArea.Viewport>
259
+ <ScrollArea.Scrollbar orientation="vertical">
260
+ <ScrollArea.Thumb />
261
+ </ScrollArea.Scrollbar>
262
+ </ScrollArea.Root>
263
+ {/if}
264
+ </div>
265
+
266
+ <!-- Document pane -->
267
+ <div class="flex min-w-0 flex-1 flex-col">
268
+ {#if selected}
269
+ <Bar class="shrink-0 rounded-none border-b border-divider-faded">
270
+ <Popover.Root bind:open={iconPickerOpen}>
271
+ <Popover.Trigger icon aria-label="Change icon" title="Change icon">
272
+ {#if selected.icon}
273
+ <span aria-hidden="true">{selected.icon}</span>
274
+ {:else}
275
+ <FileText />
276
+ {/if}
277
+ </Popover.Trigger>
278
+ <Popover.Content align="start" class="w-max">
279
+ <div class="flex flex-col gap-gutter">
280
+ {#each ICON_ROWS as row, r (r)}
281
+ <div class="flex gap-gutter">
282
+ {#each row as glyph (glyph)}
283
+ <Button
284
+ icon
285
+ size="default"
286
+ active={selected.icon === glyph}
287
+ aria-label={glyph}
288
+ onclick={() => {
289
+ store.setIcon(selected.id, glyph);
290
+ iconPickerOpen = false;
291
+ }}
292
+ >
293
+ {glyph}
294
+ </Button>
295
+ {/each}
296
+ </div>
297
+ {/each}
298
+ {#if selected.icon}
299
+ <Button
300
+ size="sm"
301
+ class="w-full"
302
+ onclick={() => {
303
+ store.setIcon(selected.id, null);
304
+ iconPickerOpen = false;
305
+ }}
306
+ >
307
+ Clear icon
308
+ </Button>
309
+ {/if}
310
+ </div>
311
+ </Popover.Content>
312
+ </Popover.Root>
313
+
314
+ <Input
315
+ class="min-w-0 flex-1 bg-transparent shadow-none"
316
+ aria-label="Note title"
317
+ placeholder="Untitled"
318
+ value={selected.title}
319
+ oninput={(e: Event & { currentTarget: HTMLInputElement }) =>
320
+ store.update(selected.id, { title: e.currentTarget.value })}
321
+ />
322
+
323
+ <!-- Fixed-width so the badge appearing and clearing never nudges the
324
+ title field; `saving` is deliberately silent — the badge only
325
+ reports a settled outcome. -->
326
+ <span class="flex w-16 shrink-0 justify-end">
327
+ {#if store.status === 'saved'}
328
+ <Badge tone="success">saved</Badge>
329
+ {:else if store.status === 'error'}
330
+ <Badge tone="destructive" title={store.lastError ?? undefined}>error</Badge>
331
+ {/if}
332
+ </span>
333
+ </Bar>
334
+
335
+ <div class="min-h-0 flex-1 overflow-x-hidden overflow-y-auto p-content">
336
+ <!-- ProseMirror owns the document once running, so switching notes
337
+ rebuilds the editor rather than pushing new content into it. -->
338
+ {#key selected.id}
339
+ <NoteEditor
340
+ content={selected.content}
341
+ onUpdate={(json) => store.update(selected.id, { content: json as TiptapDocument })}
342
+ />
343
+ {/key}
344
+ </div>
345
+ {:else}
346
+ <div class="flex flex-1 flex-col items-center justify-center gap-gutter p-content">
347
+ <p class="text-muted">
348
+ {store.notes.length === 0 ? 'Create a note to get started' : 'Select a note'}
349
+ </p>
350
+ {#if store.notes.length === 0}
351
+ <Button onclick={createNote}>
352
+ <Plus />
353
+ New note
354
+ </Button>
355
+ {/if}
356
+ </div>
357
+ {/if}
358
+ </div>
359
+ </div>
360
+
361
+ <AlertDialog.Root
362
+ open={pendingDelete !== null}
363
+ onOpenChange={(open) => {
364
+ if (!open) pendingDelete = null;
365
+ }}
366
+ >
367
+ <AlertDialog.Content>
368
+ <AlertDialog.Title>Delete note</AlertDialog.Title>
369
+ <AlertDialog.Description>
370
+ “{pendingDeleteNote?.title || 'Untitled'}” will be removed. This cannot be undone.
371
+ </AlertDialog.Description>
372
+ <div class="flex justify-end gap-gutter">
373
+ <AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
374
+ <AlertDialog.Action tone="destructive" onclick={confirmDelete}>Delete</AlertDialog.Action>
375
+ </div>
376
+ </AlertDialog.Content>
377
+ </AlertDialog.Root>
@@ -0,0 +1,22 @@
1
+ import { NotesStore, type NotesAdapter } from './notes-store.svelte.js';
2
+ import type { NoteInit } from './types.js';
3
+ interface Props {
4
+ /**
5
+ * Bring your own store — for a custom adapter, or to share one set of
6
+ * notes between two mounted views. When supplied, the host owns its
7
+ * lifecycle: this component will not destroy it on unmount.
8
+ */
9
+ store?: NotesStore;
10
+ /** Persistence for the store this component creates. Ignored if `store` is set. */
11
+ adapter?: NotesAdapter;
12
+ /** `localStorage` key for the default adapter. Ignored if `store`/`adapter` is set. */
13
+ storageKey?: string;
14
+ /** The open note. Bindable, so a host can deep-link or restore a session. */
15
+ noteId?: string | null;
16
+ /** Notes created only when storage comes back empty — a first-run sample. */
17
+ seed?: NoteInit[];
18
+ class?: string;
19
+ }
20
+ declare const Notes: import("svelte").Component<Props, {}, "noteId">;
21
+ type Notes = ReturnType<typeof Notes>;
22
+ export default Notes;