@signal9/era-ui 4.14.1 → 4.15.1

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.
@@ -209,6 +209,26 @@
209
209
  text-box-edge: cap alphabetic;
210
210
  }
211
211
 
212
+ /* FALLBACK for engines without text-box (Firefox): the trim above silently
213
+ * disappears there, and the first line opened ~0.47em too low (measured:
214
+ * cap 16.4px from the top against 8px from the left, dense/mono) — the
215
+ * exact asymmetry the trim exists to remove. Approximate the same cut with
216
+ * a negative first-block margin: -0.45em is the first line's half-leading
217
+ * (0.3em at line-height 1.6) plus the ascent-above-cap of the ui stacks
218
+ * (~0.15-0.17em). In em on purpose — it scales with the FIRST BLOCK's own
219
+ * size, so a note opening on an h1 and one opening on a paragraph both
220
+ * land their cap on the container inset, which one padding value could
221
+ * never do for both (the reference terminal app's pt-1 is this same idea,
222
+ * tuned only for paragraphs). The margin collapses up through .tiptap and
223
+ * stops at the scroll container's padding, which is where it should act.
224
+ * Within ~1px of the trimmed geometry; engines with text-box never match
225
+ * this block and keep the exact version. */
226
+ @supports not ((text-box-trim: trim-start) and (text-box-edge: cap alphabetic)) {
227
+ .era-notes-prose :global(.tiptap > :first-child) {
228
+ margin-top: -0.45em;
229
+ }
230
+ }
231
+
212
232
  /* Placeholder, via @tiptap/extension-placeholder */
