@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.
@@ -0,0 +1,42 @@
1
+ import { get } from 'svelte/store';
2
+ import { universeGraph } from '../data/universeStore.js';
3
+
4
+ /**
5
+ * Builds a URL for a repository root
6
+ * @param {string} repository - Repository key (e.g., "amaranthine")
7
+ * @returns {string | null} - Full URL to the repository root, or null if repository is unconfigured
8
+ */
9
+ export function linkForRepository(repository) {
10
+ const graph = get(universeGraph);
11
+ if (!graph?.repositories) {
12
+ return null;
13
+ }
14
+
15
+ const repoConfig = graph.repositories[repository];
16
+ if (!repoConfig) {
17
+ return null;
18
+ }
19
+
20
+ const { kind, options } = repoConfig;
21
+
22
+ // Handle GitHub repositories
23
+ if (kind === 'sprig-repository-github') {
24
+ // Prefer url if available, otherwise build from owner/repo
25
+ if (options?.url) {
26
+ const baseUrl = options.url;
27
+ // Remove trailing slash if present
28
+ return baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
29
+ }
30
+
31
+ const owner = options?.owner;
32
+ const repo = options?.repo;
33
+ if (owner && repo) {
34
+ const baseUrl = `https://github.com/${owner}/${repo}`;
35
+ // Remove trailing slash if present
36
+ return baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
37
+ }
38
+ }
39
+
40
+ return null;
41
+ }
42
+
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Simple client-side router for CSR app
3
+ */
4
+
5
+ /**
6
+ * Parse a route and extract parameters
7
+ * @param {string} pathname
8
+ * @returns {{ route: string, params: Record<string, string> } | null}
9
+ */
10
+ export function parseRoute(pathname) {
11
+ // Remove leading/trailing slashes
12
+ const path = pathname.replace(/^\/+|\/+$/g, '');
13
+
14
+ // Root route
15
+ if (!path || path === '') {
16
+ return { route: 'home', params: {} };
17
+ }
18
+
19
+ // Match /universes/:universe/series/:series
20
+ const seriesMatch = path.match(/^universes\/([^/]+)\/series\/([^/]+)$/);
21
+ if (seriesMatch) {
22
+ return {
23
+ route: 'series',
24
+ params: {
25
+ universe: seriesMatch[1],
26
+ series: seriesMatch[2],
27
+ },
28
+ };
29
+ }
30
+
31
+ // Match /universes/:universe/anthology/:anthology
32
+ const anthologyMatch = path.match(/^universes\/([^/]+)\/anthology\/([^/]+)$/);
33
+ if (anthologyMatch) {
34
+ return {
35
+ route: 'anthology',
36
+ params: {
37
+ universe: anthologyMatch[1],
38
+ anthology: anthologyMatch[2],
39
+ },
40
+ };
41
+ }
42
+
43
+ // Match /universes/:universe/concept/:id
44
+ const conceptMatch = path.match(/^universes\/([^/]+)\/concept\/(.+)$/);
45
+ if (conceptMatch) {
46
+ return {
47
+ route: 'concept',
48
+ params: {
49
+ universe: conceptMatch[1],
50
+ id: conceptMatch[2],
51
+ },
52
+ };
53
+ }
54
+
55
+ // Unknown route
56
+ return null;
57
+ }
58
+
59
+ /**
60
+ * Get current route from window.location
61
+ * @returns {{ route: string, params: Record<string, string> } | null}
62
+ */
63
+ export function getCurrentRoute() {
64
+ return parseRoute(window.location.pathname);
65
+ }
66
+
67
+ /**
68
+ * Navigate to a new route
69
+ * @param {string} path
70
+ */
71
+ export function navigate(path) {
72
+ window.history.pushState({}, '', path);
73
+ window.dispatchEvent(new PopStateEvent('popstate'));
74
+ }
75
+
@@ -0,0 +1,9 @@
1
+ import { writable } from 'svelte/store';
2
+
3
+ /**
4
+ * Store for describe block rendering mode.
5
+ * Values: 'plain' | 'lists'
6
+ * Default: 'plain'
7
+ */
8
+ export const describeRenderMode = writable(/** @type {'plain' | 'lists'} */ ('plain'));
9
+
@@ -0,0 +1,98 @@
1
+ import { writable } from 'svelte/store';
2
+
3
+ /**
4
+ * Get system preference for color scheme
5
+ * @returns {'light' | 'dark'}
6
+ */
7
+ function getSystemPreference() {
8
+ if (typeof window === 'undefined') return 'light';
9
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
10
+ }
11
+
12
+ /**
13
+ * Get initial theme: manual preference from localStorage, or system preference
14
+ * @returns {'light' | 'dark' | 'auto'}
15
+ */
16
+ function getInitialTheme() {
17
+ if (typeof localStorage === 'undefined') return 'auto';
18
+ const stored = localStorage.getItem('theme');
19
+ if (stored === 'light' || stored === 'dark') return stored;
20
+ return 'auto'; // 'auto' means use system preference
21
+ }
22
+
23
+ /**
24
+ * Get effective theme (resolves 'auto' to system preference)
25
+ * @param {'light' | 'dark' | 'auto'} theme
26
+ * @returns {'light' | 'dark'}
27
+ */
28
+ function getEffectiveTheme(theme) {
29
+ if (theme === 'auto') {
30
+ return getSystemPreference();
31
+ }
32
+ return theme;
33
+ }
34
+
35
+ const initial = getInitialTheme();
36
+ export const theme = writable(initial);
37
+
38
+ // Apply theme to document
39
+ function applyTheme(themeValue) {
40
+ if (typeof document === 'undefined') return;
41
+ const effective = getEffectiveTheme(themeValue);
42
+ document.documentElement.dataset.theme = effective;
43
+ }
44
+
45
+ // Listen for system preference changes when theme is 'auto'
46
+ let mediaQuery = null;
47
+ let mediaQueryListener = null;
48
+
49
+ function setupSystemPreferenceListener() {
50
+ if (typeof window === 'undefined') return;
51
+ if (mediaQuery) return; // Already set up
52
+
53
+ mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
54
+ mediaQueryListener = () => {
55
+ // Re-read current theme value and reapply if it's 'auto'
56
+ const unsubscribe = theme.subscribe((t) => {
57
+ unsubscribe(); // Immediately unsubscribe to avoid memory leak
58
+ if (t === 'auto') {
59
+ applyTheme('auto');
60
+ }
61
+ });
62
+ };
63
+ mediaQuery.addEventListener('change', mediaQueryListener);
64
+ }
65
+
66
+ function cleanupSystemPreferenceListener() {
67
+ if (mediaQuery && mediaQueryListener) {
68
+ mediaQuery.removeEventListener('change', mediaQueryListener);
69
+ mediaQuery = null;
70
+ mediaQueryListener = null;
71
+ }
72
+ }
73
+
74
+ // Subscribe to theme changes
75
+ let currentThemeValue = initial;
76
+ theme.subscribe((t) => {
77
+ currentThemeValue = t;
78
+ applyTheme(t);
79
+ if (typeof localStorage !== 'undefined') {
80
+ localStorage.setItem('theme', t);
81
+ }
82
+
83
+ // Setup/cleanup system preference listener based on theme
84
+ if (t === 'auto') {
85
+ setupSystemPreferenceListener();
86
+ } else {
87
+ cleanupSystemPreferenceListener();
88
+ }
89
+ });
90
+
91
+ // Initialize: apply theme and setup listener if needed
92
+ if (typeof window !== 'undefined') {
93
+ applyTheme(initial);
94
+ if (initial === 'auto') {
95
+ setupSystemPreferenceListener();
96
+ }
97
+ }
98
+
package/src/main.js ADDED
@@ -0,0 +1,143 @@
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
+ document.body.style.backgroundImage = `url(${url})`;
53
+ document.body.style.backgroundRepeat = 'repeat';
54
+ document.body.style.backgroundSize = '1024px 1024px';
55
+ document.body.style.backgroundAttachment = 'fixed';
56
+ }
57
+ }
58
+
59
+ // Generate both textures
60
+ const { url: lightUrl } = await generateTexture({
61
+ key: 'sprig-ui-csr-light',
62
+ palette: parchment,
63
+ preset: subtle,
64
+ });
65
+ textureState.light = lightUrl;
66
+
67
+ const { url: darkUrl } = await generateTexture({
68
+ key: 'sprig-ui-csr-dark',
69
+ palette: sprigGrey,
70
+ preset: subtle,
71
+ });
72
+ textureState.dark = darkUrl;
73
+
74
+ // Track current theme value
75
+ let currentThemeValue = get(theme);
76
+
77
+ // Listen for system preference changes when theme is 'auto'
78
+ let mediaQuery = null;
79
+ let mediaQueryListener = null;
80
+
81
+ function setupSystemPreferenceListener() {
82
+ if (typeof window === 'undefined') return;
83
+ if (mediaQuery) return; // Already set up
84
+
85
+ mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
86
+ mediaQueryListener = () => {
87
+ // Only update texture if current theme is 'auto'
88
+ if (currentThemeValue === 'auto') {
89
+ updateTexture('auto');
90
+ }
91
+ };
92
+ mediaQuery.addEventListener('change', mediaQueryListener);
93
+ }
94
+
95
+ function cleanupSystemPreferenceListener() {
96
+ if (mediaQuery && mediaQueryListener) {
97
+ mediaQuery.removeEventListener('change', mediaQueryListener);
98
+ mediaQuery = null;
99
+ mediaQueryListener = null;
100
+ }
101
+ }
102
+
103
+ // Subscribe to theme changes
104
+ const unsubscribe = theme.subscribe((themeValue) => {
105
+ currentThemeValue = themeValue;
106
+ updateTexture(themeValue);
107
+
108
+ // Setup/cleanup system preference listener based on theme
109
+ if (themeValue === 'auto') {
110
+ setupSystemPreferenceListener();
111
+ } else {
112
+ cleanupSystemPreferenceListener();
113
+ }
114
+ });
115
+
116
+ // Apply initial texture
117
+ updateTexture(get(theme));
118
+
119
+ // Setup listener if initial theme is 'auto'
120
+ if (get(theme) === 'auto') {
121
+ setupSystemPreferenceListener();
122
+ }
123
+
124
+ // Store cleanup on window for potential cleanup
125
+ window.__textureCleanup = () => {
126
+ unsubscribe();
127
+ cleanupSystemPreferenceListener();
128
+ if (textureState.light) {
129
+ releaseObjectUrl(textureState.light);
130
+ }
131
+ if (textureState.dark) {
132
+ releaseObjectUrl(textureState.dark);
133
+ }
134
+ };
135
+ } catch (error) {
136
+ console.error('Failed to generate texture:', error);
137
+ }
138
+ }
139
+
140
+ // Initialize texture after component is mounted
141
+ setTimeout(initTexture, 0);
142
+
143
+ export default app;
@@ -0,0 +1,84 @@
1
+ <script>
2
+ import { currentAnthology, anthologySeries, universeGraph, currentUniverseName, universeRootNode, getNodeRoute, currentAnthologyId } from '../lib/data/universeStore.js';
3
+ import { getDisplayTitle } from '../lib/format/title.js';
4
+ import Prose from '../lib/components/Prose.svelte';
5
+ import ContentsCard from '../lib/components/ContentsCard.svelte';
6
+ import FooterStatus from '../lib/components/FooterStatus.svelte';
7
+ import PageHeader from '../lib/components/PageHeader.svelte';
8
+
9
+ /** @type {{ universe: string, anthology: string }} */
10
+ export let params;
11
+
12
+ // Update stores reactively when params change
13
+ $: {
14
+ const anthologyId = `${params.universe}:anthology:${params.anthology}`;
15
+ currentAnthologyId.set(anthologyId);
16
+ currentUniverseName.set(params.universe);
17
+ }
18
+
19
+ $: subtitle = $universeRootNode
20
+ ? `An anthology in <a href="/">${getDisplayTitle($universeRootNode) || params.universe}</a>`
21
+ : `An anthology in ${params.universe}`;
22
+ </script>
23
+
24
+ {#if $currentAnthology}
25
+ <PageHeader title={getDisplayTitle($currentAnthology)} {subtitle} />
26
+
27
+ <div class="grid">
28
+ <section class="narrative">
29
+ {#if $currentAnthology.describe}
30
+ <Prose textBlock={$currentAnthology.describe} />
31
+ {/if}
32
+ </section>
33
+
34
+ <aside class="index">
35
+ <ContentsCard children={$anthologySeries} />
36
+ </aside>
37
+ </div>
38
+
39
+ <FooterStatus graph={$universeGraph} root={$universeRootNode} />
40
+ {:else}
41
+ <div class="loading">Loading…</div>
42
+ {/if}
43
+
44
+ <style>
45
+ .grid {
46
+ display: grid;
47
+ grid-template-columns: 1fr 350px;
48
+ gap: 64px;
49
+ align-items: start;
50
+ max-width: 1200px;
51
+ margin: 0 auto;
52
+ width: 100%;
53
+ }
54
+
55
+ .narrative {
56
+ max-width: 680px;
57
+ min-width: 0;
58
+ width: 100%;
59
+ }
60
+
61
+ .index {
62
+ min-width: 0;
63
+ width: 100%;
64
+ }
65
+
66
+ .loading {
67
+ color: var(--text-tertiary);
68
+ padding: 24px 0;
69
+ }
70
+
71
+ @media (max-width: 768px) {
72
+ .grid {
73
+ grid-template-columns: 1fr;
74
+ gap: 32px;
75
+ }
76
+ }
77
+
78
+ @media (max-width: 480px) {
79
+ .grid {
80
+ gap: 32px;
81
+ }
82
+ }
83
+ </style>
84
+