@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,70 @@
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;
@@ -0,0 +1,185 @@
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 { textContent, tiptapToMarkdown } from './markdown.js';
15
+ function countWords(text) {
16
+ return text.trim().split(/\s+/).filter(Boolean).length;
17
+ }
18
+ /** Build a tree for one note's document. */
19
+ export function buildNoteTree(noteId, title, doc) {
20
+ const root = { title, noteId, level: 1, wordCount: 0, children: [] };
21
+ if (!doc.content || doc.content.length === 0)
22
+ return root;
23
+ const stack = [root];
24
+ let currentText = '';
25
+ for (const node of doc.content) {
26
+ if (node.type === 'heading' && node.attrs?.level) {
27
+ // Text seen since the last heading belongs to the section we're leaving.
28
+ if (currentText.trim()) {
29
+ stack[stack.length - 1].wordCount += countWords(currentText);
30
+ currentText = '';
31
+ }
32
+ const headingLevel = node.attrs.level;
33
+ const treeLevel = headingLevel + 1; // note=1, H1=2, H2=3, H3=4
34
+ const headingNode = {
35
+ title: textContent(node),
36
+ headingId: node.attrs?.id ?? undefined,
37
+ level: treeLevel,
38
+ wordCount: 0,
39
+ children: []
40
+ };
41
+ // The parent is the deepest node still shallower than this heading.
42
+ while (stack.length > 1 && stack[stack.length - 1].level >= treeLevel) {
43
+ stack.pop();
44
+ }
45
+ stack[stack.length - 1].children.push(headingNode);
46
+ stack.push(headingNode);
47
+ }
48
+ else {
49
+ currentText += textContent(node) + ' ';
50
+ }
51
+ }
52
+ if (currentText.trim()) {
53
+ stack[stack.length - 1].wordCount += countWords(currentText);
54
+ }
55
+ // A note with no headings at all is one flat section.
56
+ if (root.children.length === 0 && root.wordCount === 0) {
57
+ root.wordCount = countWords(doc.content.map(textContent).join(' '));
58
+ }
59
+ return root;
60
+ }
61
+ /** Combine every note into one index tree. */
62
+ export function buildGlobalTree(notes) {
63
+ return {
64
+ title: 'Notes Index',
65
+ level: 0,
66
+ wordCount: 0,
67
+ children: notes.map((n) => buildNoteTree(n.id, n.title, n.content))
68
+ };
69
+ }
70
+ /** Node count below (and including) this node. */
71
+ export function countNodes(node) {
72
+ return 1 + node.children.reduce((sum, c) => sum + countNodes(c), 0);
73
+ }
74
+ /** Total words in this subtree. */
75
+ export function totalWords(node) {
76
+ return node.wordCount + node.children.reduce((sum, c) => sum + totalWords(c), 0);
77
+ }
78
+ /**
79
+ * Render the tree as YAML-shaped key/value text for a model to read.
80
+ *
81
+ * YAML-style indented pairs beat JSON on nested data in the 2025 retrieval
82
+ * benchmarks, and the labelled fields (`id`, `hid`) are copy-pasteable straight
83
+ * back into a `get(id)` / `extractSection(noteId, headingId)` call, which is
84
+ * the whole point of handing over a TOC.
85
+ */
86
+ export function treeToText(tree) {
87
+ if (tree.children.length === 0)
88
+ return '(no notes)';
89
+ const blocks = [];
90
+ for (const note of tree.children) {
91
+ const noteWords = totalWords(note);
92
+ const lines = [
93
+ `- title: ${note.title || 'Untitled'}`,
94
+ ` id: ${note.noteId ?? '?'}`,
95
+ ` words: ${noteWords}`
96
+ ];
97
+ if (note.children.length > 0) {
98
+ lines.push(' sections:');
99
+ renderSections(note.children, lines, 2);
100
+ }
101
+ blocks.push(lines.join('\n'));
102
+ }
103
+ return blocks.join('\n');
104
+ }
105
+ function renderSections(nodes, lines, depth) {
106
+ const indent = ' '.repeat(depth);
107
+ for (const node of nodes) {
108
+ const shortId = node.headingId ? node.headingId.slice(0, 8) : '';
109
+ lines.push(`${indent}- title: ${node.title || 'Untitled'}`);
110
+ if (shortId)
111
+ lines.push(`${indent} hid: ${shortId}`);
112
+ if (node.wordCount > 0)
113
+ lines.push(`${indent} words: ${node.wordCount}`);
114
+ if (node.children.length > 0) {
115
+ lines.push(`${indent} sections:`);
116
+ renderSections(node.children, lines, depth + 1);
117
+ }
118
+ }
119
+ }
120
+ function isHeading(node) {
121
+ return node.type === 'heading' && typeof node.attrs?.level === 'number';
122
+ }
123
+ function updateBreadcrumb(stack, title, level) {
124
+ while (stack.length > 1 && stack[stack.length - 1].level >= level) {
125
+ stack.pop();
126
+ }
127
+ stack.push({ title, level });
128
+ }
129
+ /**
130
+ * The raw nodes of one section: everything between the target heading and the
131
+ * next heading of the same or a higher level, plus the breadcrumb that led to
132
+ * it. Callers pick their own serialisation.
133
+ */
134
+ export function extractSectionNodes(noteTitle, doc, targetHeadingId) {
135
+ if (!doc.content)
136
+ return null;
137
+ const headingStack = [{ title: noteTitle, level: 0 }];
138
+ let startIdx = -1;
139
+ let targetLevel = 0;
140
+ for (let i = 0; i < doc.content.length; i++) {
141
+ const node = doc.content[i];
142
+ if (!isHeading(node))
143
+ continue;
144
+ updateBreadcrumb(headingStack, textContent(node), node.attrs.level);
145
+ const headingId = node.attrs?.id;
146
+ if (!headingId)
147
+ continue;
148
+ // `treeToText` emits 8-char prefixes, so accept a prefix as well as the
149
+ // full id — otherwise a model copying from the TOC never matches.
150
+ if (headingId === targetHeadingId || headingId.startsWith(targetHeadingId)) {
151
+ startIdx = i + 1;
152
+ targetLevel = node.attrs.level;
153
+ break;
154
+ }
155
+ }
156
+ if (startIdx < 0)
157
+ return null;
158
+ const headingPath = headingStack.map((h) => h.title).join(' > ');
159
+ const nodes = [];
160
+ for (let i = startIdx; i < doc.content.length; i++) {
161
+ const node = doc.content[i];
162
+ if (isHeading(node) && node.attrs.level <= targetLevel)
163
+ break;
164
+ nodes.push(node);
165
+ }
166
+ return { headingPath, nodes };
167
+ }
168
+ /**
169
+ * One section, serialised to markdown.
170
+ *
171
+ * Markdown rather than flattened text on purpose: plain-text extraction garbles
172
+ * lists, tables, and code blocks, which is exactly the structure a model needs
173
+ * to answer from a retrieved section.
174
+ */
175
+ export function extractSection(noteTitle, doc, targetHeadingId) {
176
+ const section = extractSectionNodes(noteTitle, doc, targetHeadingId);
177
+ if (!section)
178
+ return null;
179
+ const content = tiptapToMarkdown({ type: 'doc', content: section.nodes }).trim();
180
+ return {
181
+ headingPath: section.headingPath,
182
+ content,
183
+ wordCount: countWords(content)
184
+ };
185
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The Notes document model.
3
+ *
4
+ * TipTap's own `JSONContent` is deliberately loose (every field optional), so
5
+ * the app carries its own narrowed shapes: a `doc` always has `content`, and a
6
+ * `Note` always has the fields the list and the editor read. Nothing here
7
+ * imports from `@tiptap/*` — the model is the persistence contract, and a
8
+ * consumer storing notes server-side should be able to type its API against it
9
+ * without pulling the editor in.
10
+ */
11
+ export interface TiptapMark {
12
+ type: string;
13
+ attrs?: Record<string, unknown>;
14
+ }
15
+ export interface TiptapNode {
16
+ type: string;
17
+ attrs?: Record<string, unknown>;
18
+ marks?: TiptapMark[];
19
+ content?: TiptapNode[];
20
+ text?: string;
21
+ }
22
+ export interface TiptapDocument {
23
+ type: 'doc';
24
+ content: TiptapNode[];
25
+ }
26
+ export interface Note {
27
+ id: string;
28
+ title: string;
29
+ /** A single emoji/character shown in the list, or `null` for the default glyph. */
30
+ icon: string | null;
31
+ content: TiptapDocument;
32
+ /**
33
+ * Flattened text of `content`, refreshed on every write. Kept denormalised
34
+ * so filtering the list never has to walk every document's AST.
35
+ */
36
+ plainText: string;
37
+ pinned: boolean;
38
+ /** Epoch milliseconds — JSON-safe, so a note survives a `structuredClone` or a
39
+ * `JSON.stringify` round-trip through storage without a revive step. */
40
+ createdAt: number;
41
+ updatedAt: number;
42
+ }
43
+ /** The projection the sidebar list renders — no document body. */
44
+ export type NoteListItem = Pick<Note, 'id' | 'title' | 'icon' | 'pinned' | 'updatedAt'>;
45
+ /** Fields a caller may hand to `NotesStore.create()`. */
46
+ export type NoteInit = Partial<Pick<Note, 'title' | 'icon' | 'content' | 'pinned'>>;
47
+ /** Fields `NotesStore.update()` accepts. */
48
+ export type NotePatch = Partial<Pick<Note, 'title' | 'icon' | 'content' | 'pinned'>>;
49
+ export declare const EMPTY_DOC: TiptapDocument;
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The Notes document model.
3
+ *
4
+ * TipTap's own `JSONContent` is deliberately loose (every field optional), so
5
+ * the app carries its own narrowed shapes: a `doc` always has `content`, and a
6
+ * `Note` always has the fields the list and the editor read. Nothing here
7
+ * imports from `@tiptap/*` — the model is the persistence contract, and a
8
+ * consumer storing notes server-side should be able to type its API against it
9
+ * without pulling the editor in.
10
+ */
11
+ export const EMPTY_DOC = { type: 'doc', content: [{ type: 'paragraph' }] };
@@ -5,7 +5,8 @@ import { iconFlush } from './icon-flush.js';
5
5
  import { offAxisMotion } from './off-axis-motion.js';
6
6
  import { offTierHeight } from './off-tier-height.js';
7
7
  import { radiusConcentricity } from './radius-concentricity.js';
8
+ import { restingGap } from './resting-gap.js';
8
9
  import { singleGlyphSquare } from './single-glyph-square.js';
9
10
  import { uniformSiblingGaps } from './uniform-sibling-gaps.js';
10
11
  import { wallMatchesGap } from './wall-matches-gap.js';
11
- export { collapsedTextTrim, concentricInset, flatChrome, iconFlush, offAxisMotion, offTierHeight, radiusConcentricity, singleGlyphSquare, uniformSiblingGaps, wallMatchesGap };
12
+ export { collapsedTextTrim, concentricInset, flatChrome, iconFlush, offAxisMotion, offTierHeight, radiusConcentricity, restingGap, singleGlyphSquare, uniformSiblingGaps, wallMatchesGap };
@@ -6,10 +6,11 @@ import { iconFlush } from './icon-flush.js';
6
6
  import { offAxisMotion } from './off-axis-motion.js';
7
7
  import { offTierHeight } from './off-tier-height.js';
8
8
  import { radiusConcentricity } from './radius-concentricity.js';
9
+ import { restingGap } from './resting-gap.js';
9
10
  import { singleGlyphSquare } from './single-glyph-square.js';
10
11
  import { uniformSiblingGaps } from './uniform-sibling-gaps.js';
11
12
  import { wallMatchesGap } from './wall-matches-gap.js';
12
- export { collapsedTextTrim, concentricInset, flatChrome, iconFlush, offAxisMotion, offTierHeight, radiusConcentricity, singleGlyphSquare, uniformSiblingGaps, wallMatchesGap };
13
+ export { collapsedTextTrim, concentricInset, flatChrome, iconFlush, offAxisMotion, offTierHeight, radiusConcentricity, restingGap, singleGlyphSquare, uniformSiblingGaps, wallMatchesGap };
13
14
  // Auto-register built-ins. Consumers can still unregister or add their own.
14
15
  //
15
16
  // Two of these only tell the truth on a particular axis setting, because the bug
@@ -25,6 +26,7 @@ registry.register(iconFlush);
25
26
  registry.register(offAxisMotion);
26
27
  registry.register(offTierHeight);
27
28
  registry.register(radiusConcentricity);
29
+ registry.register(restingGap);
28
30
  registry.register(singleGlyphSquare);
29
31
  registry.register(uniformSiblingGaps);
30
32
  registry.register(wallMatchesGap);
@@ -0,0 +1,2 @@
1
+ import type { Audit } from '../types.js';
2
+ export declare const restingGap: Audit;
@@ -0,0 +1,78 @@
1
+ import { TOL, flexChildren, isFlex } from './_helpers.js';
2
+ import { BOUNDED_TIER_MAX } from '../../measure.js';
3
+ /*
4
+ * A child that TRAVELS must rest at the same inset it is centred by.
5
+ *
6
+ * The even-gap law is stated on all four sides, but every check that enforces it
7
+ * compares the vertical pair (concentric-inset) or asserts the concentricity
8
+ * identity against gaps.top (radius-concentricity, getConcentricity). Both are
9
+ * blind to a purely HORIZONTAL error, because a moving part legitimately has one
10
+ * huge gap on the side it travelled away from — so "all four gaps equal" can
11
+ * never be the rule for it, and the vertical pair alone always looks fine.
12
+ *
13
+ * That blind spot shipped: the Switch thumb rested one border-width too far
14
+ * right at BOTH ends, so the gap at the left end and at the right end differed
15
+ * by 2px (dense 5 vs 3, touch 9 vs 7). --era-xs-inset-sm is measured from the
16
+ * track's OUTER edge, but a translate-x starts from the CONTENT box inside the
17
+ * 1px border, so offsetting by the raw token added the border on one side and
18
+ * subtracted it on the other. Vertically it was always right, because
19
+ * items-center centres within the content box and absorbs the border by
20
+ * construction. Every existing audit passed while the thing was visibly wrong.
21
+ *
22
+ * The rule that IS true for a moving part: whichever side it is currently parked
23
+ * against, that NEAR gap must equal the vertical inset it is centred by. Then
24
+ * both ends of the travel read identically, and identically to the top and
25
+ * bottom.
26
+ *
27
+ * Scope is deliberately narrow — a child with a non-zero horizontal translate,
28
+ * in a vertically-centred bounded-tier container. A labelled button's icon is
29
+ * NOT this: it has no translate, and its near gap is the text-symmetric side
30
+ * padding, which is a different token from the icon's vertical inset. Widening
31
+ * this to "any asymmetric child" would fire on every icon+label control in the
32
+ * library.
33
+ */
34
+ export const restingGap = {
35
+ id: 'layout/resting-gap',
36
+ name: 'Travelling child rests at its vertical inset',
37
+ description: 'A child that is translated horizontally within a bounded container (a switch thumb) must rest with its near gap equal to its vertical inset, so both ends of the travel match each other and the top/bottom.',
38
+ category: 'layout',
39
+ severity: 'warn',
40
+ selector: '*',
41
+ check(el) {
42
+ const s = getComputedStyle(el);
43
+ if (!isFlex(s) || !s.alignItems.includes('center'))
44
+ return null;
45
+ const box = el.getBoundingClientRect();
46
+ if (box.height === 0 || box.height > BOUNDED_TIER_MAX)
47
+ return null;
48
+ const issues = [];
49
+ for (const kid of flexChildren(el)) {
50
+ // matrix(a, b, c, d, tx, ty) — tx is the horizontal translation. No
51
+ // translate means the child is not a travelling part and this rule
52
+ // says nothing about it.
53
+ const t = getComputedStyle(kid).transform;
54
+ if (!t || t === 'none')
55
+ continue;
56
+ const tx = Number(t.match(/matrix\(([^)]+)\)/)?.[1]?.split(',')[4] ?? 0);
57
+ if (!Number.isFinite(tx) || Math.abs(tx) <= TOL)
58
+ continue;
59
+ const k = kid.getBoundingClientRect();
60
+ const top = k.top - box.top;
61
+ const bottom = box.bottom - k.bottom;
62
+ const left = k.left - box.left;
63
+ const right = box.right - k.right;
64
+ // The side it is parked against; the other side is the travel.
65
+ const near = Math.min(left, right);
66
+ const vertical = (top + bottom) / 2;
67
+ if (Math.abs(near - vertical) <= TOL)
68
+ continue;
69
+ issues.push({
70
+ auditId: 'layout/resting-gap',
71
+ element: kid,
72
+ message: `travelling child rests at ${near.toFixed(1)}px but is centred at ${vertical.toFixed(1)}px — the two ends of its travel differ by ${(2 * Math.abs(near - vertical)).toFixed(1)}px`,
73
+ details: { near, vertical, top, bottom, left, right, translateX: tx }
74
+ });
75
+ }
76
+ return issues.length > 0 ? issues : null;
77
+ }
78
+ };
@@ -0,0 +1,82 @@
1
+ ## Usage
2
+
3
+ Apps ship on their own subpath and are **not** re-exported from `@sig-nine/era-ui`.
4
+ Notes carries TipTap, and a consumer who never imports an app should never pay
5
+ for it.
6
+
7
+ ```svelte
8
+ <script>
9
+ import { Notes } from '@sig-nine/era-ui/apps';
10
+ </script>
11
+
12
+ <Notes />
13
+ ```
14
+
15
+ That is the whole integration: a sidebar with filter, pin, and delete; a TipTap
16
+ document with a `/` command menu and a selection toolbar; and persistence to
17
+ `localStorage` under `era-ui:notes`.
18
+
19
+ ## Peer dependencies
20
+
21
+ Install alongside the library (they are not bundled):
22
+
23
+ ```
24
+ @tiptap/core @tiptap/pm @tiptap/starter-kit @tiptap/suggestion
25
+ @tiptap/extension-code-block-lowlight @tiptap/extension-details
26
+ @tiptap/extension-highlight @tiptap/extension-image
27
+ @tiptap/extension-placeholder @tiptap/extension-table
28
+ @tiptap/extension-task-item @tiptap/extension-task-list
29
+ @tiptap/extension-typography @tiptap/extension-unique-id
30
+ lowlight tiptap-markdown
31
+ ```
32
+
33
+ ## Props
34
+
35
+ | Prop | Type | Default | Notes |
36
+ | ------------ | ---------------- | ------------------ | ----------------------------------------------------------------- |
37
+ | `store` | `NotesStore` | created internally | Bring your own; when set, the host owns its lifecycle. |
38
+ | `adapter` | `NotesAdapter` | `localStorage` | Persistence for the internally-created store. |
39
+ | `storageKey` | `string` | `era-ui:notes` | Key for the default adapter. |
40
+ | `noteId` | `string \| null` | `null` | `$bindable` — the open note, for deep-linking or session restore. |
41
+ | `seed` | `NoteInit[]` | — | Created only when storage comes back empty (first-run sample). |
42
+
43
+ ## Persistence
44
+
45
+ Persistence is one small interface, so server-backed hosting is a drop-in swap —
46
+ `load`/`save` may be sync or async:
47
+
48
+ ```ts
49
+ import { Notes, NotesStore } from '@sig-nine/era-ui/apps';
50
+
51
+ const store = new NotesStore({
52
+ adapter: {
53
+ load: () => fetch('/api/notes').then((r) => r.json()),
54
+ save: (notes) => fetch('/api/notes', { method: 'PUT', body: JSON.stringify(notes) })
55
+ }
56
+ });
57
+ ```
58
+
59
+ `NotesStore` is the reactive source of truth (`notes`, `ordered`, `loading`,
60
+ `status`, `lastError`) with `create` / `update` / `remove` / `togglePin` /
61
+ `setIcon` / `search` / `toMarkdown` / `flush`. Writes are debounced and
62
+ coalesced, so a burst of keystrokes costs one `save`.
63
+
64
+ ## The heading index
65
+
66
+ Every document is also a tree. `buildGlobalTree()` walks heading structure,
67
+ `treeToText()` renders it as a table of contents a model can read, and
68
+ `extractSection(title, doc, headingId)` returns just the requested slice as
69
+ structure-preserving markdown — reasoning-based retrieval rather than
70
+ embeddings. Heading ids are stable (`@tiptap/extension-unique-id`), so a section
71
+ address survives edits above it.
72
+
73
+ ## Notes
74
+
75
+ - Keyboard: `/` opens the command menu, `Mod-K` links a selection, `Mod-S`
76
+ flushes pending writes immediately.
77
+ - The slash menu and selection toolbar are real Svelte components mounted into
78
+ the document root, so they follow every axis — density tiers, surface chrome,
79
+ corners, motion, font — instead of carrying their own hardcoded styling.
80
+ - `@tiptap/extension-mathematics` is deliberately absent: it needs KaTeX's
81
+ stylesheet and fonts to render. Spread `noteExtensions` and add it yourself if
82
+ you want math.