@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/README.md +120 -0
- package/dist/assets/index-B6I7oo2K.js +1 -0
- package/dist/assets/index-O71xRVzw.js +4 -0
- package/dist/assets/index-coj3G3o6.css +1 -0
- package/dist/index.html +13 -0
- package/package.json +33 -0
- package/src/App.svelte +277 -0
- package/src/cli.js +146 -0
- package/src/lib/components/ContentsCard.svelte +283 -0
- package/src/lib/components/FooterStatus.svelte +83 -0
- package/src/lib/components/GlobalSearch.svelte +428 -0
- package/src/lib/components/NodePageLayout.svelte +65 -0
- package/src/lib/components/PageHeader.svelte +116 -0
- package/src/lib/components/Prose.svelte +260 -0
- package/src/lib/components/RelationshipsSection.svelte +122 -0
- package/src/lib/components/UniverseHeader.svelte +20 -0
- package/src/lib/data/universeStore.js +350 -0
- package/src/lib/format/kind.js +26 -0
- package/src/lib/format/title.js +97 -0
- package/src/lib/router.js +39 -0
- package/src/lib/stores/describeRenderMode.js +9 -0
- package/src/lib/stores/theme.js +98 -0
- package/src/main.js +154 -0
- package/src/pages/ConceptPage.svelte +88 -0
- package/src/pages/HomePage.svelte +48 -0
- package/src/server.js +115 -0
- package/src/styles/app.css +353 -0
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
|
+
|