@signal9/era-ui 4.14.0 → 4.15.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.
@@ -17,7 +17,7 @@
17
17
  * follows the density, surface, corners, motion, and font axes like any other
18
18
  * component in the library.
19
19
  */
20
- import { onDestroy, onMount, untrack } from 'svelte';
20
+ import { onDestroy, onMount, untrack, type Snippet } from 'svelte';
21
21
  import FileText from '@lucide/svelte/icons/file-text';
22
22
  import Plus from '@lucide/svelte/icons/plus';
23
23
  import Search from '@lucide/svelte/icons/search';
@@ -36,7 +36,7 @@
36
36
  import { cn, keys } from '../../utils/index.js';
37
37
  import NoteEditor from './note-editor.svelte';
38
38
  import { NotesStore, localStorageAdapter, type NotesAdapter } from './notes-store.svelte.js';
39
- import type { NoteInit, TiptapDocument } from './types.js';
39
+ import type { Note, NoteInit, TiptapDocument } from './types.js';
40
40
 
41
41
  interface Props {
42
42
  /**
@@ -53,6 +53,16 @@
53
53
  noteId?: string | null;
54
54
  /** Notes created only when storage comes back empty — a first-run sample. */
55
55
  seed?: NoteInit[];
56
+ /**
57
+ * The pane hosting this app, if any. Structural on purpose — an os-layer
58
+ * `AppPane` satisfies it as-is, and Notes never has to import the os layer.
59
+ * When provided, the document header (icon picker, title, save status)
60
+ * moves out of the editor area and into the pane bar's CENTRE slot, the
61
+ * way the reference terminal app titles its notes window; it is cleared
62
+ * again on unmount. Omit it and the header renders above the editor as a
63
+ * normal in-app bar.
64
+ */
65
+ pane?: { center?: Snippet } | null;
56
66
  class?: string;
57
67
  }
58
68
 
@@ -62,6 +72,7 @@
62
72
  storageKey,
63
73
  noteId = $bindable(null),
64
74
  seed,
75
+ pane,
65
76
  class: className
66
77
  }: Props = $props();
67
78
 
@@ -106,6 +117,20 @@
106
117
  if (owned) store.destroy();
107
118
  });
108
119
 
120
+ // Claim the pane bar's centre for the document header. An effect rather than
121
+ // a one-shot: `pane` can arrive late (the docs shell finds its Reader pane
122
+ // after mount) or change identity, and each run releases the previous claim.
123
+ // The guard on the cleanup matters — if some later occupant already replaced
124
+ // the snippet, unmounting Notes must not blank THEIR chrome.
125
+ $effect(() => {
126
+ const host = pane;
127
+ if (!host) return;
128
+ host.center = paneHeader;
129
+ return () => {
130
+ if (host.center === paneHeader) host.center = undefined;
131
+ };
132
+ });
133
+
109
134
  function createNote() {
110
135
  const note = store.create();
111
136
  noteId = note.id;
@@ -138,6 +163,86 @@
138
163
  }
139
164
  </script>
140
165
 
