@signal9/era-ui 12.1.2 → 13.0.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.
- package/dist/dev/audit-overlay.svelte +1 -1
- package/dist/docs/css-utilities.js +6 -1
- package/dist/docs/notes.md +12 -11
- package/dist/era-ui.css +1 -1
- package/dist/generated-docs/badge.md +3 -0
- package/dist/generated-docs/button.md +4 -0
- package/dist/generated-docs/chip.md +3 -0
- package/dist/generated-docs/llms-full.txt +53 -31
- package/dist/generated-docs/llms.txt +1 -1
- package/dist/generated-docs/manifest.json +17 -17
- package/dist/generated-docs/notes.md +12 -11
- package/dist/generated-docs/utilities.json +10 -10
- package/dist/generated-docs/utilities.md +30 -19
- package/dist/os/index.d.ts +0 -1
- package/dist/os/index.js +0 -1
- package/dist/os/notification-center.svelte +1 -2
- package/dist/os/taskbar.svelte +2 -3
- package/dist/os/toaster.svelte +2 -3
- package/dist/styles/index.css +77 -12
- package/dist/styles/layers.css +48 -0
- package/dist/styles/surfaces/base.css +4 -2
- package/dist/styles/surfaces/glass.css +0 -13
- package/dist/theme/index.d.ts +87 -0
- package/dist/theme/index.js +86 -0
- package/dist/ui/alert-dialog/alert-dialog-content.svelte +2 -2
- package/dist/ui/alert-dialog/alert-dialog-overlay.svelte +1 -1
- package/dist/ui/badge/badge.svelte +14 -1
- package/dist/ui/badge/badge.svelte.d.ts +4 -0
- package/dist/ui/button/button.svelte +37 -7
- package/dist/ui/button/button.svelte.d.ts +6 -0
- package/dist/ui/chip/chip.svelte +14 -1
- package/dist/ui/chip/chip.svelte.d.ts +4 -0
- package/dist/ui/combobox/combobox-content.svelte +1 -1
- package/dist/ui/command-bar/command-bar.svelte +1 -1
- package/dist/ui/context-menu/context-menu-content.svelte +1 -1
- package/dist/ui/context-menu/context-menu-sub-content.svelte +1 -1
- package/dist/ui/date-picker/date-picker-content.svelte +1 -1
- package/dist/ui/date-range-picker/date-range-picker-content.svelte +1 -1
- package/dist/ui/dialog/dialog-content.svelte +1 -1
- package/dist/ui/dialog/dialog-overlay.svelte +5 -1
- package/dist/ui/dropdown-menu/dropdown-menu-content.svelte +1 -1
- package/dist/ui/dropdown-menu/dropdown-menu-sub-content.svelte +1 -1
- package/dist/ui/link-preview/link-preview-content.svelte +1 -1
- package/dist/ui/menubar/menubar-content.svelte +1 -1
- package/dist/ui/menubar/menubar-sub-content.svelte +1 -1
- package/dist/ui/popover/popover-content.svelte +1 -1
- package/dist/ui/select/select-content.svelte +1 -1
- package/dist/ui/sheet/sheet-content.svelte +1 -1
- package/dist/ui/sheet/sheet-content.svelte.d.ts +1 -1
- package/dist/ui/tooltip/tooltip-content.svelte +1 -1
- package/package.json +9 -12
- package/dist/apps/index.d.ts +0 -20
- package/dist/apps/index.js +0 -20
- package/dist/apps/llm-shell/index.d.ts +0 -2
- package/dist/apps/llm-shell/index.js +0 -1
- package/dist/apps/llm-shell/llm-shell.svelte +0 -334
- package/dist/apps/llm-shell/llm-shell.svelte.d.ts +0 -57
- package/dist/apps/llm-shell/llm-swarm.svelte +0 -79
- package/dist/apps/llm-shell/llm-swarm.svelte.d.ts +0 -25
- package/dist/apps/llm-shell/types.d.ts +0 -103
- package/dist/apps/llm-shell/types.js +0 -1
- package/dist/apps/notes/editor/bubble-menu.svelte.d.ts +0 -11
- package/dist/apps/notes/editor/bubble-menu.svelte.js +0 -165
- package/dist/apps/notes/editor/bubble-toolbar.svelte +0 -55
- package/dist/apps/notes/editor/bubble-toolbar.svelte.d.ts +0 -22
- package/dist/apps/notes/editor/extensions.d.ts +0 -18
- package/dist/apps/notes/editor/extensions.js +0 -88
- package/dist/apps/notes/editor/floating.d.ts +0 -25
- package/dist/apps/notes/editor/floating.js +0 -54
- package/dist/apps/notes/editor/link.d.ts +0 -9
- package/dist/apps/notes/editor/link.js +0 -22
- package/dist/apps/notes/editor/list-cleanup-rule.d.ts +0 -14
- package/dist/apps/notes/editor/list-cleanup-rule.js +0 -47
- package/dist/apps/notes/editor/slash-command.svelte.d.ts +0 -22
- package/dist/apps/notes/editor/slash-command.svelte.js +0 -215
- package/dist/apps/notes/editor/slash-menu.svelte +0 -82
- package/dist/apps/notes/editor/slash-menu.svelte.d.ts +0 -23
- package/dist/apps/notes/index.d.ts +0 -11
- package/dist/apps/notes/index.js +0 -13
- package/dist/apps/notes/markdown.d.ts +0 -12
- package/dist/apps/notes/markdown.js +0 -165
- package/dist/apps/notes/note-editor.svelte +0 -176
- package/dist/apps/notes/note-editor.svelte.d.ts +0 -36
- package/dist/apps/notes/notes-store.svelte.d.ts +0 -64
- package/dist/apps/notes/notes-store.svelte.js +0 -234
- package/dist/apps/notes/notes.svelte +0 -501
- package/dist/apps/notes/notes.svelte.d.ts +0 -53
- package/dist/apps/notes/tree.d.ts +0 -70
- package/dist/apps/notes/tree.js +0 -185
- package/dist/apps/notes/types.d.ts +0 -49
- package/dist/apps/notes/types.js +0 -11
- package/dist/os/layers.d.ts +0 -23
- package/dist/os/layers.js +0 -23
|
@@ -1,501 +0,0 @@
|
|
|
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, type Snippet } 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 ContextMenu from '../../ui/context-menu/index.js';
|
|
34
|
-
import * as Popover from '../../ui/popover/index.js';
|
|
35
|
-
import * as ScrollArea from '../../ui/scroll-area/index.js';
|
|
36
|
-
import { cn, keys } from '../../utils/index.js';
|
|
37
|
-
import NoteEditor from './note-editor.svelte';
|
|
38
|
-
import { NotesStore, localStorageAdapter, type NotesAdapter } from './notes-store.svelte.js';
|
|
39
|
-
import type { Note, NoteInit, TiptapDocument } from './types.js';
|
|
40
|
-
|
|
41
|
-
interface Props {
|
|
42
|
-
/**
|
|
43
|
-
* Bring your own store — for a custom adapter, or to share one set of
|
|
44
|
-
* notes between two mounted views. When supplied, the host owns its
|
|
45
|
-
* lifecycle: this component will not destroy it on unmount.
|
|
46
|
-
*/
|
|
47
|
-
store?: NotesStore;
|
|
48
|
-
/** Persistence for the store this component creates. Ignored if `store` is set. */
|
|
49
|
-
adapter?: NotesAdapter;
|
|
50
|
-
/** `localStorage` key for the default adapter. Ignored if `store`/`adapter` is set. */
|
|
51
|
-
storageKey?: string;
|
|
52
|
-
/** The open note. Bindable, so a host can deep-link or restore a session. */
|
|
53
|
-
noteId?: string | null;
|
|
54
|
-
/** Notes created only when storage comes back empty — a first-run sample. */
|
|
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;
|
|
66
|
-
class?: string;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
let {
|
|
70
|
-
store: providedStore,
|
|
71
|
-
adapter,
|
|
72
|
-
storageKey,
|
|
73
|
-
noteId = $bindable(null),
|
|
74
|
-
seed,
|
|
75
|
-
pane,
|
|
76
|
-
class: className
|
|
77
|
-
}: Props = $props();
|
|
78
|
-
|
|
79
|
-
// Resolved once, on purpose — untrack() is the declaration of that intent, not
|
|
80
|
-
// a workaround. A store swapped in mid-flight would strand the notes currently
|
|
81
|
-
// on screen and the editor mounted over them.
|
|
82
|
-
const owned = untrack(() => providedStore === undefined);
|
|
83
|
-
const store = untrack(
|
|
84
|
-
() => providedStore ?? new NotesStore({ adapter: adapter ?? localStorageAdapter(storageKey) })
|
|
85
|
-
);
|
|
86
|
-
|
|
87
|
-
let query = $state('');
|
|
88
|
-
let pendingDelete = $state<string | null>(null);
|
|
89
|
-
let iconPickerOpen = $state(false);
|
|
90
|
-
|
|
91
|
-
const visible = $derived(store.search(query));
|
|
92
|
-
const selected = $derived(store.get(noteId));
|
|
93
|
-
const pendingDeleteNote = $derived(store.get(pendingDelete));
|
|
94
|
-
|
|
95
|
-
/** A compact glyph set — enough to tell notes apart at a glance, no picker UI. */
|
|
96
|
-
const ICON_ROWS = [
|
|
97
|
-
['📝', '📋', '📌', '💡', '🔥', '⭐'],
|
|
98
|
-
['🎯', '🚀', '🔧', '📦', '🔒', '🔑'],
|
|
99
|
-
['💻', '🤖', '🧠', '⚡', '🔍', '🌐']
|
|
100
|
-
];
|
|
101
|
-
|
|
102
|
-
onMount(async () => {
|
|
103
|
-
// `loading` is only true before a first successful load, so this is also the
|
|
104
|
-
// flag for "this mount is the one that hydrated the store". Seeding keys off
|
|
105
|
-
// it rather than off `owned`: a second view sharing an already-loaded store
|
|
106
|
-
// must not seed again, but a HOST-supplied store that comes back empty
|
|
107
|
-
// should still get its first-run notes.
|
|
108
|
-
const hydrating = store.loading;
|
|
109
|
-
if (hydrating) await store.load();
|
|
110
|
-
if (hydrating && seed?.length && store.notes.length === 0) {
|
|
111
|
-
for (const init of seed) store.create(init);
|
|
112
|
-
}
|
|
113
|
-
if (!store.get(noteId)) noteId = store.ordered[0]?.id ?? null;
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
onDestroy(() => {
|
|
117
|
-
if (owned) store.destroy();
|
|
118
|
-
});
|
|
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
|
-
|
|
134
|
-
function createNote() {
|
|
135
|
-
const note = store.create();
|
|
136
|
-
noteId = note.id;
|
|
137
|
-
query = '';
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
function confirmDelete() {
|
|
141
|
-
const id = pendingDelete;
|
|
142
|
-
pendingDelete = null;
|
|
143
|
-
if (!id) return;
|
|
144
|
-
store.remove(id);
|
|
145
|
-
if (noteId === id) noteId = store.ordered[0]?.id ?? null;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/** Short relative age — the list has a single cell for it, so it must stay narrow. */
|
|
149
|
-
function formatAge(ts: number) {
|
|
150
|
-
const d = Date.now() - ts;
|
|
151
|
-
if (d < 60_000) return 'now';
|
|
152
|
-
if (d < 3_600_000) return `${Math.floor(d / 60_000)}m`;
|
|
153
|
-
if (d < 86_400_000) return `${Math.floor(d / 3_600_000)}h`;
|
|
154
|
-
return new Date(ts).toLocaleDateString();
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
function onRowKeydown(event: KeyboardEvent) {
|
|
158
|
-
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
|
|
159
|
-
event.preventDefault();
|
|
160
|
-
const row = event.currentTarget as HTMLElement;
|
|
161
|
-
const next = event.key === 'ArrowDown' ? row.nextElementSibling : row.previousElementSibling;
|
|
162
|
-
(next as HTMLElement | null)?.focus();
|
|
163
|
-
}
|
|
164
|
-
</script>
|
|
165
|
-
|
|
166
|
-
{#snippet iconPicker(note: Note)}
|
|
167
|
-
<Popover.Root bind:open={iconPickerOpen}>
|
|
168
|
-
<Popover.Trigger icon size="pill" 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="control"
|
|
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="chip"
|
|
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="pill"
|
|
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-gutter" 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-gutter -translate-y-1/2">
|
|
249
|
-
{@render statusBadge()}
|
|
250
|
-
</span>
|
|
251
|
-
</div>
|
|
252
|
-
{/if}
|
|
253
|
-
{/snippet}
|
|
254
|
-
|
|
255
|
-
<div
|
|
256
|
-
class={cn('flex h-full min-h-0 text-body text-fg', className)}
|
|
257
|
-
use:keys={{
|
|
258
|
-
// Persistence is already debounced; Ctrl/Cmd-S is the reassurance gesture
|
|
259
|
-
// people press anyway, wired to a real immediate flush.
|
|
260
|
-
'$mod+s': (e) => {
|
|
261
|
-
e.preventDefault();
|
|
262
|
-
void store.flush();
|
|
263
|
-
}
|
|
264
|
-
}}
|
|
265
|
-
>
|
|
266
|
-
<!-- Sidebar: filter + note list -->
|
|
267
|
-
<div class="flex w-56 shrink-0 flex-col border-r border-divider-faded">
|
|
268
|
-
<!-- content="pill": 24/30/36/48 of chrome around an 18/20/22/26 field and
|
|
269
|
-
button, with Bar deriving the two-tier concentric inset. A notes sidebar
|
|
270
|
-
is chrome around a list; at the lg/md pairing it was a 32px bar over
|
|
271
|
-
24px rows and the chrome outweighed the content. This is the tier pair
|
|
272
|
-
term's notes app uses — its hardcoded 3px is what Bar now derives at
|
|
273
|
-
dense. The field keeps its fill: xxs is the smallest tier a real field
|
|
274
|
-
fits on. -->
|
|
275
|
-
<!-- The divider is drawn OUTSIDE the box, exactly as Pane.Handle draws its
|
|
276
|
-
own: a border consumes a pixel of the bar's content box, so its children
|
|
277
|
-
centred in 23px and sat 2.5px from the top against a 3px wall. Outset
|
|
278
|
-
costs no layout, so the bar keeps its full tier height and every child
|
|
279
|
-
sits the wall's distance from all four edges. -->
|
|
280
|
-
<Bar size="control" content="pill" divider class="rounded-none">
|
|
281
|
-
<Input
|
|
282
|
-
icon={Search}
|
|
283
|
-
size="pill"
|
|
284
|
-
class="min-w-0 flex-1"
|
|
285
|
-
type="text"
|
|
286
|
-
placeholder="Filter…"
|
|
287
|
-
aria-label="Filter notes"
|
|
288
|
-
bind:value={query}
|
|
289
|
-
/>
|
|
290
|
-
<Button icon size="pill" aria-label="New note" title="New note" onclick={createNote}>
|
|
291
|
-
<Plus />
|
|
292
|
-
</Button>
|
|
293
|
-
</Bar>
|
|
294
|
-
|
|
295
|
-
{#if store.loading}
|
|
296
|
-
<div class="flex flex-col gap-gutter p-gutter">
|
|
297
|
-
{#each Array.from({ length: 5 }, (_, i) => i) as i (i)}
|
|
298
|
-
<Skeleton class="h-control rounded-control" />
|
|
299
|
-
{/each}
|
|
300
|
-
</div>
|
|
301
|
-
{:else if visible.length === 0}
|
|
302
|
-
<!-- No call to action here: the document pane already carries the primary
|
|
303
|
-
one, and the bar's + is always within reach. Two competing CTAs for
|
|
304
|
-
an empty list reads as indecision. -->
|
|
305
|
-
<div
|
|
306
|
-
class="flex flex-1 flex-col items-center justify-center gap-gutter p-section text-center"
|
|
307
|
-
>
|
|
308
|
-
<p class="text-muted">{query ? 'No matches' : 'No notes yet'}</p>
|
|
309
|
-
</div>
|
|
310
|
-
{:else}
|
|
311
|
-
<ScrollArea.Root class="min-h-0 flex-1 rounded-none">
|
|
312
|
-
<ScrollArea.Viewport>
|
|
313
|
-
<div class="flex flex-col gap-gutter p-gutter" role="listbox" aria-label="Notes">
|
|
314
|
-
{#each visible as note (note.id)}
|
|
315
|
-
{@const current = note.id === noteId}
|
|
316
|
-
<!-- The row is the selectable option; the pin/delete buttons inside it
|
|
317
|
-
are separate actions, so they stop propagation rather than
|
|
318
|
-
selecting the note out from under the click.
|
|
319
|
-
Right-click opens the same two actions as a real ContextMenu.
|
|
320
|
-
`child` delegation is load-bearing: the trigger's props land ON
|
|
321
|
-
the row instead of wrapping it, so the row stays a direct
|
|
322
|
-
role="option" child of the listbox. A wrapper element there
|
|
323
|
-
would put a non-option between them and break the relationship
|
|
324
|
-
screen readers rely on.
|
|
325
|
-
rounded-control, not rounded-menu-item: item-rd is the radius for a row
|
|
326
|
-
inside a PANEL (it subtracts the panel's frame inset). A list
|
|
327
|
-
row on a plain surface takes its own tier radius — the
|
|
328
|
-
conversations-rail pattern. -->
|
|
329
|
-
<ContextMenu.Root>
|
|
330
|
-
<ContextMenu.Trigger>
|
|
331
|
-
{#snippet child({ props })}
|
|
332
|
-
<div
|
|
333
|
-
{...props}
|
|
334
|
-
class="group/row flex h-control cursor-pointer items-center gap-pill-inset-control rounded-control px-pill-inset-control leading-none {current
|
|
335
|
-
? 'bg-hover text-bright shadow-pressed'
|
|
336
|
-
: 'hover:bg-highlight hover:shadow-highlight'}"
|
|
337
|
-
role="option"
|
|
338
|
-
aria-selected={current}
|
|
339
|
-
tabindex="0"
|
|
340
|
-
onclick={() => (noteId = note.id)}
|
|
341
|
-
onkeydown={(e: KeyboardEvent) => {
|
|
342
|
-
if (e.key === 'Enter' || e.key === ' ') {
|
|
343
|
-
e.preventDefault();
|
|
344
|
-
noteId = note.id;
|
|
345
|
-
return;
|
|
346
|
-
}
|
|
347
|
-
onRowKeydown(e);
|
|
348
|
-
}}
|
|
349
|
-
>
|
|
350
|
-
<span class="flex size-pill shrink-0 items-center justify-center">
|
|
351
|
-
{#if note.icon}
|
|
352
|
-
{note.icon}
|
|
353
|
-
{:else}
|
|
354
|
-
<FileText class="size-glyph-pill text-muted" />
|
|
355
|
-
{/if}
|
|
356
|
-
</span>
|
|
357
|
-
|
|
358
|
-
<span class="min-w-0 flex-1 truncate">{note.title || 'Untitled'}</span>
|
|
359
|
-
|
|
360
|
-
<!-- One cell, two occupants: the age at rest, the actions on
|
|
361
|
-
hover. Swapping them in place keeps the row from reflowing.
|
|
362
|
-
At rest EVERY row shows its age and nothing else. A pinned
|
|
363
|
-
row used to put a pin glyph here instead — the same glyph,
|
|
364
|
-
in the same slot, as the pin BUTTON one state away — so a
|
|
365
|
-
pinned row read as a row whose actions were stuck on. Being
|
|
366
|
-
pinned is already visible: those rows sort to the top, and
|
|
367
|
-
the button carries its latched state once you are on the
|
|
368
|
-
row. -->
|
|
369
|
-
<span class="shrink-0 text-muted group-hover/row:hidden">
|
|
370
|
-
{formatAge(note.updatedAt)}
|
|
371
|
-
</span>
|
|
372
|
-
<span
|
|
373
|
-
class="hidden shrink-0 items-center gap-pill-inset-control group-hover/row:flex"
|
|
374
|
-
>
|
|
375
|
-
<Button
|
|
376
|
-
icon
|
|
377
|
-
size="pill"
|
|
378
|
-
aria-label={note.pinned ? 'Unpin note' : 'Pin note'}
|
|
379
|
-
title={note.pinned ? 'Unpin note' : 'Pin note'}
|
|
380
|
-
active={note.pinned}
|
|
381
|
-
onclick={(e: MouseEvent) => {
|
|
382
|
-
e.stopPropagation();
|
|
383
|
-
store.togglePin(note.id);
|
|
384
|
-
}}
|
|
385
|
-
>
|
|
386
|
-
{#if note.pinned}<PinOff />{:else}<Pin />{/if}
|
|
387
|
-
</Button>
|
|
388
|
-
<Button
|
|
389
|
-
icon
|
|
390
|
-
size="pill"
|
|
391
|
-
tone="destructive"
|
|
392
|
-
aria-label="Delete note"
|
|
393
|
-
title="Delete note"
|
|
394
|
-
onclick={(e: MouseEvent) => {
|
|
395
|
-
e.stopPropagation();
|
|
396
|
-
pendingDelete = note.id;
|
|
397
|
-
}}
|
|
398
|
-
>
|
|
399
|
-
<Trash2 />
|
|
400
|
-
</Button>
|
|
401
|
-
</span>
|
|
402
|
-
</div>
|
|
403
|
-
{/snippet}
|
|
404
|
-
</ContextMenu.Trigger>
|
|
405
|
-
<ContextMenu.Content>
|
|
406
|
-
<ContextMenu.Item onSelect={() => store.togglePin(note.id)}>
|
|
407
|
-
{#if note.pinned}
|
|
408
|
-
<PinOff class="size-icon text-muted" />
|
|
409
|
-
Unpin note
|
|
410
|
-
{:else}
|
|
411
|
-
<Pin class="size-icon text-muted" />
|
|
412
|
-
Pin note
|
|
413
|
-
{/if}
|
|
414
|
-
</ContextMenu.Item>
|
|
415
|
-
<!-- No separator: a rule groups things, and two actions on one row
|
|
416
|
-
are one group — it was drawing a boundary through the middle of
|
|
417
|
-
a pair. The panel's gutter gives them their own breathing room. -->
|
|
418
|
-
<!-- Routed through the same confirm dialog the toolbar button
|
|
419
|
-
uses, not a direct delete: a destructive action should not
|
|
420
|
-
get easier to reach by arriving from a different gesture. -->
|
|
421
|
-
<ContextMenu.Item onSelect={() => (pendingDelete = note.id)}>
|
|
422
|
-
<Trash2 class="size-icon text-destructive" />
|
|
423
|
-
Delete note
|
|
424
|
-
</ContextMenu.Item>
|
|
425
|
-
</ContextMenu.Content>
|
|
426
|
-
</ContextMenu.Root>
|
|
427
|
-
{/each}
|
|
428
|
-
</div>
|
|
429
|
-
</ScrollArea.Viewport>
|
|
430
|
-
<ScrollArea.Scrollbar orientation="vertical">
|
|
431
|
-
<ScrollArea.Thumb />
|
|
432
|
-
</ScrollArea.Scrollbar>
|
|
433
|
-
</ScrollArea.Root>
|
|
434
|
-
{/if}
|
|
435
|
-
</div>
|
|
436
|
-
|
|
437
|
-
<!-- Document pane -->
|
|
438
|
-
<div class="flex min-w-0 flex-1 flex-col">
|
|
439
|
-
{#if selected}
|
|
440
|
-
<!-- Matches the sidebar's filter bar exactly — the two headers sit side by
|
|
441
|
-
side across the top of the app, so they must be the same tier. -->
|
|
442
|
-
<!-- Same outset divider as the sidebar's filter bar above. -->
|
|
443
|
-
<!-- Rendered only when no pane hosts the app — with a pane, the same
|
|
444
|
-
header (see the snippets below) lives in the pane bar's centre and
|
|
445
|
-
drawing it twice would title the document twice. -->
|
|
446
|
-
{#if !pane}
|
|
447
|
-
<Bar size="control" content="pill" divider class="rounded-none">
|
|
448
|
-
{@render iconPicker(selected)}
|
|
449
|
-
{@render titleField(selected, 'min-w-0 flex-1')}
|
|
450
|
-
<!-- Fixed-width so the badge appearing and clearing never nudges the
|
|
451
|
-
title field; `saving` is deliberately silent — the badge only
|
|
452
|
-
reports a settled outcome. -->
|
|
453
|
-
<span class="flex w-16 shrink-0 justify-end">
|
|
454
|
-
{@render statusBadge()}
|
|
455
|
-
</span>
|
|
456
|
-
</Bar>
|
|
457
|
-
{/if}
|
|
458
|
-
|
|
459
|
-
<div class="min-h-0 flex-1 overflow-x-hidden overflow-y-auto p-card">
|
|
460
|
-
<!-- ProseMirror owns the document once running, so switching notes
|
|
461
|
-
rebuilds the editor rather than pushing new content into it. -->
|
|
462
|
-
{#key selected.id}
|
|
463
|
-
<NoteEditor
|
|
464
|
-
content={selected.content}
|
|
465
|
-
onUpdate={(json) => store.update(selected.id, { content: json as TiptapDocument })}
|
|
466
|
-
/>
|
|
467
|
-
{/key}
|
|
468
|
-
</div>
|
|
469
|
-
{:else}
|
|
470
|
-
<div class="flex flex-1 flex-col items-center justify-center gap-gutter p-section">
|
|
471
|
-
<p class="text-muted">
|
|
472
|
-
{store.notes.length === 0 ? 'Create a note to get started' : 'Select a note'}
|
|
473
|
-
</p>
|
|
474
|
-
{#if store.notes.length === 0}
|
|
475
|
-
<Button onclick={createNote}>
|
|
476
|
-
<Plus />
|
|
477
|
-
New note
|
|
478
|
-
</Button>
|
|
479
|
-
{/if}
|
|
480
|
-
</div>
|
|
481
|
-
{/if}
|
|
482
|
-
</div>
|
|
483
|
-
</div>
|
|
484
|
-
|
|
485
|
-
<AlertDialog.Root
|
|
486
|
-
open={pendingDelete !== null}
|
|
487
|
-
onOpenChange={(open) => {
|
|
488
|
-
if (!open) pendingDelete = null;
|
|
489
|
-
}}
|
|
490
|
-
>
|
|
491
|
-
<AlertDialog.Content>
|
|
492
|
-
<AlertDialog.Title>Delete note</AlertDialog.Title>
|
|
493
|
-
<AlertDialog.Description>
|
|
494
|
-
“{pendingDeleteNote?.title || 'Untitled'}” will be removed. This cannot be undone.
|
|
495
|
-
</AlertDialog.Description>
|
|
496
|
-
<div class="flex justify-end gap-gutter">
|
|
497
|
-
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
|
|
498
|
-
<AlertDialog.Action tone="destructive" onclick={confirmDelete}>Delete</AlertDialog.Action>
|
|
499
|
-
</div>
|
|
500
|
-
</AlertDialog.Content>
|
|
501
|
-
</AlertDialog.Root>
|
|
@@ -1,53 +0,0 @@
|
|
|
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';
|
|
20
|
-
import { NotesStore, type NotesAdapter } from './notes-store.svelte.js';
|
|
21
|
-
import type { NoteInit } from './types.js';
|
|
22
|
-
interface Props {
|
|
23
|
-
/**
|
|
24
|
-
* Bring your own store — for a custom adapter, or to share one set of
|
|
25
|
-
* notes between two mounted views. When supplied, the host owns its
|
|
26
|
-
* lifecycle: this component will not destroy it on unmount.
|
|
27
|
-
*/
|
|
28
|
-
store?: NotesStore;
|
|
29
|
-
/** Persistence for the store this component creates. Ignored if `store` is set. */
|
|
30
|
-
adapter?: NotesAdapter;
|
|
31
|
-
/** `localStorage` key for the default adapter. Ignored if `store`/`adapter` is set. */
|
|
32
|
-
storageKey?: string;
|
|
33
|
-
/** The open note. Bindable, so a host can deep-link or restore a session. */
|
|
34
|
-
noteId?: string | null;
|
|
35
|
-
/** Notes created only when storage comes back empty — a first-run sample. */
|
|
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;
|
|
49
|
-
class?: string;
|
|
50
|
-
}
|
|
51
|
-
declare const Notes: import("svelte").Component<Props, {}, "noteId">;
|
|
52
|
-
type Notes = ReturnType<typeof Notes>;
|
|
53
|
-
export default Notes;
|
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Notes tree index — a hierarchical table of contents built by walking a
|
|
3
|
-
* TipTap document's heading structure.
|
|
4
|
-
*
|
|
5
|
-
* This is the retrieval half of the app. Instead of embedding notes and
|
|
6
|
-
* hoping vector similarity lands on the right paragraph, a model is handed
|
|
7
|
-
* `treeToText(tree)` as a table of contents and reasons about which section to
|
|
8
|
-
* open — then `extractSection()` returns just that slice, structure intact
|
|
9
|
-
* (PageIndex-style reasoning-based retrieval).
|
|
10
|
-
*
|
|
11
|
-
* Nothing here touches the editor or the DOM: it operates on stored documents,
|
|
12
|
-
* so it is equally usable from a tool handler, a worker, or a build step.
|
|
13
|
-
*/
|
|
14
|
-
import type { TiptapDocument, TiptapNode } from './types.js';
|
|
15
|
-
export interface NoteTreeNode {
|
|
16
|
-
title: string;
|
|
17
|
-
noteId?: string;
|
|
18
|
-
headingId?: string;
|
|
19
|
-
/** 0=root, 1=note, 2=H1, 3=H2, 4=H3 — heading level shifted by the note row. */
|
|
20
|
-
level: number;
|
|
21
|
-
/** Words in this section's own content, not its children's. */
|
|
22
|
-
wordCount: number;
|
|
23
|
-
children: NoteTreeNode[];
|
|
24
|
-
}
|
|
25
|
-
export interface SectionResult {
|
|
26
|
-
/** `Note Title > H2 > H3` */
|
|
27
|
-
headingPath: string;
|
|
28
|
-
/** Markdown for this section — structure-preserving, not flattened text. */
|
|
29
|
-
content: string;
|
|
30
|
-
wordCount: number;
|
|
31
|
-
}
|
|
32
|
-
export interface SectionNodes {
|
|
33
|
-
headingPath: string;
|
|
34
|
-
nodes: TiptapNode[];
|
|
35
|
-
}
|
|
36
|
-
/** Build a tree for one note's document. */
|
|
37
|
-
export declare function buildNoteTree(noteId: string, title: string, doc: TiptapDocument): NoteTreeNode;
|
|
38
|
-
/** Combine every note into one index tree. */
|
|
39
|
-
export declare function buildGlobalTree(notes: Array<{
|
|
40
|
-
id: string;
|
|
41
|
-
title: string;
|
|
42
|
-
content: TiptapDocument;
|
|
43
|
-
}>): NoteTreeNode;
|
|
44
|
-
/** Node count below (and including) this node. */
|
|
45
|
-
export declare function countNodes(node: NoteTreeNode): number;
|
|
46
|
-
/** Total words in this subtree. */
|
|
47
|
-
export declare function totalWords(node: NoteTreeNode): number;
|
|
48
|
-
/**
|
|
49
|
-
* Render the tree as YAML-shaped key/value text for a model to read.
|
|
50
|
-
*
|
|
51
|
-
* YAML-style indented pairs beat JSON on nested data in the 2025 retrieval
|
|
52
|
-
* benchmarks, and the labelled fields (`id`, `hid`) are copy-pasteable straight
|
|
53
|
-
* back into a `get(id)` / `extractSection(noteId, headingId)` call, which is
|
|
54
|
-
* the whole point of handing over a TOC.
|
|
55
|
-
*/
|
|
56
|
-
export declare function treeToText(tree: NoteTreeNode): string;
|
|
57
|
-
/**
|
|
58
|
-
* The raw nodes of one section: everything between the target heading and the
|
|
59
|
-
* next heading of the same or a higher level, plus the breadcrumb that led to
|
|
60
|
-
* it. Callers pick their own serialisation.
|
|
61
|
-
*/
|
|
62
|
-
export declare function extractSectionNodes(noteTitle: string, doc: TiptapDocument, targetHeadingId: string): SectionNodes | null;
|
|
63
|
-
/**
|
|
64
|
-
* One section, serialised to markdown.
|
|
65
|
-
*
|
|
66
|
-
* Markdown rather than flattened text on purpose: plain-text extraction garbles
|
|
67
|
-
* lists, tables, and code blocks, which is exactly the structure a model needs
|
|
68
|
-
* to answer from a retrieved section.
|
|
69
|
-
*/
|
|
70
|
-
export declare function extractSection(noteTitle: string, doc: TiptapDocument, targetHeadingId: string): SectionResult | null;
|