@sprig-and-prose/sprig-ui-csr 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/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@sprig-and-prose/sprig-ui-csr",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "bin": {
6
+ "sprig-ui-csr": "./src/cli.js"
7
+ },
8
+ "scripts": {
9
+ "dev": "vite dev",
10
+ "build": "vite build",
11
+ "start": "node src/cli.js",
12
+ "format": "biome format . --write",
13
+ "lint": "biome lint .",
14
+ "typecheck": "tsc -p tsconfig.json"
15
+ },
16
+ "dependencies": {
17
+ "@sprig-and-prose/sprig-design": "^0.1.5",
18
+ "@sprig-and-prose/sprig-texture": "^0.1.0"
19
+ },
20
+ "devDependencies": {
21
+ "@biomejs/biome": "^2.3.10",
22
+ "@sveltejs/vite-plugin-svelte": "^5.0.0",
23
+ "@types/node": "^22.0.0",
24
+ "svelte": "^5.46.0",
25
+ "typescript": "^5.7.2",
26
+ "vite": "^6.0.0"
27
+ }
28
+ }
29
+
package/src/App.svelte ADDED
@@ -0,0 +1,202 @@
1
+ <script>
2
+ import { loadUniverseGraph } from './lib/data/universeStore.js';
3
+ import { getCurrentRoute, navigate } from './lib/router.js';
4
+ import { theme } from './lib/stores/theme.js';
5
+ import { describeRenderMode } from './lib/stores/describeRenderMode.js';
6
+ import GlobalSearch from './lib/components/GlobalSearch.svelte';
7
+ import HomePage from './pages/HomePage.svelte';
8
+ import SeriesPage from './pages/SeriesPage.svelte';
9
+ import AnthologyPage from './pages/AnthologyPage.svelte';
10
+ import ConceptPage from './pages/ConceptPage.svelte';
11
+
12
+ let loading = true;
13
+ let error = null;
14
+ let currentRoute = getCurrentRoute();
15
+
16
+ function toggleTheme() {
17
+ theme.update((t) => {
18
+ if (t === 'light') return 'dark';
19
+ if (t === 'dark') return 'auto';
20
+ return 'light'; // 'auto' -> 'light'
21
+ });
22
+ }
23
+
24
+ // Get display label for theme toggle
25
+ $: themeLabel = (() => {
26
+ if ($theme === 'auto') {
27
+ return 'Auto';
28
+ }
29
+ return $theme === 'dark' ? 'Light' : 'Dark';
30
+ })();
31
+
32
+ async function loadManifest() {
33
+ try {
34
+ loading = true;
35
+ error = null;
36
+ const { describeRenderMode: mode } = await loadUniverseGraph('/api/manifest');
37
+ if (mode === 'lists' || mode === 'plain') {
38
+ describeRenderMode.set(mode);
39
+ }
40
+ } catch (err) {
41
+ error = err.message || 'Failed to load manifest';
42
+ console.error('Error loading manifest:', err);
43
+ } finally {
44
+ loading = false;
45
+ }
46
+ }
47
+
48
+ function handleRouteChange() {
49
+ currentRoute = getCurrentRoute();
50
+ }
51
+
52
+ // Intercept link clicks for client-side navigation
53
+ function handleLinkClick(event) {
54
+ const link = event.target.closest('a');
55
+ if (!link) return;
56
+
57
+ const href = link.getAttribute('href');
58
+ if (!href) return;
59
+
60
+ // Only handle internal links (not external or hash links)
61
+ if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('mailto:') || href.startsWith('#')) {
62
+ return;
63
+ }
64
+
65
+ // Check if it's an internal route
66
+ if (href.startsWith('/') || !href.includes('://')) {
67
+ event.preventDefault();
68
+ navigate(href);
69
+ handleRouteChange();
70
+ }
71
+ }
72
+
73
+ // Listen for popstate (back/forward button)
74
+ window.addEventListener('popstate', handleRouteChange);
75
+
76
+ // Listen for link clicks
77
+ document.addEventListener('click', handleLinkClick);
78
+
79
+ loadManifest();
80
+ </script>
81
+
82
+ <div class="app sprig-design">
83
+ <div class="page">
84
+ <div class="top-bar">
85
+ <GlobalSearch />
86
+ <button class="theme-toggle" on:click={toggleTheme} type="button" aria-label="Toggle theme">
87
+ {themeLabel}
88
+ </button>
89
+ </div>
90
+ <main class="main-content">
91
+ {#if loading}
92
+ <div class="loading">Loading…</div>
93
+ {:else if error}
94
+ <div class="error">Error: {error}</div>
95
+ {:else if currentRoute}
96
+ {#if currentRoute.route === 'home'}
97
+ <HomePage />
98
+ {:else if currentRoute.route === 'series'}
99
+ <SeriesPage params={currentRoute.params} />
100
+ {:else if currentRoute.route === 'anthology'}
101
+ <AnthologyPage params={currentRoute.params} />
102
+ {:else if currentRoute.route === 'concept'}
103
+ <ConceptPage params={currentRoute.params} />
104
+ {:else}
105
+ <div class="error">Unknown route: {currentRoute.route}</div>
106
+ {/if}
107
+ {:else}
108
+ <div class="error">Invalid route</div>
109
+ {/if}
110
+ </main>
111
+ </div>
112
+ </div>
113
+
114
+ <style>
115
+ .app {
116
+ min-height: 100vh;
117
+ width: 100%;
118
+ }
119
+
120
+ .page {
121
+ max-width: 1200px;
122
+ margin: 0 auto;
123
+ width: 100%;
124
+ }
125
+
126
+ .top-bar {
127
+ padding: 24px 32px;
128
+ border-bottom: 1px solid var(--border-color);
129
+ display: flex;
130
+ justify-content: space-between;
131
+ align-items: center;
132
+ gap: 1rem;
133
+ width: 100%;
134
+ position: sticky;
135
+ top: 0;
136
+ background-color: var(--bg-color);
137
+ z-index: 100;
138
+ }
139
+
140
+ .main-content {
141
+ padding: 64px 32px;
142
+ min-height: calc(100vh - 200px);
143
+ width: 100%;
144
+ }
145
+
146
+ .theme-toggle {
147
+ background: var(--card-bg);
148
+ border: none;
149
+ border-radius: 4px;
150
+ color: var(--text-color);
151
+ font-family: var(--font-ui);
152
+ font-size: var(--sp-font-small);
153
+ padding: 8px 12px;
154
+ cursor: pointer;
155
+ white-space: nowrap;
156
+ transition: background 0.2s, opacity 0.2s;
157
+ }
158
+
159
+ .theme-toggle:hover {
160
+ background: var(--hover);
161
+ opacity: 0.8;
162
+ }
163
+
164
+ .theme-toggle:focus {
165
+ outline: 2px solid var(--text-tertiary);
166
+ outline-offset: 2px;
167
+ border-radius: 2px;
168
+ }
169
+
170
+ .loading,
171
+ .error {
172
+ color: var(--text-tertiary);
173
+ padding: 24px 0;
174
+ font-size: var(--sp-font-body);
175
+ }
176
+
177
+ .error {
178
+ color: #d32f2f;
179
+ }
180
+
181
+ @media (max-width: 768px) {
182
+ .top-bar {
183
+ padding: 20px 24px;
184
+ }
185
+
186
+ .main-content {
187
+ padding: 48px 24px;
188
+ }
189
+ }
190
+
191
+ @media (max-width: 480px) {
192
+ .top-bar {
193
+ padding: 16px 20px;
194
+ flex-wrap: wrap;
195
+ gap: 8px;
196
+ }
197
+
198
+ .main-content {
199
+ padding: 32px 20px;
200
+ }
201
+ }
202
+ </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-csr [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-csr
83
+ sprig-ui-csr --port 3000
84
+ sprig-ui-csr --manifest ./custom-manifest.json
85
+ sprig-ui-csr -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,167 @@
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
+ /** @type {import('../data/universeStore.js').NodeModel | null} */
8
+ export let currentNode = null;
9
+ /** @type {string | null} */
10
+ export let title = null;
11
+
12
+ $: titleText = title !== null
13
+ ? title
14
+ : currentNode?.kind === 'book' && children.length > 0
15
+ ? `Contents (Chapters in ${getDisplayTitle(currentNode)})`
16
+ : 'Contents';
17
+ </script>
18
+
19
+ <div class="card">
20
+ <div class="title">{titleText}</div>
21
+
22
+ {#if children.length > 0}
23
+ <ul class="list">
24
+ {#each children as child}
25
+ <li class="item">
26
+ <a class="link sprig-link" href={getNodeRoute(child)}>
27
+ <span class="name">{getDisplayTitle(child)}</span>
28
+ <span class="kind">{child.kind}</span>
29
+ </a>
30
+ </li>
31
+ {/each}
32
+ </ul>
33
+ {:else}
34
+ <p class="empty">No contents yet.</p>
35
+ {/if}
36
+ </div>
37
+
38
+ <style>
39
+ .card {
40
+ background-color: var(--card-bg);
41
+ border-radius: 4px;
42
+ padding: 24px;
43
+ font-size: var(--sp-font-small);
44
+ line-height: 1.6;
45
+ width: 100%;
46
+ position: sticky;
47
+ top: 32px;
48
+ }
49
+
50
+ .title {
51
+ font-size: var(--sp-font-tiny);
52
+ color: var(--text-tertiary);
53
+ letter-spacing: 0.5px;
54
+ margin-bottom: 20px;
55
+ font-family: var(--font-ui);
56
+ }
57
+
58
+ .list {
59
+ list-style: none;
60
+ padding: 0;
61
+ margin: 0;
62
+ }
63
+
64
+ .item {
65
+ margin-bottom: 20px;
66
+ color: var(--text-secondary);
67
+ }
68
+
69
+ .item:last-child {
70
+ margin-bottom: 0;
71
+ }
72
+
73
+ .link {
74
+ align-items: center;
75
+ background: none;
76
+ border: none;
77
+ cursor: pointer;
78
+ display: flex;
79
+ font-family: inherit;
80
+ font-size: inherit;
81
+ gap: 0.75rem;
82
+ justify-content: space-between;
83
+ padding: 0;
84
+ text-align: left;
85
+ width: 100%;
86
+ }
87
+
88
+ /* Remove underline from link itself, show only on name */
89
+ .link.sprig-link {
90
+ text-decoration: none;
91
+ }
92
+
93
+ /* Show underline only on hover - no underline at rest */
94
+ .link.sprig-link .name {
95
+ text-decoration: none;
96
+ }
97
+
98
+ .link.sprig-link:hover .name {
99
+ text-decoration: underline;
100
+ text-decoration-thickness: 1px;
101
+ text-underline-offset: 0.25rem;
102
+ text-decoration-color: rgba(0, 0, 0, 0.25);
103
+ }
104
+
105
+ @media (prefers-color-scheme: dark) {
106
+ :root:not([data-theme="light"]) .link.sprig-link:hover .name {
107
+ text-decoration-color: rgba(255, 255, 255, 0.35);
108
+ }
109
+ }
110
+
111
+ :global(html[data-theme="dark"]) .link.sprig-link:hover .name {
112
+ text-decoration-color: rgba(255, 255, 255, 0.35);
113
+ }
114
+
115
+ :global(html[data-theme="light"]) .link.sprig-link:hover .name {
116
+ text-decoration-color: rgba(0, 0, 0, 0.4);
117
+ }
118
+
119
+ .link:focus {
120
+ outline: 2px solid var(--text-tertiary);
121
+ outline-offset: 0.25rem;
122
+ border-radius: 2px;
123
+ }
124
+
125
+ .name {
126
+ font-size: var(--sp-font-body);
127
+ font-family: var(--font-prose);
128
+ font-weight: 400;
129
+ color: var(--text-color);
130
+ }
131
+
132
+ .kind {
133
+ font-size: var(--sp-font-tiny);
134
+ color: var(--text-tertiary);
135
+ font-family: var(--font-ui);
136
+ font-style: italic;
137
+ font-weight: normal;
138
+ text-decoration: none;
139
+ }
140
+
141
+ .empty {
142
+ color: var(--text-tertiary);
143
+ font-size: var(--sp-font-small);
144
+ margin: 0;
145
+ font-family: var(--font-ui);
146
+ }
147
+
148
+ @media (max-width: 768px) {
149
+ .card {
150
+ padding: 20px;
151
+ font-size: var(--sp-font-small);
152
+ position: static;
153
+ }
154
+
155
+ .item {
156
+ margin-bottom: 20px;
157
+ }
158
+ }
159
+
160
+ @media (max-width: 480px) {
161
+ .card {
162
+ padding: 18px;
163
+ font-size: var(--sp-font-tiny);
164
+ }
165
+ }
166
+ </style>
167
+
@@ -0,0 +1,80 @@
1
+ <script>
2
+ /** @type {import('../data/universeStore.js').UniverseGraph | null} */
3
+ export let graph = null;
4
+ /** @type {import('../data/universeStore.js').NodeModel | null} */
5
+ export let root = null;
6
+
7
+ /**
8
+ * @param {any} obj
9
+ */
10
+ const count = (obj) => (obj ? Object.keys(obj).length : 0);
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">Relationships</span>
24
+ <span class="value">{count(graph?.edges)}</span>
25
+ </div>
26
+ </footer>
27
+
28
+ <style>
29
+ .footer {
30
+ padding: 2rem 0;
31
+ border-top: 1px solid var(--border-color);
32
+ margin-top: 4rem;
33
+ width: 100%;
34
+ overflow-x: hidden;
35
+ display: flex;
36
+ justify-content: space-between;
37
+ gap: 18px;
38
+ font-size: var(--sp-font-tiny);
39
+ color: var(--text-tertiary);
40
+ max-width: 1200px;
41
+ margin-left: auto;
42
+ margin-right: auto;
43
+ }
44
+
45
+ .left, .right {
46
+ display: flex;
47
+ gap: 8px;
48
+ align-items: center;
49
+ flex-wrap: wrap;
50
+ }
51
+
52
+ .label {
53
+ color: var(--text-tertiary);
54
+ }
55
+
56
+ .value {
57
+ color: var(--text-secondary);
58
+ }
59
+
60
+ .dot {
61
+ color: var(--text-tertiary);
62
+ }
63
+
64
+ @media (max-width: 768px) {
65
+ .footer {
66
+ padding: 2rem 0;
67
+ margin-top: 48px;
68
+ font-size: var(--sp-font-tiny);
69
+ }
70
+ }
71
+
72
+ @media (max-width: 480px) {
73
+ .footer {
74
+ padding: 1.5rem 0;
75
+ margin-top: 40px;
76
+ font-size: var(--sp-font-tiny);
77
+ }
78
+ }
79
+ </style>
80
+