@sprig-and-prose/sprig-ui 0.1.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.
@@ -0,0 +1,83 @@
1
+ <script>
2
+ /** @type {import('../data/universeStore.js').UniverseGraph | null} */
3
+ export let graph = null;
4
+
5
+ /** @param {Record<string, any> | null | undefined} obj */
6
+ const count = (obj) => (obj ? Object.keys(obj).length : 0);
7
+ $: dimensionCount = count(graph?.nodes && Object.fromEntries(
8
+ Object.entries(graph.nodes).filter(([, node]) => node?.kind === 'dimension'),
9
+ ));
10
+ $: relationshipCount = count(graph?.relationshipsById) + count(graph?.relatesById);
11
+ </script>
12
+
13
+ <footer class="footer">
14
+ <div class="left">
15
+ <span class="label">Grounding</span>
16
+ <span class="value">Coming soon</span>
17
+ </div>
18
+
19
+ <div class="right">
20
+ <span class="label">Nodes</span>
21
+ <span class="value">{count(graph?.nodes)}</span>
22
+ <span class="dot">•</span>
23
+ <span class="label">Dimensions</span>
24
+ <span class="value">{dimensionCount}</span>
25
+ <span class="dot">•</span>
26
+ <span class="label">Relationships</span>
27
+ <span class="value">{relationshipCount}</span>
28
+ </div>
29
+ </footer>
30
+
31
+ <style>
32
+ .footer {
33
+ padding: 2rem 0;
34
+ border-top: 1px solid var(--border-color);
35
+ margin-top: 4rem;
36
+ width: 100%;
37
+ overflow-x: hidden;
38
+ display: flex;
39
+ justify-content: space-between;
40
+ gap: 18px;
41
+ font-size: var(--sp-font-tiny);
42
+ color: var(--text-tertiary);
43
+ max-width: 1200px;
44
+ margin-left: auto;
45
+ margin-right: auto;
46
+ }
47
+
48
+ .left, .right {
49
+ display: flex;
50
+ gap: 8px;
51
+ align-items: center;
52
+ flex-wrap: wrap;
53
+ }
54
+
55
+ .label {
56
+ color: var(--text-tertiary);
57
+ }
58
+
59
+ .value {
60
+ color: var(--text-secondary);
61
+ }
62
+
63
+ .dot {
64
+ color: var(--text-tertiary);
65
+ }
66
+
67
+ @media (max-width: 768px) {
68
+ .footer {
69
+ padding: 2rem 0;
70
+ margin-top: 48px;
71
+ font-size: var(--sp-font-tiny);
72
+ }
73
+ }
74
+
75
+ @media (max-width: 480px) {
76
+ .footer {
77
+ padding: 1.5rem 0;
78
+ margin-top: 40px;
79
+ font-size: var(--sp-font-tiny);
80
+ }
81
+ }
82
+ </style>
83
+
@@ -0,0 +1,428 @@
1
+ <script>
2
+ import { onMount, onDestroy } from 'svelte';
3
+ import { universeGraph } from '../data/universeStore.js';
4
+ import { getNodeRoute, getAncestorChain } from '../data/universeStore.js';
5
+ import { getDisplayTitle } from '../format/title.js';
6
+ import { getDisplayKind } from '../format/kind.js';
7
+ import { navigate } from '../router.js';
8
+
9
+ /**
10
+ * @typedef {import('../data/universeStore.js').UniverseGraph} UniverseGraph
11
+ * @typedef {import('../data/universeStore.js').NodeModel} NodeModel
12
+ * @typedef {{ node: NodeModel | any, name: string, kind: string, id: string, route: string, context: string | null }} SearchItem
13
+ */
14
+
15
+ /** @type {HTMLInputElement | null} */
16
+ let searchInput = null;
17
+ let query = '';
18
+ let searchIndex = /** @type {SearchItem[]} */ ([]);
19
+ let activeIndex = /** @type {number | null} */ (null);
20
+ let listboxId = 'search-results-listbox';
21
+ /** @type {HTMLDivElement | null} */
22
+ let resultsDropdown = null;
23
+ let isManuallyClosed = false;
24
+
25
+ function buildContext(graph, node) {
26
+ const ancestors = getAncestorChain(graph, node);
27
+ if (ancestors.length === 0) return null;
28
+ return `in ${getDisplayTitle(ancestors[0])}`;
29
+ }
30
+
31
+ /**
32
+ * Build search index from universe graph nodes.
33
+ * @param {UniverseGraph | null} graph
34
+ * @returns {SearchItem[]}
35
+ */
36
+ function buildSearchIndex(graph) {
37
+ if (!graph) return [];
38
+
39
+ const items = [];
40
+ const searchableKinds = ['concept', 'dimension', 'relationship', 'relates'];
41
+
42
+ if (graph.nodes) {
43
+ for (const node of Object.values(graph.nodes)) {
44
+ if (!node || !searchableKinds.includes(node.kind)) continue;
45
+
46
+ const route = getNodeRoute(node);
47
+ const context = buildContext(graph, node);
48
+
49
+ items.push({
50
+ node,
51
+ name: node.name,
52
+ kind: getDisplayKind(node),
53
+ id: node.id,
54
+ route,
55
+ context,
56
+ });
57
+ }
58
+ }
59
+
60
+ return items;
61
+ }
62
+
63
+ /**
64
+ * Search nodes with ranking
65
+ * @param {SearchItem[]} index
66
+ * @param {string} searchQuery
67
+ * @returns {SearchItem[]}
68
+ */
69
+ function searchNodes(index, searchQuery) {
70
+ if (!searchQuery.trim()) return [];
71
+
72
+ const queryLower = searchQuery.toLowerCase().trim();
73
+ const prefixMatches = [];
74
+ const substringMatches = [];
75
+
76
+ for (const item of index) {
77
+ const nameLower = item.name.toLowerCase();
78
+
79
+ if (nameLower.startsWith(queryLower)) {
80
+ prefixMatches.push(item);
81
+ } else if (nameLower.includes(queryLower)) {
82
+ substringMatches.push(item);
83
+ }
84
+ }
85
+
86
+ prefixMatches.sort((a, b) => a.name.localeCompare(b.name));
87
+ substringMatches.sort((a, b) => a.name.localeCompare(b.name));
88
+
89
+ const results = [...prefixMatches, ...substringMatches];
90
+ return results.slice(0, 10);
91
+ }
92
+
93
+ // Build search index reactively when graph changes
94
+ $: {
95
+ if ($universeGraph) {
96
+ searchIndex = buildSearchIndex($universeGraph);
97
+ }
98
+ }
99
+
100
+ // Perform search reactively
101
+ $: results = query.trim() ? searchNodes(searchIndex, query) : [];
102
+
103
+ // Reset active index and manual close flag when query or results change
104
+ $: {
105
+ activeIndex = null;
106
+ if (query.trim().length > 0) {
107
+ isManuallyClosed = false;
108
+ }
109
+ }
110
+
111
+ // Compute isOpen: show dropdown if we have results, query is not empty, and not manually closed
112
+ $: isOpen = results.length > 0 && query.trim().length > 0 && !isManuallyClosed;
113
+
114
+ /**
115
+ * Scroll active item into view
116
+ */
117
+ function scrollActiveIntoView() {
118
+ if (activeIndex === null || !resultsDropdown) return;
119
+
120
+ const optionId = `search-option-${activeIndex}`;
121
+ const optionElement = resultsDropdown.querySelector(`#${optionId}`);
122
+ if (optionElement) {
123
+ optionElement.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Handle keyboard navigation in search input
129
+ * @param {KeyboardEvent} event
130
+ */
131
+ function handleInputKeydown(event) {
132
+ // Ignore "/" key since it's the focus key
133
+ if (event.key === '/') {
134
+ event.preventDefault();
135
+ return;
136
+ }
137
+
138
+ if (event.key === 'Tab') {
139
+ return;
140
+ }
141
+
142
+ if (event.key === 'ArrowDown') {
143
+ event.preventDefault();
144
+ if (!isOpen || results.length === 0) return;
145
+
146
+ if (activeIndex === null) {
147
+ activeIndex = 0;
148
+ } else {
149
+ activeIndex = Math.min(activeIndex + 1, results.length - 1);
150
+ }
151
+ scrollActiveIntoView();
152
+ return;
153
+ }
154
+
155
+ if (event.key === 'ArrowUp') {
156
+ event.preventDefault();
157
+ if (!isOpen || results.length === 0) return;
158
+
159
+ if (activeIndex === null) {
160
+ activeIndex = results.length - 1;
161
+ } else {
162
+ activeIndex = Math.max(activeIndex - 1, 0);
163
+ }
164
+ scrollActiveIntoView();
165
+ return;
166
+ }
167
+
168
+ if (event.key === 'Enter' && isOpen && activeIndex !== null && results[activeIndex]) {
169
+ event.preventDefault();
170
+ const item = results[activeIndex];
171
+ handleResultClick(item);
172
+ return;
173
+ }
174
+
175
+ if (event.key === 'Escape') {
176
+ event.preventDefault();
177
+ isManuallyClosed = true;
178
+ activeIndex = null;
179
+ searchInput?.focus();
180
+ return;
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Handle global keyboard shortcuts (for `/` to focus)
186
+ * @param {KeyboardEvent} event
187
+ */
188
+ function handleGlobalKeydown(event) {
189
+ const activeElement = document.activeElement;
190
+ const isInputElement = activeElement && (
191
+ activeElement.tagName === 'INPUT' ||
192
+ activeElement.tagName === 'TEXTAREA' ||
193
+ (activeElement instanceof HTMLElement && activeElement.isContentEditable)
194
+ );
195
+
196
+ if (event.key === '/' && !isInputElement) {
197
+ event.preventDefault();
198
+ searchInput?.focus();
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Handle result click
204
+ * @param {SearchItem} item
205
+ */
206
+ function handleResultClick(item) {
207
+ query = '';
208
+ isOpen = false;
209
+ activeIndex = null;
210
+ navigate(item.route);
211
+ // Trigger route change event
212
+ window.dispatchEvent(new PopStateEvent('popstate'));
213
+ }
214
+
215
+ /**
216
+ * Handle mouse hover on result item
217
+ * @param {number} index
218
+ */
219
+ function handleResultHover(index) {
220
+ activeIndex = index;
221
+ }
222
+
223
+ onMount(() => {
224
+ window.addEventListener('keydown', handleGlobalKeydown);
225
+ });
226
+
227
+ onDestroy(() => {
228
+ if (typeof window !== 'undefined') {
229
+ window.removeEventListener('keydown', handleGlobalKeydown);
230
+ }
231
+ });
232
+ </script>
233
+
234
+ <div class="search-container">
235
+ <div class="search-wrapper">
236
+ <input
237
+ bind:this={searchInput}
238
+ type="text"
239
+ placeholder="Search concepts, dimensions, relationships… (Press / to focus)"
240
+ bind:value={query}
241
+ class="search-input"
242
+ role="combobox"
243
+ aria-label="Search"
244
+ aria-expanded={isOpen}
245
+ aria-controls={listboxId}
246
+ aria-activedescendant={activeIndex !== null ? `search-option-${activeIndex}` : undefined}
247
+ aria-autocomplete="list"
248
+ on:keydown={handleInputKeydown}
249
+ />
250
+
251
+ {#if isOpen && results.length > 0}
252
+ <div
253
+ bind:this={resultsDropdown}
254
+ class="results-dropdown"
255
+ role="listbox"
256
+ id={listboxId}
257
+ >
258
+ {#each results as item, index (item.id)}
259
+ {@const optionId = `search-option-${index}`}
260
+ {@const isActive = activeIndex === index}
261
+ <a
262
+ href={item.route}
263
+ class="result-item"
264
+ class:result-item--active={isActive}
265
+ role="option"
266
+ id={optionId}
267
+ aria-selected={isActive}
268
+ on:click|preventDefault={() => handleResultClick(item)}
269
+ on:mouseenter={() => handleResultHover(index)}
270
+ >
271
+ <div class="result-content">
272
+ <div class="result-title">{item.node != null ? getDisplayTitle(item.node) : item.name}</div>
273
+ <div class="result-meta">
274
+ <span class="result-kind">{item.kind}</span>
275
+ {#if item.context}
276
+ <span class="result-context">{item.context}</span>
277
+ {/if}
278
+ </div>
279
+ </div>
280
+ </a>
281
+ {/each}
282
+ </div>
283
+ {/if}
284
+ </div>
285
+ </div>
286
+
287
+ <style>
288
+ .search-container {
289
+ display: flex;
290
+ flex: 1;
291
+ }
292
+
293
+ .search-wrapper {
294
+ position: relative;
295
+ max-width: 500px;
296
+ width: 100%;
297
+ }
298
+
299
+ .search-input {
300
+ width: 100%;
301
+ padding: 10px 14px;
302
+ font-family: var(--font-ui);
303
+ font-size: var(--sp-font-small);
304
+ color: var(--text-color);
305
+ background: var(--card-bg);
306
+ border: none;
307
+ border-radius: 4px;
308
+ outline: none;
309
+ transition: box-shadow 0.2s;
310
+ }
311
+
312
+ .search-input:focus {
313
+ box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.05);
314
+ }
315
+
316
+ @media (prefers-color-scheme: dark) {
317
+ .search-input:focus {
318
+ box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.05);
319
+ }
320
+ }
321
+
322
+ :global(html[data-theme="dark"]) .search-input:focus {
323
+ box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.05);
324
+ }
325
+
326
+ .search-input::placeholder {
327
+ color: var(--text-tertiary);
328
+ }
329
+
330
+ .results-dropdown {
331
+ position: absolute;
332
+ top: calc(100% + 4px);
333
+ left: 0;
334
+ right: 0;
335
+ background: var(--card-bg);
336
+ border: 1px solid var(--border-color);
337
+ border-radius: 4px;
338
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
339
+ max-height: 400px;
340
+ overflow-y: auto;
341
+ z-index: 1000;
342
+ margin-top: 4px;
343
+ }
344
+
345
+ @media (prefers-color-scheme: dark) {
346
+ .results-dropdown {
347
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
348
+ }
349
+ }
350
+
351
+ :global(html[data-theme="dark"]) .results-dropdown {
352
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
353
+ }
354
+
355
+ .result-item {
356
+ display: block;
357
+ padding: 12px 14px;
358
+ text-decoration: none;
359
+ color: inherit;
360
+ border-bottom: 1px solid var(--hairline);
361
+ transition: background-color 0.15s;
362
+ cursor: pointer;
363
+ }
364
+
365
+ .result-item:last-child {
366
+ border-bottom: none;
367
+ }
368
+
369
+ .result-item:hover,
370
+ .result-item:focus {
371
+ background: var(--hover);
372
+ outline: none;
373
+ }
374
+
375
+ .result-item:focus-visible {
376
+ outline: 2px solid var(--text-tertiary);
377
+ outline-offset: -2px;
378
+ }
379
+
380
+ .result-item--active {
381
+ background: var(--hover);
382
+ }
383
+
384
+ .result-item--active .result-title {
385
+ text-decoration: underline;
386
+ text-decoration-thickness: 1px;
387
+ text-underline-offset: 2px;
388
+ text-decoration-color: var(--text-secondary);
389
+ }
390
+
391
+ .result-content {
392
+ display: flex;
393
+ flex-direction: column;
394
+ gap: 4px;
395
+ }
396
+
397
+ .result-title {
398
+ font-family: var(--font-prose);
399
+ font-size: var(--sp-font-body);
400
+ color: var(--text-color);
401
+ font-weight: 400;
402
+ }
403
+
404
+ .result-meta {
405
+ display: flex;
406
+ align-items: center;
407
+ gap: 8px;
408
+ font-family: var(--font-ui);
409
+ font-size: var(--sp-font-tiny);
410
+ }
411
+
412
+ .result-kind {
413
+ color: var(--text-tertiary);
414
+ text-transform: capitalize;
415
+ font-weight: 400;
416
+ }
417
+
418
+ .result-context {
419
+ color: var(--text-secondary);
420
+ }
421
+
422
+ .result-context::before {
423
+ content: '·';
424
+ margin-right: 4px;
425
+ color: var(--text-tertiary);
426
+ }
427
+ </style>
428
+
@@ -0,0 +1,65 @@
1
+ <script>
2
+ import PageHeader from './PageHeader.svelte';
3
+ import FooterStatus from './FooterStatus.svelte';
4
+
5
+ export let title;
6
+ export let subtitle;
7
+ export let graph;
8
+ export let showIndex = false;
9
+ </script>
10
+
11
+ <PageHeader {title} {subtitle} />
12
+
13
+ <div class="grid">
14
+ <section class="narrative">
15
+ <slot name="narrative" />
16
+ </section>
17
+
18
+ {#if showIndex}
19
+ <aside class="index">
20
+ <slot name="index" />
21
+ </aside>
22
+ {/if}
23
+ </div>
24
+
25
+ <slot name="sections" />
26
+
27
+ {#if graph}
28
+ <FooterStatus {graph} />
29
+ {/if}
30
+
31
+ <style>
32
+ .grid {
33
+ display: grid;
34
+ grid-template-columns: 1fr 350px;
35
+ gap: 64px;
36
+ align-items: start;
37
+ max-width: 1200px;
38
+ margin: 0 auto;
39
+ width: 100%;
40
+ }
41
+
42
+ .narrative {
43
+ max-width: 680px;
44
+ min-width: 0;
45
+ width: 100%;
46
+ }
47
+
48
+ .index {
49
+ min-width: 0;
50
+ width: 100%;
51
+ }
52
+
53
+ @media (max-width: 768px) {
54
+ .grid {
55
+ grid-template-columns: 1fr;
56
+ gap: 32px;
57
+ }
58
+ }
59
+
60
+ @media (max-width: 480px) {
61
+ .grid {
62
+ gap: 32px;
63
+ }
64
+ }
65
+ </style>
@@ -0,0 +1,116 @@
1
+ <script>
2
+ import { getDisplayTitle } from '../format/title.js';
3
+
4
+ /** @type {string} */
5
+ export let title;
6
+
7
+ /** @type {string | null} */
8
+ export let subtitle = null;
9
+
10
+ /** @type {any} */
11
+ export let subtitleNode = null;
12
+ </script>
13
+
14
+ <header class="header">
15
+ <div>
16
+ <h1 class="title">{title}</h1>
17
+ {#if subtitle}
18
+ <p class="subtitle">{@html subtitle}</p>
19
+ {:else if subtitleNode}
20
+ <p class="subtitle">{getDisplayTitle(subtitleNode)}</p>
21
+ {/if}
22
+ </div>
23
+ </header>
24
+
25
+ <style>
26
+ .header {
27
+ margin-bottom: 48px;
28
+ }
29
+
30
+ .title {
31
+ font-size: var(--sp-font-h1);
32
+ font-weight: 400;
33
+ line-height: 1.2;
34
+ margin-bottom: 0;
35
+ color: var(--text-color);
36
+ letter-spacing: -0.5px;
37
+ font-family: var(--font-prose);
38
+ }
39
+
40
+ .subtitle {
41
+ margin: 0;
42
+ font-size: var(--sp-font-body);
43
+ line-height: 1.6;
44
+ color: var(--text-color);
45
+ font-family: var(--font-ui);
46
+ margin-top: 0;
47
+ }
48
+
49
+ .subtitle :global(a) {
50
+ color: inherit;
51
+ text-decoration: underline;
52
+ text-decoration-thickness: 1px;
53
+ text-underline-offset: 0.25rem;
54
+ text-decoration-color: rgba(0, 0, 0, 0.25);
55
+ transition: text-decoration-thickness 150ms ease, text-decoration-color 150ms ease;
56
+ }
57
+
58
+ @media (prefers-color-scheme: dark) {
59
+ :root:not([data-theme="light"]) .subtitle :global(a) {
60
+ text-decoration-color: rgba(255, 255, 255, 0.35);
61
+ }
62
+ }
63
+
64
+ :global(html[data-theme="dark"]) .subtitle :global(a) {
65
+ text-decoration-color: rgba(255, 255, 255, 0.35);
66
+ }
67
+
68
+ :global(html[data-theme="light"]) .subtitle :global(a) {
69
+ text-decoration-color: rgba(0, 0, 0, 0.25);
70
+ }
71
+
72
+ .subtitle :global(a:hover) {
73
+ text-decoration-thickness: 1.5px;
74
+ text-decoration-color: rgba(0, 0, 0, 0.4);
75
+ }
76
+
77
+ @media (prefers-color-scheme: dark) {
78
+ :root:not([data-theme="light"]) .subtitle :global(a:hover) {
79
+ text-decoration-color: rgba(255, 255, 255, 0.5);
80
+ }
81
+ }
82
+
83
+ :global(html[data-theme="dark"]) .subtitle :global(a:hover) {
84
+ text-decoration-color: rgba(255, 255, 255, 0.5);
85
+ }
86
+
87
+ :global(html[data-theme="light"]) .subtitle :global(a:hover) {
88
+ text-decoration-color: rgba(0, 0, 0, 0.4);
89
+ }
90
+
91
+ @media (max-width: 768px) {
92
+ .title {
93
+ font-size: 36px;
94
+ margin-bottom: 32px;
95
+ letter-spacing: -0.3px;
96
+ }
97
+
98
+ .subtitle {
99
+ font-size: var(--sp-font-body);
100
+ line-height: 1.65;
101
+ }
102
+ }
103
+
104
+ @media (max-width: 480px) {
105
+ .title {
106
+ font-size: 32px;
107
+ margin-bottom: 28px;
108
+ letter-spacing: -0.2px;
109
+ }
110
+
111
+ .subtitle {
112
+ font-size: var(--sp-font-body);
113
+ }
114
+ }
115
+ </style>
116
+