@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/main.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import './styles/app.css';
|
|
2
|
+
import './lib/stores/theme.js'; // Initialize theme store
|
|
3
|
+
import { theme } from './lib/stores/theme.js';
|
|
4
|
+
import { get } from 'svelte/store';
|
|
5
|
+
import App from './App.svelte';
|
|
6
|
+
import { mount } from 'svelte';
|
|
7
|
+
|
|
8
|
+
const target = document.getElementById('app');
|
|
9
|
+
if (!target) {
|
|
10
|
+
throw new Error('Target element #app not found');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const app = mount(App, {
|
|
14
|
+
target,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
// Initialize texture generation after component mounts (outside component to avoid effect_orphan)
|
|
18
|
+
async function initTexture() {
|
|
19
|
+
try {
|
|
20
|
+
const textureModule = await import('@sprig-and-prose/sprig-texture');
|
|
21
|
+
const { generateTexture, releaseObjectUrl, sprigGrey, parchment, subtle } = textureModule;
|
|
22
|
+
|
|
23
|
+
const textureState = {
|
|
24
|
+
light: null,
|
|
25
|
+
dark: null,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Get system preference for color scheme
|
|
30
|
+
* @returns {'light' | 'dark'}
|
|
31
|
+
*/
|
|
32
|
+
function getSystemPreference() {
|
|
33
|
+
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Get effective theme (resolves 'auto' to system preference)
|
|
38
|
+
* @param {'light' | 'dark' | 'auto'} themeValue
|
|
39
|
+
* @returns {'light' | 'dark'}
|
|
40
|
+
*/
|
|
41
|
+
function getEffectiveTheme(themeValue) {
|
|
42
|
+
if (themeValue === 'auto') {
|
|
43
|
+
return getSystemPreference();
|
|
44
|
+
}
|
|
45
|
+
return themeValue;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function updateTexture(themeValue) {
|
|
49
|
+
const effectiveTheme = getEffectiveTheme(themeValue);
|
|
50
|
+
const url = effectiveTheme === 'dark' ? textureState.dark : textureState.light;
|
|
51
|
+
if (url) {
|
|
52
|
+
// Apply texture to body
|
|
53
|
+
document.body.style.backgroundImage = `url(${url})`;
|
|
54
|
+
document.body.style.backgroundRepeat = 'repeat';
|
|
55
|
+
document.body.style.backgroundSize = '1024px 1024px';
|
|
56
|
+
document.body.style.backgroundAttachment = 'fixed';
|
|
57
|
+
|
|
58
|
+
// Apply same texture to top bar so it matches the background
|
|
59
|
+
const topBar = document.querySelector('.top-bar');
|
|
60
|
+
if (topBar) {
|
|
61
|
+
topBar.style.backgroundImage = `url(${url})`;
|
|
62
|
+
topBar.style.backgroundRepeat = 'repeat';
|
|
63
|
+
topBar.style.backgroundSize = '1024px 1024px';
|
|
64
|
+
topBar.style.backgroundAttachment = 'fixed';
|
|
65
|
+
topBar.style.backgroundPosition = '0 0'; // Align with body
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Generate both textures
|
|
71
|
+
const { url: lightUrl } = await generateTexture({
|
|
72
|
+
key: 'sprig-ui-light',
|
|
73
|
+
palette: parchment,
|
|
74
|
+
preset: subtle,
|
|
75
|
+
});
|
|
76
|
+
textureState.light = lightUrl;
|
|
77
|
+
|
|
78
|
+
const { url: darkUrl } = await generateTexture({
|
|
79
|
+
key: 'sprig-ui-dark',
|
|
80
|
+
palette: sprigGrey,
|
|
81
|
+
preset: subtle,
|
|
82
|
+
});
|
|
83
|
+
textureState.dark = darkUrl;
|
|
84
|
+
|
|
85
|
+
// Track current theme value
|
|
86
|
+
let currentThemeValue = get(theme);
|
|
87
|
+
|
|
88
|
+
// Listen for system preference changes when theme is 'auto'
|
|
89
|
+
let mediaQuery = null;
|
|
90
|
+
let mediaQueryListener = null;
|
|
91
|
+
|
|
92
|
+
function setupSystemPreferenceListener() {
|
|
93
|
+
if (typeof window === 'undefined') return;
|
|
94
|
+
if (mediaQuery) return; // Already set up
|
|
95
|
+
|
|
96
|
+
mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
|
97
|
+
mediaQueryListener = () => {
|
|
98
|
+
// Only update texture if current theme is 'auto'
|
|
99
|
+
if (currentThemeValue === 'auto') {
|
|
100
|
+
updateTexture('auto');
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
mediaQuery.addEventListener('change', mediaQueryListener);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function cleanupSystemPreferenceListener() {
|
|
107
|
+
if (mediaQuery && mediaQueryListener) {
|
|
108
|
+
mediaQuery.removeEventListener('change', mediaQueryListener);
|
|
109
|
+
mediaQuery = null;
|
|
110
|
+
mediaQueryListener = null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Subscribe to theme changes
|
|
115
|
+
const unsubscribe = theme.subscribe((themeValue) => {
|
|
116
|
+
currentThemeValue = themeValue;
|
|
117
|
+
updateTexture(themeValue);
|
|
118
|
+
|
|
119
|
+
// Setup/cleanup system preference listener based on theme
|
|
120
|
+
if (themeValue === 'auto') {
|
|
121
|
+
setupSystemPreferenceListener();
|
|
122
|
+
} else {
|
|
123
|
+
cleanupSystemPreferenceListener();
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// Apply initial texture
|
|
128
|
+
updateTexture(get(theme));
|
|
129
|
+
|
|
130
|
+
// Setup listener if initial theme is 'auto'
|
|
131
|
+
if (get(theme) === 'auto') {
|
|
132
|
+
setupSystemPreferenceListener();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Store cleanup on window for potential cleanup
|
|
136
|
+
window.__textureCleanup = () => {
|
|
137
|
+
unsubscribe();
|
|
138
|
+
cleanupSystemPreferenceListener();
|
|
139
|
+
if (textureState.light) {
|
|
140
|
+
releaseObjectUrl(textureState.light);
|
|
141
|
+
}
|
|
142
|
+
if (textureState.dark) {
|
|
143
|
+
releaseObjectUrl(textureState.dark);
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
} catch (error) {
|
|
147
|
+
console.error('Failed to generate texture:', error);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Initialize texture after component is mounted
|
|
152
|
+
setTimeout(initTexture, 0);
|
|
153
|
+
|
|
154
|
+
export default app;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
import { universeGraph, currentUniverseName, getNodeRoute, getRelationshipsForNode, getAncestorChain, organizeChildren } from '../lib/data/universeStore.js';
|
|
3
|
+
import { getDisplayTitle } from '../lib/format/title.js';
|
|
4
|
+
import { getDisplayKind } from '../lib/format/kind.js';
|
|
5
|
+
import Prose from '../lib/components/Prose.svelte';
|
|
6
|
+
import ContentsCard from '../lib/components/ContentsCard.svelte';
|
|
7
|
+
import NodePageLayout from '../lib/components/NodePageLayout.svelte';
|
|
8
|
+
import RelationshipsSection from '../lib/components/RelationshipsSection.svelte';
|
|
9
|
+
|
|
10
|
+
/** @type {{ universe: string, id: string }} */
|
|
11
|
+
export let params;
|
|
12
|
+
|
|
13
|
+
$: currentUniverseName.set(params.universe);
|
|
14
|
+
|
|
15
|
+
$: nodeId = decodeURIComponent(params.id);
|
|
16
|
+
$: currentNode = $universeGraph?.nodes[nodeId];
|
|
17
|
+
$: childIds = currentNode?.children || [];
|
|
18
|
+
$: rawChildren = childIds
|
|
19
|
+
.map((id) => $universeGraph?.nodes[id])
|
|
20
|
+
.filter(Boolean) || [];
|
|
21
|
+
$: sameDimensionPeers = (() => {
|
|
22
|
+
if (!currentNode?.dimension || !$universeGraph?.nodes) return [];
|
|
23
|
+
return Object.values($universeGraph.nodes)
|
|
24
|
+
.filter((node) => node.id !== currentNode.id)
|
|
25
|
+
.filter((node) => node.dimension === currentNode.dimension);
|
|
26
|
+
})();
|
|
27
|
+
$: navChildren = (() => {
|
|
28
|
+
const byId = new Map();
|
|
29
|
+
for (const child of rawChildren) {
|
|
30
|
+
if (child) {
|
|
31
|
+
byId.set(child.id, child);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
for (const peer of sameDimensionPeers) {
|
|
35
|
+
byId.set(peer.id, peer);
|
|
36
|
+
}
|
|
37
|
+
return Array.from(byId.values());
|
|
38
|
+
})();
|
|
39
|
+
$: organizedChildren = currentNode ? organizeChildren(navChildren) : { primary: [], other: [] };
|
|
40
|
+
$: hasIndex = organizedChildren.primary.length > 0 || organizedChildren.other.length > 0;
|
|
41
|
+
$: relationships = currentNode ? getRelationshipsForNode($universeGraph, nodeId) : [];
|
|
42
|
+
$: ancestors = currentNode ? getAncestorChain($universeGraph, currentNode) : [];
|
|
43
|
+
|
|
44
|
+
$: subtitle = (() => {
|
|
45
|
+
if (!currentNode) return '';
|
|
46
|
+
const displayKind = getDisplayKind(currentNode);
|
|
47
|
+
if (ancestors.length > 0) {
|
|
48
|
+
const parent = ancestors[0];
|
|
49
|
+
return `A ${displayKind} in <a href="${getNodeRoute(parent)}">${getDisplayTitle(parent)}</a>`;
|
|
50
|
+
}
|
|
51
|
+
return `A ${displayKind} in ${params.universe}`;
|
|
52
|
+
})();
|
|
53
|
+
</script>
|
|
54
|
+
|
|
55
|
+
{#if currentNode}
|
|
56
|
+
<NodePageLayout
|
|
57
|
+
title={getDisplayTitle(currentNode)}
|
|
58
|
+
{subtitle}
|
|
59
|
+
graph={$universeGraph}
|
|
60
|
+
showIndex={hasIndex}
|
|
61
|
+
>
|
|
62
|
+
<svelte:fragment slot="narrative">
|
|
63
|
+
<Prose textBlock={currentNode.describe} />
|
|
64
|
+
</svelte:fragment>
|
|
65
|
+
|
|
66
|
+
<svelte:fragment slot="index">
|
|
67
|
+
{@const allChildren = [...organizedChildren.primary, ...organizedChildren.other]}
|
|
68
|
+
<ContentsCard children={allChildren} />
|
|
69
|
+
</svelte:fragment>
|
|
70
|
+
|
|
71
|
+
<svelte:fragment slot="sections">
|
|
72
|
+
<RelationshipsSection
|
|
73
|
+
{relationships}
|
|
74
|
+
/>
|
|
75
|
+
</svelte:fragment>
|
|
76
|
+
</NodePageLayout>
|
|
77
|
+
{:else}
|
|
78
|
+
<div class="loading">Loading…</div>
|
|
79
|
+
{/if}
|
|
80
|
+
|
|
81
|
+
<style>
|
|
82
|
+
.loading {
|
|
83
|
+
color: var(--text-tertiary);
|
|
84
|
+
padding: 24px 0;
|
|
85
|
+
}
|
|
86
|
+
</style>
|
|
87
|
+
|
|
88
|
+
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
import { getDisplayTitle } from '../lib/format/title.js';
|
|
3
|
+
import { universeRootNode, rootChildren, universeGraph } from '../lib/data/universeStore.js';
|
|
4
|
+
import Prose from '../lib/components/Prose.svelte';
|
|
5
|
+
import ContentsCard from '../lib/components/ContentsCard.svelte';
|
|
6
|
+
import NodePageLayout from '../lib/components/NodePageLayout.svelte';
|
|
7
|
+
|
|
8
|
+
/** @param {string} s */
|
|
9
|
+
const firstSentence = (s) => {
|
|
10
|
+
if (!s) return '';
|
|
11
|
+
const idx = s.indexOf('.');
|
|
12
|
+
return idx >= 0 ? s.slice(0, idx + 1) : s;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
$: subtitle = $universeRootNode?.describe?.normalized || $universeRootNode?.describe?.raw
|
|
16
|
+
? firstSentence($universeRootNode.describe?.normalized ?? $universeRootNode.describe?.raw)
|
|
17
|
+
: null;
|
|
18
|
+
|
|
19
|
+
$: hasIndex = $rootChildren.primary.length > 0 || $rootChildren.other.length > 0;
|
|
20
|
+
</script>
|
|
21
|
+
|
|
22
|
+
{#if $universeRootNode}
|
|
23
|
+
<NodePageLayout
|
|
24
|
+
title={getDisplayTitle($universeRootNode)}
|
|
25
|
+
{subtitle}
|
|
26
|
+
graph={$universeGraph}
|
|
27
|
+
showIndex={hasIndex}
|
|
28
|
+
>
|
|
29
|
+
<svelte:fragment slot="narrative">
|
|
30
|
+
<Prose textBlock={$universeRootNode.describe} />
|
|
31
|
+
</svelte:fragment>
|
|
32
|
+
|
|
33
|
+
<svelte:fragment slot="index">
|
|
34
|
+
{@const allChildren = [...$rootChildren.primary, ...$rootChildren.other]}
|
|
35
|
+
<ContentsCard children={allChildren} />
|
|
36
|
+
</svelte:fragment>
|
|
37
|
+
</NodePageLayout>
|
|
38
|
+
{:else}
|
|
39
|
+
<div class="loading">Loading…</div>
|
|
40
|
+
{/if}
|
|
41
|
+
|
|
42
|
+
<style>
|
|
43
|
+
.loading {
|
|
44
|
+
color: var(--text-tertiary);
|
|
45
|
+
padding: 24px 0;
|
|
46
|
+
}
|
|
47
|
+
</style>
|
|
48
|
+
|
package/src/server.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { createServer } from 'http';
|
|
2
|
+
import { readFileSync, existsSync, statSync } from 'fs';
|
|
3
|
+
import { join, extname, resolve, dirname } from 'path';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
|
|
6
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
7
|
+
const __dirname = dirname(__filename);
|
|
8
|
+
|
|
9
|
+
const mimeTypes = {
|
|
10
|
+
'.html': 'text/html',
|
|
11
|
+
'.js': 'application/javascript',
|
|
12
|
+
'.json': 'application/json',
|
|
13
|
+
'.css': 'text/css',
|
|
14
|
+
'.png': 'image/png',
|
|
15
|
+
'.jpg': 'image/jpeg',
|
|
16
|
+
'.jpeg': 'image/jpeg',
|
|
17
|
+
'.gif': 'image/gif',
|
|
18
|
+
'.svg': 'image/svg+xml',
|
|
19
|
+
'.woff': 'font/woff',
|
|
20
|
+
'.woff2': 'font/woff2',
|
|
21
|
+
'.ttf': 'font/ttf',
|
|
22
|
+
'.eot': 'application/vnd.ms-fontobject',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Create and return the HTTP server
|
|
27
|
+
* @param {string} manifestPath - Path to manifest.json file
|
|
28
|
+
* @returns {import('http').Server}
|
|
29
|
+
*/
|
|
30
|
+
export function createAppServer(manifestPath) {
|
|
31
|
+
const distDir = resolve(__dirname, '../dist');
|
|
32
|
+
const manifestFilePath = resolve(process.cwd(), manifestPath);
|
|
33
|
+
|
|
34
|
+
const server = createServer((req, res) => {
|
|
35
|
+
const url = new URL(req.url || '/', `http://${req.headers.host}`);
|
|
36
|
+
const pathname = url.pathname;
|
|
37
|
+
|
|
38
|
+
// Handle /_ui/* routes - serve static files from dist/
|
|
39
|
+
if (pathname.startsWith('/_ui/')) {
|
|
40
|
+
// Remove /_ui prefix to get the file path relative to dist/
|
|
41
|
+
const filePath = pathname.slice(5); // Remove '/_ui/'
|
|
42
|
+
const fullPath = join(distDir, filePath);
|
|
43
|
+
|
|
44
|
+
// Security: ensure file is within dist directory
|
|
45
|
+
const resolvedPath = resolve(fullPath);
|
|
46
|
+
if (!resolvedPath.startsWith(resolve(distDir))) {
|
|
47
|
+
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
|
48
|
+
res.end('Forbidden');
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (!existsSync(resolvedPath) || !statSync(resolvedPath).isFile()) {
|
|
53
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
54
|
+
res.end('Not Found');
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
const content = readFileSync(resolvedPath);
|
|
60
|
+
const ext = extname(resolvedPath);
|
|
61
|
+
const contentType = mimeTypes[ext] || 'application/octet-stream';
|
|
62
|
+
|
|
63
|
+
res.writeHead(200, { 'Content-Type': contentType });
|
|
64
|
+
res.end(content);
|
|
65
|
+
} catch (error) {
|
|
66
|
+
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
67
|
+
res.end('Internal Server Error');
|
|
68
|
+
}
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Handle /api/manifest route
|
|
73
|
+
if (pathname === '/api/manifest') {
|
|
74
|
+
if (!existsSync(manifestFilePath) || !statSync(manifestFilePath).isFile()) {
|
|
75
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
76
|
+
res.end(JSON.stringify({ error: 'Manifest not found' }));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
const content = readFileSync(manifestFilePath, 'utf-8');
|
|
82
|
+
const headers = {
|
|
83
|
+
'Content-Type': 'application/json',
|
|
84
|
+
'X-Describe-Render-Mode': 'lists',
|
|
85
|
+
};
|
|
86
|
+
res.writeHead(200, headers);
|
|
87
|
+
res.end(content);
|
|
88
|
+
} catch (error) {
|
|
89
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
90
|
+
res.end(JSON.stringify({ error: 'Error reading manifest' }));
|
|
91
|
+
}
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// All other routes - serve index.html for client-side routing
|
|
96
|
+
const indexPath = join(distDir, 'index.html');
|
|
97
|
+
if (!existsSync(indexPath)) {
|
|
98
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
99
|
+
res.end('Not Found - dist/index.html does not exist. Run "npm run build" first.');
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
const content = readFileSync(indexPath, 'utf-8');
|
|
105
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
106
|
+
res.end(content);
|
|
107
|
+
} catch (error) {
|
|
108
|
+
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
109
|
+
res.end('Internal Server Error');
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
return server;
|
|
114
|
+
}
|
|
115
|
+
|