213
233
  .era-notes-prose :global(.tiptap p.is-editor-empty:first-child::before) {
214
234
  content: attr(data-placeholder);
@@ -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,95 @@
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 titleField(note: Note, cls: string)}
220
+ <Input
221
+ reveal
222
+ size="xxs"
223
+ class={cls}
224
+ aria-label="Note title"
225
+ placeholder="Untitled"
226
+ value={note.title}
227
+ oninput={(e: Event & { currentTarget: HTMLInputElement }) =>
228
+ store.update(note.id, { title: e.currentTarget.value })}
229
+ />
230
+ {/snippet}
231
+
232
+ {#snippet statusBadge()}
233
+ {#if store.status === 'saved'}
234
+ <Badge tone="success">saved</Badge>
235
+ {:else if store.status === 'error'}
236
+ <Badge tone="destructive" title={store.lastError ?? undefined}>error</Badge>
237
+ {/if}
238
+ {/snippet}
239
+
240
+ {#snippet paneHeader()}
241
+ {#if selected}
242
+ <div class="relative flex min-w-0 items-center gap-(--era-gap)" data-pane-control>
243
+ {@render iconPicker(selected)}
244
+ {@render titleField(selected, 'field-sizing-content max-w-56 min-w-0 text-center')}
245
+ <!-- Absolutely positioned, so the badge appearing never nudges the
246
+ centred title; empty when the status is quiet, which costs
247
+ nothing off-flow. -->
248
+ <span class="absolute top-1/2 left-full ml-(--era-gap) -translate-y-1/2">
249
+ {@render statusBadge()}
250
+ </span>
251
+ </div>
252
+ {/if}
253
+ {/snippet}
254
+
141
255
  <div
142
256
  class={cn('flex h-full min-h-0 text-body text-fg', className)}
143
257
  use:keys={{
@@ -163,11 +277,7 @@
163
277
  centred in 23px and sat 2.5px from the top against a 3px wall. Outset
164
278
  costs no layout, so the bar keeps its full tier height and every child
165
279
  sits the wall's distance from all four edges. -->
166
- <Bar
167
- size="md"
168
- content="xxs"
169
- class="shrink-0 rounded-none [box-shadow:0_1px_0_var(--color-divider-faded)]"
170
- >
280
+ <Bar size="md" content="xxs" divider class="rounded-none">
171
281
  <Input
172
282
  icon={Search}
173
283
  size="xxs"
@@ -326,77 +436,21 @@
326
436
  <!-- Matches the sidebar's filter bar exactly — the two headers sit side by
327
437
  side across the top of the app, so they must be the same tier. -->
328
438
  <!-- 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>
377
-
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
- />
388
-
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>
439
+ <!-- Rendered only when no pane hosts the app — with a pane, the same
440
+ header (see the snippets below) lives in the pane bar's centre and
441
+ drawing it twice would title the document twice. -->
442
+ {#if !pane}
443
+ <Bar size="md" content="xxs" divider class="rounded-none">
444
+ {@render iconPicker(selected)}
445
+ {@render titleField(selected, 'min-w-0 flex-1')}
446
+ <!-- Fixed-width so the badge appearing and clearing never nudges the
447
+ title field; `saving` is deliberately silent — the badge only
448
+ reports a settled outcome. -->
449
+ <span class="flex w-16 shrink-0 justify-end">
450
+ {@render statusBadge()}
451
+ </span>
452
+ </Bar>
453
+ {/if}
400
454
 
401
455
  <div class="min-h-0 flex-1 overflow-x-hidden overflow-y-auto p-card">
402
456
  <!-- 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">;
@@ -49,7 +49,6 @@ function ownTextLines(el) {
49
49
  tops.add(Math.round(rect.top * 2) / 2);
50
50
  }
51
51
  }
52
- range.detach();
53
52
  return tops.size;
54
53
  }
55
54
  export const proseLeading = {
@@ -58,11 +57,24 @@ export const proseLeading = {
58
57
  description: 'Text that wraps to a second line must set its own leading (leading-body) — otherwise it inherits a fixed-height row’s control leading and the lines collide.',
59
58
  category: 'layout',
60
59
  severity: 'error',
61
- // Only elements that can hold text directly; a <div> wrapper with no text of
62
- // its own has nothing to measure and is skipped by ownTextLines anyway.
63
- selector: '*',
60
+ // Text-bearing elements only. The audit is about PROSE, and the wrapping the
61
+ // check measures happens on the element that owns the text nodes — chrome
62
+ // tags (svg, input, button…) can't fire it, so they never pay for a style
63
+ // read. div/span stay in: half the wrapping copy in this library lives in
64
+ // them.
65
+ selector: 'p, span, div, li, dd, dt, td, th, label, a, blockquote, figcaption, h1, h2, h3, h4, h5, h6',
64
66
  check(el) {
65
- if (!el.textContent?.trim())
67
+ // Cheapest gate first: any own text at all? A scan over direct children
68
+ // short-circuits; textContent would serialize the whole subtree on every
69
+ // wrapper in the tree.
70
+ let hasText = false;
71
+ for (const n of el.childNodes) {
72
+ if (n.nodeType === Node.TEXT_NODE && n.textContent.trim()) {
73
+ hasText = true;
74
+ break;
75
+ }
76
+ }
77
+ if (!hasText)
66
78
  return null;
67
79
  const s = getComputedStyle(el);
68
80
  // Text that cannot wrap cannot have this bug — the whole defect is the
@@ -76,16 +88,17 @@ export const proseLeading = {
76
88
  const ratio = lineHeight / fontSize;
77
89
  if (ratio >= PROSE_RATIO)
78
90
  return null;
79
- // The expensive half, deliberately last: only measure once the leading is
80
- // already known to be too tight.
81
- if (ownTextLines(el) < 2)
91
+ // The expensive half (a Range + getClientRects is a forced layout read),
92
+ // deliberately last and measured once: only elements whose leading is
93
+ // already known to be too tight pay for it.
94
+ const lines = ownTextLines(el);
95
+ if (lines < 2)
82
96
  return null;
83
- const issue = {
97
+ return {
84
98
  auditId: 'typography/prose-leading',
85
99
  element: el,
86
100
  message: `wrapped text at line-height ${ratio.toFixed(2)}× (${lineHeight.toFixed(1)}px on ${fontSize}px type) — this is control leading, inherited from a fixed-height row. Add leading-body.`,
87
- details: { ratio: +ratio.toFixed(2), lineHeight, fontSize, lines: ownTextLines(el) }
101
+ details: { ratio: +ratio.toFixed(2), lineHeight, fontSize, lines }
88
102
  };
89
- return issue;
90
103
  }
91
104
  };