@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.
- package/dist/apps/index.d.ts +19 -0
- package/dist/apps/index.js +19 -0
- package/dist/apps/notes/editor/bubble-menu.svelte.d.ts +11 -0
- package/dist/apps/notes/editor/bubble-menu.svelte.js +106 -0
- package/dist/apps/notes/editor/bubble-toolbar.svelte +42 -0
- package/dist/apps/notes/editor/bubble-toolbar.svelte.d.ts +22 -0
- package/dist/apps/notes/editor/extensions.d.ts +18 -0
- package/dist/apps/notes/editor/extensions.js +88 -0
- package/dist/apps/notes/editor/floating.d.ts +21 -0
- package/dist/apps/notes/editor/floating.js +44 -0
- package/dist/apps/notes/editor/link.d.ts +9 -0
- package/dist/apps/notes/editor/link.js +22 -0
- package/dist/apps/notes/editor/list-cleanup-rule.d.ts +14 -0
- package/dist/apps/notes/editor/list-cleanup-rule.js +47 -0
- package/dist/apps/notes/editor/slash-command.svelte.d.ts +22 -0
- package/dist/apps/notes/editor/slash-command.svelte.js +215 -0
- package/dist/apps/notes/editor/slash-menu.svelte +63 -0
- package/dist/apps/notes/editor/slash-menu.svelte.d.ts +23 -0
- package/dist/apps/notes/index.d.ts +11 -0
- package/dist/apps/notes/index.js +13 -0
- package/dist/apps/notes/markdown.d.ts +12 -0
- package/dist/apps/notes/markdown.js +165 -0
- package/dist/apps/notes/note-editor.svelte +456 -0
- package/dist/apps/notes/note-editor.svelte.d.ts +35 -0
- package/dist/apps/notes/notes-store.svelte.d.ts +64 -0
- package/dist/apps/notes/notes-store.svelte.js +234 -0
- package/dist/apps/notes/notes.svelte +377 -0
- package/dist/apps/notes/notes.svelte.d.ts +22 -0
- package/dist/apps/notes/tree.d.ts +70 -0
- package/dist/apps/notes/tree.js +185 -0
- package/dist/apps/notes/types.d.ts +49 -0
- package/dist/apps/notes/types.js +11 -0
- package/dist/dev/audit/audits/index.d.ts +2 -1
- package/dist/dev/audit/audits/index.js +3 -1
- package/dist/dev/audit/audits/resting-gap.d.ts +2 -0
- package/dist/dev/audit/audits/resting-gap.js +78 -0
- package/dist/docs/notes.md +82 -0
- package/dist/era-ui.css +1 -1
- package/dist/generated-docs/llms-full.txt +92 -1
- package/dist/generated-docs/llms.txt +1 -1
- package/dist/generated-docs/manifest.json +16 -1
- package/dist/generated-docs/notes.md +86 -0
- package/dist/generated-docs/utilities.json +1 -1
- package/dist/generated-docs/utilities.md +1 -1
- package/dist/styles/index.css +2 -1
- package/dist/styles/themes.css +70 -52
- package/dist/ui/badge/badge.svelte.d.ts +17 -17
- package/dist/ui/bar/bar.svelte.d.ts +11 -11
- package/dist/ui/button/variants.d.ts +44 -44
- package/dist/ui/card/card.svelte.d.ts +20 -20
- package/dist/ui/chip/chip.svelte.d.ts +14 -14
- package/dist/ui/pane/pane-root.svelte.d.ts +1 -1
- package/dist/ui/pane/pane.svelte.d.ts +1 -1
- package/dist/ui/sheet/sheet-content.svelte.d.ts +14 -14
- package/dist/ui/skeleton/skeleton.svelte.d.ts +11 -11
- package/dist/utils/index.js +11 -0
- 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,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.
|