166
+ {#snippet iconPicker(note: Note)}
167
+ <Popover.Root bind:open={iconPickerOpen}>
168
+ <Popover.Trigger icon size="xxs" aria-label="Change icon" title="Change icon">
169
+ {#if note.icon}
170
+ <span aria-hidden="true">{note.icon}</span>
171
+ {:else}
172
+ <FileText />
173
+ {/if}
174
+ </Popover.Trigger>
175
+ <Popover.Content align="start" class="w-max">
176
+ <div class="flex flex-col gap-gutter">
177
+ {#each ICON_ROWS as row, r (r)}
178
+ <div class="flex gap-gutter">
179
+ {#each row as glyph (glyph)}
180
+ <Button
181
+ icon
182
+ size="default"
183
+ active={note.icon === glyph}
184
+ aria-label={glyph}
185
+ onclick={() => {
186
+ store.setIcon(note.id, glyph);
187
+ iconPickerOpen = false;
188
+ }}
189
+ >
190
+ {glyph}
191
+ </Button>
192
+ {/each}
193
+ </div>
194
+ {/each}
195
+ {#if note.icon}
196
+ <Button
197
+ size="sm"
198
+ class="w-full"
199
+ onclick={() => {
200
+ store.setIcon(note.id, null);
201
+ iconPickerOpen = false;
202
+ }}
203
+ >
204
+ Clear icon
205
+ </Button>
206
+ {/if}
207
+ </div>
208
+ </Popover.Content>
209
+ </Popover.Root>
210
+ {/snippet}
211
+
212
+ <!-- The document header, pane-bar edition: the same icon picker and title,
213
+ compacted for the bar's centre track. data-pane-control is what makes an
214
+ EDITABLE title possible inside a drag zone — a press on the cluster means
215
+ caret/click, everywhere else on the bar still means drag. The input is
216
+ content-sized (field-sizing) so the cluster hugs the title and stays
217
+ optically centred; the status badge hangs OFF the flow (absolute, past the
218
+ right edge) so saved/error appearing never nudges the centred title. -->
219
+ {#snippet paneHeader()}
220
+ {#if selected}
221
+ <div class="relative flex min-w-0 items-center gap-(--era-gap)" data-pane-control>
222
+ {@render iconPicker(selected)}
223
+ <Input
224
+ reveal
225
+ size="xxs"
226
+ class="field-sizing-content max-w-56 min-w-0 text-center"
227
+ aria-label="Note title"
228
+ placeholder="Untitled"
229
+ value={selected.title}
230
+ oninput={(e: Event & { currentTarget: HTMLInputElement }) =>
231
+ store.update(selected.id, { title: e.currentTarget.value })}
232
+ />
233
+ {#if store.status === 'saved' || store.status === 'error'}
234
+ <span class="absolute top-1/2 left-full ml-(--era-gap) -translate-y-1/2">
235
+ {#if store.status === 'saved'}
236
+ <Badge tone="success">saved</Badge>
237
+ {:else}
238
+ <Badge tone="destructive" title={store.lastError ?? undefined}>error</Badge>
239
+ {/if}
240
+ </span>
241
+ {/if}
242
+ </div>
243
+ {/if}
244
+ {/snippet}
245
+
141
246
  <div
142
247
  class={cn('flex h-full min-h-0 text-body text-fg', className)}
143
248
  use:keys={{
@@ -326,77 +431,40 @@
326
431
  <!-- Matches the sidebar's filter bar exactly — the two headers sit side by
327
432
  side across the top of the app, so they must be the same tier. -->
328
433
  <!-- Same outset divider as the sidebar's filter bar above. -->
329
- <Bar
330
- size="md"
331
- content="xxs"
332
- class="shrink-0 rounded-none [box-shadow:0_1px_0_var(--color-divider-faded)]"
333
- >
334
- <Popover.Root bind:open={iconPickerOpen}>
335
- <Popover.Trigger icon size="xxs" aria-label="Change icon" title="Change icon">
336
- {#if selected.icon}
337
- <span aria-hidden="true">{selected.icon}</span>
338
- {:else}
339
- <FileText />
340
- {/if}
341
- </Popover.Trigger>
342
- <Popover.Content align="start" class="w-max">
343
- <div class="flex flex-col gap-gutter">
344
- {#each ICON_ROWS as row, r (r)}
345
- <div class="flex gap-gutter">
346
- {#each row as glyph (glyph)}
347
- <Button
348
- icon
349
- size="default"
350
- active={selected.icon === glyph}
351
- aria-label={glyph}
352
- onclick={() => {
353
- store.setIcon(selected.id, glyph);
354
- iconPickerOpen = false;
355
- }}
356
- >
357
- {glyph}
358
- </Button>
359
- {/each}
360
- </div>
361
- {/each}
362
- {#if selected.icon}
363
- <Button
364
- size="sm"
365
- class="w-full"
366
- onclick={() => {
367
- store.setIcon(selected.id, null);
368
- iconPickerOpen = false;
369
- }}
370
- >
371
- Clear icon
372
- </Button>
373
- {/if}
374
- </div>
375
- </Popover.Content>
376
- </Popover.Root>
434
+ <!-- Rendered only when no pane hosts the app — with a pane, the same
435
+ header (see the snippets below) lives in the pane bar's centre and
436
+ drawing it twice would title the document twice. -->
437
+ {#if !pane}
438
+ <Bar
439
+ size="md"
440
+ content="xxs"
441
+ class="shrink-0 rounded-none [box-shadow:0_1px_0_var(--color-divider-faded)]"
442
+ >
443
+ {@render iconPicker(selected)}
377
444
 
378
- <Input
379
- reveal
380
- size="xxs"
381
- class="min-w-0 flex-1"
382
- aria-label="Note title"
383
- placeholder="Untitled"
384
- value={selected.title}
385
- oninput={(e: Event & { currentTarget: HTMLInputElement }) =>
386
- store.update(selected.id, { title: e.currentTarget.value })}
387
- />
445
+ <Input
446
+ reveal
447
+ size="xxs"
448
+ class="min-w-0 flex-1"
449
+ aria-label="Note title"
450
+ placeholder="Untitled"
451
+ value={selected.title}
452
+ oninput={(e: Event & { currentTarget: HTMLInputElement }) =>
453
+ store.update(selected.id, { title: e.currentTarget.value })}
454
+ />
388
455
 
389
- <!-- Fixed-width so the badge appearing and clearing never nudges the
390
- title field; `saving` is deliberately silent — the badge only
391
- reports a settled outcome. -->
392
- <span class="flex w-16 shrink-0 justify-end">
393
- {#if store.status === 'saved'}
394
- <Badge tone="success">saved</Badge>
395
- {:else if store.status === 'error'}
396
- <Badge tone="destructive" title={store.lastError ?? undefined}>error</Badge>
397
- {/if}
398
- </span>
399
- </Bar>
456
+ <!-- Fixed-width so the badge appearing and clearing never nudges the
457
+ title field; `saving` is deliberately silent — the badge only
458
+ reports a settled outcome. -->
459
+ <span class="flex w-16 shrink-0 justify-end">
460
+ {#if store.status === 'saved'}
461
+ <Badge tone="success">saved</Badge>
462
+ {:else if store.status === 'error'}
463
+ <Badge tone="destructive" title={store.lastError ?? undefined}>error</Badge>
464
+ {/if}
465
+ </span>
466
+ </Bar>
467
+ {/if}
400
468
 
401
469
  <div class="min-h-0 flex-1 overflow-x-hidden overflow-y-auto p-card">
402
470
  <!-- ProseMirror owns the document once running, so switching notes
@@ -1,3 +1,22 @@
1
+ /**
2
+ * Notes — a complete, client-side notes app.
3
+ *
4
+ * An era port of the term/web notes app: same TipTap editor, same slash menu
5
+ * and selection toolbar, same heading-tree index. Two things changed.
6
+ *
7
+ * PERSISTENCE. The original round-tripped every edit through a server via
8
+ * TanStack Query, which forced a whole dirty-override layer — local edits had
9
+ * to out-rank refetched query data, or an in-flight save clobbered what you
10
+ * were typing. Here the store IS the source of truth and persistence is a
11
+ * debounced write behind it, so the editor writes straight through and there
12
+ * is nothing to reconcile.
13
+ *
14
+ * CHROME. Everything visible is an era primitive (Bar, Button, Input, Badge,
15
+ * Popover, ScrollArea, AlertDialog, Skeleton) on era tokens, so the app
16
+ * follows the density, surface, corners, motion, and font axes like any other
17
+ * component in the library.
18
+ */
19
+ import { type Snippet } from 'svelte';
1
20
  import { NotesStore, type NotesAdapter } from './notes-store.svelte.js';
2
21
  import type { NoteInit } from './types.js';
3
22
  interface Props {
@@ -15,6 +34,18 @@ interface Props {
15
34
  noteId?: string | null;
16
35
  /** Notes created only when storage comes back empty — a first-run sample. */
17
36
  seed?: NoteInit[];
37
+ /**
38
+ * The pane hosting this app, if any. Structural on purpose — an os-layer
39
+ * `AppPane` satisfies it as-is, and Notes never has to import the os layer.
40
+ * When provided, the document header (icon picker, title, save status)
41
+ * moves out of the editor area and into the pane bar's CENTRE slot, the
42
+ * way the reference terminal app titles its notes window; it is cleared
43
+ * again on unmount. Omit it and the header renders above the editor as a
44
+ * normal in-app bar.
45
+ */
46
+ pane?: {
47
+ center?: Snippet;
48
+ } | null;
18
49
  class?: string;
19
50
  }
20
51
  declare const Notes: import("svelte").Component<Props, {}, "noteId">;