@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.
@@ -0,0 +1,350 @@
1
+ import { writable, derived, get } from 'svelte/store';
2
+
3
+ /**
4
+ * @typedef {{ id:string, name:string, title?:string, kind:string, spelledKind?:string, parent:string|null, children:string[], describe?:{raw:string, normalized:string}, dimension?:string, relationships?:Array<{edge:string, relationship?:string, to:string}> }} NodeModel
5
+ * @typedef {{ id:string, name:string, root:string }} UniverseModel
6
+ * @typedef {{ universes:Record<string, UniverseModel>, nodes:Record<string, NodeModel>, relationshipsById:Record<string, any>, relatesById:Record<string, any>, generatedAt?:string, meta?:any }} UniverseGraph
7
+ */
8
+
9
+ export const universeGraph = writable(/** @type {UniverseGraph|null} */ (null));
10
+ export const currentUniverseName = writable(/** @type {string|null} */ (null));
11
+
12
+ const KIND_PRIORITY = ['concept', 'dimension', 'relationship', 'relates'];
13
+
14
+ function normalizeTextBlock(value) {
15
+ if (!value) return undefined;
16
+ if (typeof value === 'string') {
17
+ return { raw: value, normalized: value };
18
+ }
19
+ if (typeof value === 'object' && typeof value.raw === 'string') {
20
+ return {
21
+ raw: value.raw,
22
+ normalized: typeof value.normalized === 'string' ? value.normalized : value.raw,
23
+ };
24
+ }
25
+ return undefined;
26
+ }
27
+
28
+ function normalizeTitle(value) {
29
+ if (!value) return undefined;
30
+ if (typeof value === 'string') {
31
+ const trimmed = value.trim();
32
+ return trimmed.length > 0 ? trimmed : undefined;
33
+ }
34
+ if (typeof value === 'object' && typeof value.raw === 'string') {
35
+ const trimmed = value.raw.trim();
36
+ return trimmed.length > 0 ? trimmed : undefined;
37
+ }
38
+ return undefined;
39
+ }
40
+
41
+ function endpointDisplayName(endpointId, conceptById) {
42
+ const concept = conceptById?.[endpointId];
43
+ if (concept?.name) return concept.name;
44
+ return endpointId;
45
+ }
46
+
47
+ function formatRelationshipLabel(label) {
48
+ if (!label) return '';
49
+ return String(label)
50
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
51
+ .replace(/[_-]+/g, ' ')
52
+ .toLowerCase()
53
+ .trim();
54
+ }
55
+
56
+ function sortByKindAndName(a, b) {
57
+ const kindDiff = KIND_PRIORITY.indexOf(a.kind) - KIND_PRIORITY.indexOf(b.kind);
58
+ if (kindDiff !== 0) return kindDiff;
59
+ return a.name.localeCompare(b.name);
60
+ }
61
+
62
+ /**
63
+ * @param {NodeModel[]} children
64
+ * @returns {{ primary: NodeModel[], other: NodeModel[] }}
65
+ */
66
+ export function organizeChildren(children) {
67
+ const valid = children.filter(Boolean);
68
+ if (valid.length === 0) return { primary: [], other: [] };
69
+ const primaryKind = valid[0].kind;
70
+ return {
71
+ primary: valid.filter((child) => child.kind === primaryKind),
72
+ other: valid.filter((child) => child.kind !== primaryKind),
73
+ };
74
+ }
75
+
76
+ /**
77
+ * Build a generic parent/child tree by nearest existing id prefix.
78
+ * @param {Record<string, NodeModel>} nodes
79
+ * @param {string} universeId
80
+ */
81
+ function connectTree(nodes, universeId) {
82
+ const root = nodes[universeId];
83
+ if (!root) return;
84
+
85
+ const ids = Object.keys(nodes);
86
+ for (const id of ids) {
87
+ const node = nodes[id];
88
+ if (!node || id === universeId) continue;
89
+ const parts = id.split('.');
90
+ let parentId = universeId;
91
+ for (let i = parts.length - 1; i > 1; i -= 1) {
92
+ const candidate = parts.slice(0, i).join('.');
93
+ if (nodes[candidate]) {
94
+ parentId = candidate;
95
+ break;
96
+ }
97
+ }
98
+ node.parent = parentId;
99
+ nodes[parentId].children.push(id);
100
+ }
101
+
102
+ }
103
+
104
+ /**
105
+ * @param {Record<string, any>} relationshipsById
106
+ * @param {string | undefined} relId
107
+ * @param {string} edge
108
+ */
109
+ function relationshipLabel(relationshipsById, relId, edge) {
110
+ const rel = relId ? relationshipsById[relId] : null;
111
+ if (!rel) return formatRelationshipLabel(edge);
112
+ if (rel.forward === edge || rel.inverse === edge) {
113
+ return formatRelationshipLabel(edge);
114
+ }
115
+ return formatRelationshipLabel(rel.forward || rel.inverse || edge);
116
+ }
117
+
118
+ /**
119
+ * @param {any} manifest
120
+ * @returns {UniverseGraph}
121
+ */
122
+ function normalizeLanguageManifest(manifest) {
123
+ const universeId = manifest?.universe?.id || manifest?.universe?.name;
124
+ if (!universeId) {
125
+ throw new Error('Invalid manifest: missing universe.id');
126
+ }
127
+
128
+ /** @type {Record<string, NodeModel>} */
129
+ const nodes = {};
130
+ nodes[universeId] = {
131
+ id: universeId,
132
+ name: manifest.universe.name || universeId,
133
+ title: normalizeTitle(manifest.universe.title),
134
+ kind: 'universe',
135
+ parent: null,
136
+ children: [],
137
+ describe: normalizeTextBlock(manifest.universe.describe),
138
+ };
139
+
140
+ for (const concept of Object.values(manifest?.concepts || {})) {
141
+ nodes[concept.id] = {
142
+ id: concept.id,
143
+ name: concept.name || concept.id,
144
+ title: normalizeTitle(concept.title),
145
+ kind: 'concept',
146
+ spelledKind: concept.dimension ? concept.dimension.split('.').pop() : 'concept',
147
+ parent: null,
148
+ children: [],
149
+ describe: normalizeTextBlock(concept.describe),
150
+ dimension: concept.dimension,
151
+ relationships: Array.isArray(concept.relationships) ? concept.relationships : [],
152
+ };
153
+ }
154
+
155
+ for (const dim of Object.values(manifest?.dimensions || {})) {
156
+ nodes[dim.id] = {
157
+ id: dim.id,
158
+ name: dim.name || dim.id,
159
+ title: normalizeTitle(dim.title),
160
+ kind: 'dimension',
161
+ spelledKind: 'dimension',
162
+ parent: null,
163
+ children: [],
164
+ describe: normalizeTextBlock(dim.describe),
165
+ relationships: [],
166
+ };
167
+ }
168
+
169
+ for (const rel of Object.values(manifest?.relationships || {})) {
170
+ const forward = formatRelationshipLabel(rel.forward || '');
171
+ const inverse = formatRelationshipLabel(rel.inverse || '');
172
+ const relationshipName = inverse
173
+ ? `${forward} and ${inverse}`
174
+ : forward || rel.id;
175
+
176
+ nodes[rel.id] = {
177
+ id: rel.id,
178
+ name: relationshipName,
179
+ title: normalizeTitle(rel.title),
180
+ kind: 'relationship',
181
+ spelledKind: 'relationship',
182
+ parent: null,
183
+ children: [],
184
+ describe: normalizeTextBlock(rel.describe),
185
+ relationships: [],
186
+ };
187
+ }
188
+
189
+ for (const rel of Object.values(manifest?.relates || {})) {
190
+ const leftName = endpointDisplayName(rel.left, manifest?.concepts);
191
+ const rightName = endpointDisplayName(rel.right, manifest?.concepts);
192
+
193
+ nodes[rel.id] = {
194
+ id: rel.id,
195
+ name: `${leftName} and ${rightName}`,
196
+ title: normalizeTitle(rel.title),
197
+ kind: 'relates',
198
+ spelledKind: 'relates',
199
+ parent: universeId,
200
+ children: [],
201
+ describe: normalizeTextBlock(rel.describe),
202
+ relationships: Array.isArray(rel.relationships) ? rel.relationships : [],
203
+ };
204
+ }
205
+
206
+ connectTree(nodes, universeId);
207
+
208
+ return {
209
+ universes: {
210
+ [universeId]: {
211
+ id: universeId,
212
+ name: manifest.universe.name || universeId,
213
+ root: universeId,
214
+ },
215
+ },
216
+ nodes,
217
+ relationshipsById: manifest.relationships || {},
218
+ relatesById: manifest.relates || {},
219
+ generatedAt: manifest.generatedAt,
220
+ meta: manifest.meta,
221
+ };
222
+ }
223
+
224
+ export const currentUniverse = derived([universeGraph, currentUniverseName], ([$g, $name]) =>
225
+ $name ? $g?.universes?.[$name] ?? null : null,
226
+ );
227
+
228
+ export const universeRootNode = derived([universeGraph, currentUniverse], ([$g, $u]) =>
229
+ $u && $g ? $g.nodes[$u.root] : null,
230
+ );
231
+
232
+ export const rootChildren = derived([universeGraph, universeRootNode], ([$g, $root]) => {
233
+ if (!$g || !$root) return { primary: [], other: [] };
234
+ const children = ($root.children || []).map((id) => $g.nodes[id]).filter(Boolean);
235
+ return organizeChildren(children);
236
+ });
237
+
238
+ /**
239
+ * @param {UniverseGraph | null} graph
240
+ * @param {string | null} currentNodeId
241
+ * @returns {Array<{ otherNode: NodeModel, label: string, desc: string | null }>}
242
+ */
243
+ export function getRelationshipsForNode(graph, currentNodeId) {
244
+ if (!graph || !currentNodeId) return [];
245
+ const node = graph.nodes[currentNodeId];
246
+ if (!node) return [];
247
+
248
+ /** @type {Array<{ otherNode: NodeModel, label: string, desc: string | null }>} */
249
+ const items = [];
250
+ const seen = new Set();
251
+
252
+ /**
253
+ * @param {string} otherNodeId
254
+ * @param {string} label
255
+ * @param {string | null} desc
256
+ */
257
+ function pushRelationship(otherNodeId, label, desc) {
258
+ const otherNode = graph.nodes[otherNodeId];
259
+ if (!otherNode) return;
260
+ const key = `${otherNodeId}|${label}|${desc || ''}`;
261
+ if (seen.has(key)) return;
262
+ seen.add(key);
263
+ items.push({ otherNode, label, desc });
264
+ }
265
+
266
+ for (const rel of node.relationships || []) {
267
+ const decl = rel.relationship ? graph.relationshipsById[rel.relationship] : null;
268
+ pushRelationship(
269
+ rel.to,
270
+ relationshipLabel(graph.relationshipsById, rel.relationship, rel.edge),
271
+ normalizeTextBlock(decl?.describe)?.normalized || null,
272
+ );
273
+ }
274
+
275
+ for (const rel of Object.values(graph.relatesById || {})) {
276
+ const isLeft = rel.left === currentNodeId;
277
+ const isRight = rel.right === currentNodeId;
278
+ if (!isLeft && !isRight) continue;
279
+ const otherId = isLeft ? rel.right : rel.left;
280
+ const otherNode = graph.nodes[otherId];
281
+ if (!otherNode) continue;
282
+ const firstRel = Array.isArray(rel.relationships) ? rel.relationships[0] : null;
283
+ pushRelationship(
284
+ otherId,
285
+ formatRelationshipLabel(firstRel?.edge || 'relatesTo'),
286
+ normalizeTextBlock(rel.describe)?.normalized || null,
287
+ );
288
+ }
289
+
290
+ return items;
291
+ }
292
+
293
+ /**
294
+ * @param {UniverseGraph | null} graph
295
+ * @param {NodeModel | null} node
296
+ * @returns {NodeModel[]}
297
+ */
298
+ export function getAncestorChain(graph, node) {
299
+ if (!graph || !node?.parent) return [];
300
+ const ancestors = [];
301
+ let cursor = node.parent;
302
+ while (cursor) {
303
+ const parent = graph.nodes[cursor];
304
+ if (!parent) break;
305
+ ancestors.push(parent);
306
+ cursor = parent.parent;
307
+ }
308
+ return ancestors;
309
+ }
310
+
311
+ /**
312
+ * @param {NodeModel | null | undefined} node
313
+ * @returns {string}
314
+ */
315
+ export function getNodeRoute(node) {
316
+ if (!node?.id) return '#';
317
+ const universeId = node.id.split('.')[0];
318
+ if (!universeId) return '#';
319
+ return `/universes/${encodeURIComponent(universeId)}/node/${encodeURIComponent(node.id)}`;
320
+ }
321
+
322
+ /**
323
+ * @param {UniverseGraph} graph
324
+ */
325
+ export function autoSelectFirstUniverse(graph) {
326
+ if (!graph?.universes) return;
327
+ const universeNames = Object.keys(graph.universes);
328
+ const currentName = get(currentUniverseName);
329
+ if (!currentName || !graph.universes[currentName]) {
330
+ currentUniverseName.set(universeNames[0] || null);
331
+ }
332
+ }
333
+
334
+ /**
335
+ * @param {string} url
336
+ * @param {typeof fetch} [fetchFn]
337
+ */
338
+ export async function loadUniverseGraph(url = '/api/manifest', fetchFn = fetch) {
339
+ const res = await fetchFn(url, {
340
+ cache: 'no-store',
341
+ headers: { 'Cache-Control': 'no-cache' },
342
+ });
343
+ if (!res.ok) throw new Error(`Failed to load ${url}: ${res.status}`);
344
+ const raw = await res.json();
345
+ const data = normalizeLanguageManifest(raw);
346
+ universeGraph.set(data);
347
+ const describeRenderMode = res.headers.get('x-describe-render-mode');
348
+ return { data, describeRenderMode };
349
+ }
350
+
@@ -0,0 +1,26 @@
1
+ /**
2
+ * @fileoverview Helpers for displaying declaration kinds
3
+ */
4
+
5
+ /**
6
+ * @param {{ kind?: string, spelledKind?: string } | null | undefined} node
7
+ * @returns {string}
8
+ */
9
+ export function getDisplayKind(node) {
10
+ if (!node) return '';
11
+ const spelled = typeof node.spelledKind === 'string' ? node.spelledKind.trim() : '';
12
+ if (spelled.length > 0) return formatKindLabel(spelled);
13
+ return typeof node.kind === 'string' ? node.kind : '';
14
+ }
15
+
16
+ /**
17
+ * @param {string} label
18
+ * @returns {string}
19
+ */
20
+ function formatKindLabel(label) {
21
+ if (!/[a-z][A-Z]/.test(label)) {
22
+ return label;
23
+ }
24
+ return label.replace(/([a-z])([A-Z])/g, '$1 $2').toLowerCase();
25
+ }
26
+
@@ -0,0 +1,97 @@
1
+ /**
2
+ * @fileoverview Utility for deriving human-friendly display titles from node identifiers
3
+ */
4
+
5
+ /**
6
+ * Derives a human-friendly display title from a node or identifier string.
7
+ *
8
+ * @param {any} nodeOrId - Node object with optional `title`, `name`, `id`, `slug`, or `identifier` fields, or a string identifier
9
+ * @param {{}} [options] - Optional configuration (currently unused, reserved for future use)
10
+ * @returns {string} Human-friendly display title, or empty string if no usable identifier found
11
+ *
12
+ * @example
13
+ * getDisplayTitle({ title: "Custom Title" }) // "Custom Title"
14
+ * getDisplayTitle({ name: "CountBasedEffect" }) // "Count Based Effect"
15
+ * getDisplayTitle({ name: "APIResponse" }) // "API Response"
16
+ * getDisplayTitle({ name: "effect_type" }) // "Effect Type"
17
+ * getDisplayTitle("PlayerID") // "Player ID"
18
+ */
19
+ export function getDisplayTitle(nodeOrId, options = {}) {
20
+ // Handle explicit title override
21
+ if (nodeOrId && typeof nodeOrId === 'object' && typeof nodeOrId.title === 'string') {
22
+ const title = nodeOrId.title.trim();
23
+ if (title.length > 0) {
24
+ return title;
25
+ }
26
+ }
27
+
28
+ // Extract identifier string
29
+ let identifier = null;
30
+
31
+ if (typeof nodeOrId === 'string') {
32
+ identifier = nodeOrId;
33
+ } else if (nodeOrId && typeof nodeOrId === 'object') {
34
+ // Check in order: name, id, slug, identifier
35
+ identifier = nodeOrId.name || nodeOrId.id || nodeOrId.slug || nodeOrId.identifier || null;
36
+ }
37
+
38
+ if (!identifier || typeof identifier !== 'string' || identifier.trim().length === 0) {
39
+ return '';
40
+ }
41
+
42
+ // Derive title from identifier
43
+ return deriveTitleFromIdentifier(identifier);
44
+ }
45
+
46
+ /**
47
+ * Derives a human-friendly title from an identifier string.
48
+ * Handles PascalCase, camelCase, snake_case, kebab-case, and preserves acronyms.
49
+ *
50
+ * @param {string} identifier - The identifier string to convert
51
+ * @returns {string} Human-friendly title
52
+ *
53
+ * @private
54
+ */
55
+ function deriveTitleFromIdentifier(identifier) {
56
+ // Normalize: replace underscores and hyphens with spaces
57
+ let normalized = identifier
58
+ .replace(/[_-]/g, ' ')
59
+ .trim();
60
+
61
+ // Split on case boundaries (PascalCase/camelCase)
62
+ // This regex finds positions where:
63
+ // - A lowercase letter is followed by an uppercase letter (camelCase boundary)
64
+ // - An uppercase letter is followed by an uppercase letter followed by a lowercase letter (PascalCase boundary like "APIResponse")
65
+ // - A digit is followed by a letter or vice versa
66
+ normalized = normalized.replace(/([a-z])([A-Z])/g, '$1 $2'); // camelCase: "countBased" -> "count Based"
67
+ normalized = normalized.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2'); // PascalCase acronyms: "APIResponse" -> "API Response"
68
+ normalized = normalized.replace(/([0-9])([A-Za-z])/g, '$1 $2'); // Numbers: "Item2" -> "Item 2"
69
+ normalized = normalized.replace(/([A-Za-z])([0-9])/g, '$1 $2'); // Numbers: "Item2" -> "Item 2"
70
+
71
+ // Split into words and process each
72
+ const words = normalized
73
+ .split(/\s+/)
74
+ .filter(word => word.length > 0)
75
+ .map(word => {
76
+ // Detect acronyms: 2+ consecutive uppercase letters
77
+ // Only preserve as acronym if it's a known common acronym
78
+ // This handles cases like "APIResponse" -> "API Response" but "SIMPLE" -> "Simple"
79
+ const isAcronymPattern = word.length >= 2 && word === word.toUpperCase() && /^[A-Z]+$/.test(word);
80
+
81
+ // Common acronyms that should be preserved: API, UUID, ID, HTTP, HTTPS, XML, JSON, etc.
82
+ const commonAcronyms = ['API', 'UUID', 'ID', 'HTTP', 'HTTPS', 'XML', 'JSON', 'URL', 'URI', 'HTML', 'CSS', 'JS', 'TS', 'AB'];
83
+ if (isAcronymPattern && commonAcronyms.includes(word)) {
84
+ // It's a known acronym, preserve as-is
85
+ return word;
86
+ }
87
+
88
+ // Title-case the word: first letter uppercase, rest lowercase
89
+ if (word.length === 0) return word;
90
+ if (word.length === 1) return word.toUpperCase();
91
+ return word[0].toUpperCase() + word.slice(1).toLowerCase();
92
+ });
93
+
94
+ // Join words with single spaces and collapse multiple spaces
95
+ return words.join(' ').replace(/\s+/g, ' ').trim();
96
+ }
97
+
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Parse a route and extract parameters
3
+ * @param {string} pathname
4
+ * @returns {{ route: string, params: Record<string, string> } | null}
5
+ */
6
+ export function parseRoute(pathname) {
7
+ const path = pathname.replace(/^\/+|\/+$/g, '');
8
+
9
+ if (!path || path === '') {
10
+ return { route: 'home', params: {} };
11
+ }
12
+
13
+ const nodeMatch = path.match(/^universes\/([^/]+)\/node\/(.+)$/);
14
+ if (nodeMatch) {
15
+ return {
16
+ route: 'node',
17
+ params: {
18
+ universe: decodeURIComponent(nodeMatch[1]),
19
+ id: nodeMatch[2],
20
+ },
21
+ };
22
+ }
23
+
24
+ return null;
25
+ }
26
+
27
+ /**
28
+ * Get current route from window.location
29
+ * @returns {{ route: string, params: Record<string, string> } | null}
30
+ */
31
+ export function getCurrentRoute() {
32
+ return parseRoute(window.location.pathname);
33
+ }
34
+
35
+ export function navigate(path) {
36
+ window.history.pushState({}, '', path);
37
+ window.dispatchEvent(new PopStateEvent('popstate'));
38
+ }
39
+
@@ -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: 'lists'
7
+ */
8
+ export const describeRenderMode = writable(/** @type {'plain' | 'lists'} */ ('lists'));
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
+