@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.
package/src/App.svelte ADDED
@@ -0,0 +1,277 @@
1
+ <script>
2
+ import { onMount, onDestroy } from 'svelte';
3
+ import { loadUniverseGraph, autoSelectFirstUniverse, universeGraph } from './lib/data/universeStore.js';
4
+ import { getCurrentRoute, navigate } from './lib/router.js';
5
+ import { theme } from './lib/stores/theme.js';
6
+ import { describeRenderMode } from './lib/stores/describeRenderMode.js';
7
+ import GlobalSearch from './lib/components/GlobalSearch.svelte';
8
+ import HomePage from './pages/HomePage.svelte';
9
+ import ConceptPage from './pages/ConceptPage.svelte';
10
+
11
+ let loading = true;
12
+ /** @type {string | null} */
13
+ let error = null;
14
+ let currentRoute = getCurrentRoute();
15
+
16
+ $: nodeParams = currentRoute?.route === 'node'
17
+ ? /** @type {{ universe: string, id: string }} */ (currentRoute.params)
18
+ : null;
19
+
20
+ // Reactively auto-select first universe when on home route and graph is loaded
21
+ // This runs whenever universeGraph or currentRoute changes
22
+ $: if ($universeGraph && currentRoute?.route === 'home') {
23
+ autoSelectFirstUniverse($universeGraph);
24
+ }
25
+
26
+ function toggleTheme() {
27
+ theme.update((t) => {
28
+ if (t === 'light') return 'dark';
29
+ if (t === 'dark') return 'auto';
30
+ return 'light'; // 'auto' -> 'light'
31
+ });
32
+ }
33
+
34
+ // Get display label for theme toggle
35
+ $: themeLabel = (() => {
36
+ if ($theme === 'auto') {
37
+ return 'Auto';
38
+ }
39
+ return $theme === 'dark' ? 'Light' : 'Dark';
40
+ })();
41
+
42
+ async function loadManifest() {
43
+ try {
44
+ loading = true;
45
+ error = null;
46
+ const { describeRenderMode: mode } = await loadUniverseGraph('/api/manifest');
47
+
48
+ // Auto-selection is handled reactively above
49
+
50
+ if (mode === 'lists' || mode === 'plain') {
51
+ describeRenderMode.set(mode);
52
+ }
53
+ } catch (err) {
54
+ const message = err instanceof Error ? err.message : 'Failed to load manifest';
55
+ error = message;
56
+ console.error('Error loading manifest:', err);
57
+ } finally {
58
+ loading = false;
59
+ }
60
+ }
61
+
62
+ function handleRouteChange() {
63
+ currentRoute = getCurrentRoute();
64
+ // Auto-selection will happen reactively when route changes
65
+ }
66
+
67
+ // Intercept link clicks for client-side navigation
68
+ /**
69
+ * @param {MouseEvent} event
70
+ */
71
+ function handleLinkClick(event) {
72
+ if (!(event.target instanceof HTMLElement)) return;
73
+ const link = event.target.closest('a');
74
+ if (!link) return;
75
+
76
+ const href = link.getAttribute('href');
77
+ if (!href) return;
78
+
79
+ // Only handle internal links (not external or hash links)
80
+ if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('mailto:') || href.startsWith('#')) {
81
+ return;
82
+ }
83
+
84
+ // Check if it's an internal route
85
+ if (href.startsWith('/') || !href.includes('://')) {
86
+ event.preventDefault();
87
+ navigate(href);
88
+ handleRouteChange();
89
+ }
90
+ }
91
+
92
+ // Listen for popstate (back/forward button)
93
+ window.addEventListener('popstate', handleRouteChange);
94
+
95
+ // Listen for link clicks
96
+ document.addEventListener('click', handleLinkClick);
97
+
98
+ // Set up Server-Sent Events for manifest updates
99
+ /** @type {EventSource | null} */
100
+ let eventSource = null;
101
+
102
+ onMount(() => {
103
+ if (typeof EventSource !== 'undefined') {
104
+ try {
105
+ eventSource = new EventSource('/api/events');
106
+ console.log('SSE EventSource created');
107
+
108
+ // Listen for manifest change events
109
+ eventSource.addEventListener('manifest', async (e) => {
110
+ console.log('SSE manifest event received:', e.data);
111
+ await loadManifest();
112
+ });
113
+
114
+ // Optional: refetch on connection open to be safe
115
+ eventSource.addEventListener('open', () => {
116
+ console.log('SSE connection opened');
117
+ });
118
+
119
+ // Handle errors (EventSource auto-reconnects, so we can ignore or log minimally)
120
+ eventSource.addEventListener('error', (e) => {
121
+ // EventSource will auto-reconnect, so we don't need to do anything
122
+ // Just log if needed for debugging
123
+ if (eventSource && eventSource.readyState === EventSource.CLOSED) {
124
+ console.log('SSE connection closed');
125
+ } else if (eventSource && eventSource.readyState === EventSource.CONNECTING) {
126
+ console.log('SSE reconnecting...');
127
+ }
128
+ });
129
+ } catch (err) {
130
+ console.error('Failed to create EventSource:', err);
131
+ }
132
+ }
133
+
134
+ loadManifest();
135
+ });
136
+
137
+ onDestroy(() => {
138
+ if (eventSource) {
139
+ eventSource.close();
140
+ console.log('SSE EventSource closed');
141
+ }
142
+ });
143
+ </script>
144
+
145
+ <div class="app sprig-design">
146
+ <div class="page">
147
+ <div class="top-bar">
148
+ <GlobalSearch />
149
+ <button class="theme-toggle" on:click={toggleTheme} type="button" aria-label="Toggle theme">
150
+ {themeLabel}
151
+ </button>
152
+ </div>
153
+ <main class="main-content">
154
+ {#if loading}
155
+ <div class="loading">Loading…</div>
156
+ {:else if error}
157
+ <div class="error">Error: {error}</div>
158
+ {:else if currentRoute}
159
+ {#if currentRoute.route === 'home'}
160
+ <HomePage />
161
+ {:else if currentRoute.route === 'node' && nodeParams}
162
+ <ConceptPage params={nodeParams} />
163
+ {:else}
164
+ <div class="error">Unknown route: {currentRoute.route}</div>
165
+ {/if}
166
+ {:else}
167
+ <div class="error">Invalid route</div>
168
+ {/if}
169
+ </main>
170
+ </div>
171
+ </div>
172
+
173
+ <style>
174
+ .app {
175
+ min-height: 100vh;
176
+ width: 100%;
177
+ }
178
+
179
+ .page {
180
+ max-width: 1200px;
181
+ margin: 0 auto;
182
+ width: 100%;
183
+ }
184
+
185
+ .top-bar {
186
+ padding: 24px 32px;
187
+ border-bottom: 1px solid var(--border-color);
188
+ display: flex;
189
+ justify-content: space-between;
190
+ align-items: center;
191
+ gap: 1rem;
192
+ width: 100%;
193
+ position: sticky;
194
+ top: 0;
195
+ /* Background texture applied via JavaScript in main.js to match body texture */
196
+ /* Use semi-transparent background to allow texture to show while covering scrolling content */
197
+ background-color: rgba(250, 250, 250, 0.95); /* Light mode base with slight transparency */
198
+ z-index: 100;
199
+ }
200
+
201
+ @media (prefers-color-scheme: dark) {
202
+ .top-bar {
203
+ background-color: rgba(36, 34, 31, 0.95); /* Dark mode base with slight transparency */
204
+ }
205
+ }
206
+
207
+ :global(html[data-theme="dark"]) .top-bar {
208
+ background-color: rgba(36, 34, 31, 0.95); /* Dark mode base with slight transparency */
209
+ }
210
+
211
+ :global(html[data-theme="light"]) .top-bar {
212
+ background-color: rgba(250, 250, 250, 0.95); /* Light mode base with slight transparency */
213
+ }
214
+
215
+ .main-content {
216
+ padding: 64px 32px;
217
+ min-height: calc(100vh - 200px);
218
+ width: 100%;
219
+ }
220
+
221
+ .theme-toggle {
222
+ background: var(--card-bg);
223
+ border: none;
224
+ border-radius: 4px;
225
+ color: var(--text-color);
226
+ font-family: var(--font-ui);
227
+ font-size: var(--sp-font-small);
228
+ padding: 8px 12px;
229
+ cursor: pointer;
230
+ white-space: nowrap;
231
+ transition: background 0.2s, opacity 0.2s;
232
+ }
233
+
234
+ .theme-toggle:hover {
235
+ background: var(--hover);
236
+ opacity: 0.8;
237
+ }
238
+
239
+ .theme-toggle:focus {
240
+ outline: 2px solid var(--text-tertiary);
241
+ outline-offset: 2px;
242
+ border-radius: 2px;
243
+ }
244
+
245
+ .loading,
246
+ .error {
247
+ color: var(--text-tertiary);
248
+ padding: 24px 0;
249
+ font-size: var(--sp-font-body);
250
+ }
251
+
252
+ .error {
253
+ color: #d32f2f;
254
+ }
255
+
256
+ @media (max-width: 768px) {
257
+ .top-bar {
258
+ padding: 20px 24px;
259
+ }
260
+
261
+ .main-content {
262
+ padding: 48px 24px;
263
+ }
264
+ }
265
+
266
+ @media (max-width: 480px) {
267
+ .top-bar {
268
+ padding: 16px 20px;
269
+ flex-wrap: wrap;
270
+ gap: 8px;
271
+ }
272
+
273
+ .main-content {
274
+ padding: 32px 20px;
275
+ }
276
+ }
277
+ </style>
package/src/cli.js ADDED
@@ -0,0 +1,146 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { readFileSync, existsSync, statSync } from 'fs';
4
+ import { resolve, dirname, join } from 'path';
5
+ import { createAppServer } from './server.js';
6
+
7
+ /**
8
+ * Walks upward from startPath to find .sprig directory
9
+ * @param {string} startPath - Starting directory path
10
+ * @returns {string | null} - Path to directory containing .sprig, or null if not found
11
+ */
12
+ function discoverSprigDir(startPath) {
13
+ let current = resolve(startPath);
14
+ const root = resolve('/');
15
+
16
+ while (current !== root) {
17
+ const sprigDirPath = join(current, '.sprig');
18
+ if (existsSync(sprigDirPath) && statSync(sprigDirPath).isDirectory()) {
19
+ return current;
20
+ }
21
+ current = dirname(current);
22
+ }
23
+
24
+ // Check root directory as well
25
+ const rootSprigDirPath = join(root, '.sprig');
26
+ if (existsSync(rootSprigDirPath) && statSync(rootSprigDirPath).isDirectory()) {
27
+ return root;
28
+ }
29
+
30
+ return null;
31
+ }
32
+
33
+ /**
34
+ * Parse command-line arguments
35
+ * @returns {{ port: number; manifest: string; manifestSpecified: boolean }}
36
+ */
37
+ function parseArgs() {
38
+ const args = process.argv.slice(2);
39
+ let port = 5173;
40
+ let manifest = '';
41
+ let manifestSpecified = false;
42
+
43
+ for (let i = 0; i < args.length; i++) {
44
+ const arg = args[i];
45
+ if (arg === '--port' || arg === '-p') {
46
+ const portValue = args[i + 1];
47
+ if (!portValue) {
48
+ console.error('Error: --port requires a value');
49
+ process.exit(1);
50
+ }
51
+ const parsedPort = parseInt(portValue, 10);
52
+ if (isNaN(parsedPort) || parsedPort < 1 || parsedPort > 65535) {
53
+ console.error('Error: --port must be a number between 1 and 65535');
54
+ process.exit(1);
55
+ }
56
+ port = parsedPort;
57
+ i++;
58
+ } else if (arg === '--manifest' || arg === '-m') {
59
+ const manifestValue = args[i + 1];
60
+ if (!manifestValue) {
61
+ console.error('Error: --manifest requires a value');
62
+ process.exit(1);
63
+ }
64
+ manifest = manifestValue;
65
+ manifestSpecified = true;
66
+ i++;
67
+ } else if (arg === '--help' || arg === '-h') {
68
+ console.log(`
69
+ Usage: sprig-ui [options]
70
+
71
+ Options:
72
+ -p, --port <number> Port to listen on (default: 5173)
73
+ -m, --manifest <path> Path to manifest.json file (default: auto-discovered)
74
+ -h, --help Show this help message
75
+
76
+ Discovery:
77
+ - Walks upward from current directory to find .sprig directory
78
+ - Default manifest location: <root>/.sprig/manifest.json
79
+ - Falls back to ./manifest.json if no .sprig directory found
80
+
81
+ Examples:
82
+ sprig-ui
83
+ sprig-ui --port 3000
84
+ sprig-ui --manifest ./custom-manifest.json
85
+ sprig-ui -p 8080 -m ./data/manifest.json
86
+ `);
87
+ process.exit(0);
88
+ } else {
89
+ console.error(`Error: Unknown option ${arg}`);
90
+ console.error('Use --help for usage information');
91
+ process.exit(1);
92
+ }
93
+ }
94
+
95
+ return { port, manifest, manifestSpecified };
96
+ }
97
+
98
+
99
+ function main() {
100
+ const { port, manifest, manifestSpecified } = parseArgs();
101
+
102
+ // Determine manifest path with fallback chain:
103
+ // 1. Explicit --manifest flag
104
+ // 2. Discovered .sprig/manifest.json
105
+ // 3. ./manifest.json (fallback)
106
+ let manifestPath = manifest;
107
+
108
+ if (!manifestSpecified) {
109
+ // Try to discover .sprig directory
110
+ const sprigRoot = discoverSprigDir(process.cwd());
111
+ if (sprigRoot) {
112
+ manifestPath = join(sprigRoot, '.sprig', 'manifest.json');
113
+ } else {
114
+ // Final fallback
115
+ manifestPath = './manifest.json';
116
+ }
117
+ }
118
+
119
+ const server = createAppServer(manifestPath);
120
+
121
+ server.listen(port, () => {
122
+ console.log(`Server running at http://localhost:${port}`);
123
+ console.log(`Manifest path: ${manifestPath}`);
124
+ console.log('Press Ctrl+C to stop');
125
+ });
126
+
127
+ // Handle graceful shutdown
128
+ process.on('SIGINT', () => {
129
+ console.log('\nShutting down server...');
130
+ server.close(() => {
131
+ console.log('Server stopped');
132
+ process.exit(0);
133
+ });
134
+ });
135
+
136
+ process.on('SIGTERM', () => {
137
+ console.log('\nShutting down server...');
138
+ server.close(() => {
139
+ console.log('Server stopped');
140
+ process.exit(0);
141
+ });
142
+ });
143
+ }
144
+
145
+ main();
146
+
@@ -0,0 +1,283 @@
1
+ <script>
2
+ import { getNodeRoute } from '../data/universeStore.js';
3
+ import { getDisplayTitle } from '../format/title.js';
4
+
5
+ /** @type {import('../data/universeStore.js').NodeModel[]} */
6
+ export let children = [];
7
+
8
+ /**
9
+ * @param {string | undefined} dimensionId
10
+ * @returns {string}
11
+ */
12
+ function getDimensionKey(dimensionId) {
13
+ if (!dimensionId) return '__ungrouped__';
14
+ return dimensionId;
15
+ }
16
+
17
+ /**
18
+ * @param {string} value
19
+ * @returns {string}
20
+ */
21
+ function formatDimensionLabel(value) {
22
+ if (value === '__ungrouped__') return 'other';
23
+ const segment = value.split('.').pop() || value;
24
+ return segment.replace(/([a-z0-9])([A-Z])/g, '$1 $2').toLowerCase();
25
+ }
26
+
27
+ /**
28
+ * @param {string} kind
29
+ * @returns {string}
30
+ */
31
+ function formatKindSectionTitle(kind) {
32
+ if (kind === 'relationship') return 'relationships';
33
+ if (kind === 'relates') return 'relates';
34
+ return 'concepts';
35
+ }
36
+
37
+ $: groupedSections = (() => {
38
+ /** @type {Map<string, { key:string, title:string, items:any[], order:number }>} */
39
+ const byKey = new Map();
40
+ /** @type {Map<string, { key:string, title:string, items:any[], order:number }>} */
41
+ const otherByKind = new Map();
42
+ let nextOrder = 0;
43
+
44
+ // Seed groups from explicit dimension nodes first.
45
+ for (const child of children) {
46
+ if (child.kind !== 'dimension') continue;
47
+ byKey.set(child.id, {
48
+ key: child.id,
49
+ title: formatDimensionLabel(child.name || child.id),
50
+ items: [],
51
+ order: nextOrder++,
52
+ });
53
+ }
54
+
55
+ for (const child of children) {
56
+ // Dimension rows become section headers, not list entries.
57
+ if (child.kind === 'dimension') continue;
58
+
59
+ if (child.dimension) {
60
+ const key = getDimensionKey(child.dimension);
61
+ if (!byKey.has(key)) {
62
+ byKey.set(key, {
63
+ key,
64
+ title: formatDimensionLabel(key),
65
+ items: [],
66
+ order: nextOrder++,
67
+ });
68
+ }
69
+ const group = byKey.get(key);
70
+ if (group) {
71
+ group.items.push(child);
72
+ }
73
+ } else {
74
+ const kind = child.kind || 'concept';
75
+ if (!otherByKind.has(kind)) {
76
+ otherByKind.set(kind, {
77
+ key: `other:${kind}`,
78
+ title: formatKindSectionTitle(kind),
79
+ items: [],
80
+ order: nextOrder++,
81
+ });
82
+ }
83
+ const group = otherByKind.get(kind);
84
+ if (group) {
85
+ group.items.push(child);
86
+ }
87
+ }
88
+ }
89
+
90
+ const dimensionGroups = Array.from(byKey.values())
91
+ .map((group) => ({ ...group }))
92
+ .filter((group) => group.items.length > 0)
93
+ .sort((a, b) => a.order - b.order);
94
+
95
+ const otherOrder = ['concept', 'relationship', 'relates'];
96
+ const otherGroups = Array.from(otherByKind.values())
97
+ .map((group) => ({ ...group }))
98
+ .filter((group) => group.items.length > 0)
99
+ .sort((a, b) => {
100
+ const aKind = a.key.replace('other:', '');
101
+ const bKind = b.key.replace('other:', '');
102
+ const aIndex = otherOrder.indexOf(aKind);
103
+ const bIndex = otherOrder.indexOf(bKind);
104
+ if (aIndex !== bIndex) return (aIndex === -1 ? 999 : aIndex) - (bIndex === -1 ? 999 : bIndex);
105
+ return a.order - b.order;
106
+ });
107
+
108
+ return { dimensionGroups, otherGroups };
109
+ })();
110
+
111
+ $: dimensionGroups = groupedSections.dimensionGroups;
112
+ $: otherGroups = groupedSections.otherGroups;
113
+ </script>
114
+
115
+ <div class="card">
116
+ {#if dimensionGroups.length > 0 || otherGroups.length > 0}
117
+ {#each dimensionGroups as group}
118
+ <div class="title">{group.title}</div>
119
+ <ul class="list">
120
+ {#each group.items as child}
121
+ <li class="item">
122
+ <a class="link sprig-link" href={getNodeRoute(child)}>
123
+ <span class="name">{getDisplayTitle(child)}</span>
124
+ </a>
125
+ </li>
126
+ {/each}
127
+ </ul>
128
+ {/each}
129
+
130
+ {#if otherGroups.length > 0}
131
+ {#if dimensionGroups.length > 0}
132
+ <hr class="section-separator" />
133
+ {/if}
134
+ {#each otherGroups as group}
135
+ <div class="title">{group.title}</div>
136
+ <ul class="list">
137
+ {#each group.items as child}
138
+ <li class="item">
139
+ <a class="link sprig-link" href={getNodeRoute(child)}>
140
+ <span class="name">{getDisplayTitle(child)}</span>
141
+ </a>
142
+ </li>
143
+ {/each}
144
+ </ul>
145
+ {/each}
146
+ {/if}
147
+ {:else}
148
+ <p class="empty">No contents yet.</p>
149
+ {/if}
150
+ </div>
151
+
152
+ <style>
153
+ .card {
154
+ background-color: var(--card-bg);
155
+ border-radius: 4px;
156
+ padding: 24px;
157
+ font-size: var(--sp-font-small);
158
+ line-height: 1.6;
159
+ width: 100%;
160
+ position: sticky;
161
+ top: 32px;
162
+ }
163
+
164
+ .title {
165
+ font-size: var(--sp-font-small);
166
+ color: var(--text-tertiary);
167
+ letter-spacing: 0.5px;
168
+ margin: 0 0 0.75rem 0;
169
+ font-family: var(--font-ui);
170
+ font-weight: 400;
171
+ }
172
+
173
+ .title:not(:first-child) {
174
+ margin-top: 1.75rem;
175
+ }
176
+
177
+ .section-separator {
178
+ border: 0;
179
+ border-top: 1px solid var(--hairline);
180
+ margin: 1.5rem 0 0;
181
+ }
182
+
183
+ .list {
184
+ list-style: none;
185
+ padding: 0;
186
+ margin: 0 0 2px 0;
187
+ }
188
+
189
+ .item {
190
+ margin-bottom: 10px;
191
+ color: var(--text-secondary);
192
+ }
193
+
194
+ .item:last-child {
195
+ margin-bottom: 0;
196
+ }
197
+
198
+ .link {
199
+ align-items: center;
200
+ background: none;
201
+ border: none;
202
+ cursor: pointer;
203
+ display: flex;
204
+ font-family: inherit;
205
+ font-size: inherit;
206
+ gap: 0.75rem;
207
+ justify-content: space-between;
208
+ padding: 0;
209
+ text-align: left;
210
+ width: 100%;
211
+ }
212
+
213
+ /* Remove underline from link itself, show only on name */
214
+ .link.sprig-link {
215
+ text-decoration: none;
216
+ }
217
+
218
+ /* Show underline only on hover - no underline at rest */
219
+ .link.sprig-link .name {
220
+ text-decoration: none;
221
+ }
222
+
223
+ .link.sprig-link:hover .name {
224
+ text-decoration: underline;
225
+ text-decoration-thickness: 1px;
226
+ text-underline-offset: 0.25rem;
227
+ text-decoration-color: rgba(0, 0, 0, 0.25);
228
+ }
229
+
230
+ @media (prefers-color-scheme: dark) {
231
+ :root:not([data-theme="light"]) .link.sprig-link:hover .name {
232
+ text-decoration-color: rgba(255, 255, 255, 0.35);
233
+ }
234
+ }
235
+
236
+ :global(html[data-theme="dark"]) .link.sprig-link:hover .name {
237
+ text-decoration-color: rgba(255, 255, 255, 0.35);
238
+ }
239
+
240
+ :global(html[data-theme="light"]) .link.sprig-link:hover .name {
241
+ text-decoration-color: rgba(0, 0, 0, 0.4);
242
+ }
243
+
244
+ .link:focus {
245
+ outline: 2px solid var(--text-tertiary);
246
+ outline-offset: 0.25rem;
247
+ border-radius: 2px;
248
+ }
249
+
250
+ .name {
251
+ font-size: var(--sp-font-body);
252
+ font-family: var(--font-prose);
253
+ font-weight: 400;
254
+ color: var(--text-color);
255
+ }
256
+
257
+ .empty {
258
+ color: var(--text-tertiary);
259
+ font-size: var(--sp-font-small);
260
+ margin: 0;
261
+ font-family: var(--font-ui);
262
+ }
263
+
264
+ @media (max-width: 768px) {
265
+ .card {
266
+ padding: 20px;
267
+ font-size: var(--sp-font-small);
268
+ position: static;
269
+ }
270
+
271
+ .item {
272
+ margin-bottom: 20px;
273
+ }
274
+ }
275
+
276
+ @media (max-width: 480px) {
277
+ .card {
278
+ padding: 18px;
279
+ font-size: var(--sp-font-tiny);
280
+ }
281
+ }
282
+ </style>
283
+