@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/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
+