@miozu/jera 0.3.0 → 0.4.2

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.
Files changed (72) hide show
  1. package/CLAUDE.md +350 -59
  2. package/README.md +30 -22
  3. package/llms.txt +37 -4
  4. package/package.json +12 -2
  5. package/src/components/docs/CodeBlock.svelte +203 -0
  6. package/src/components/docs/DocSection.svelte +120 -0
  7. package/src/components/docs/PropsTable.svelte +136 -0
  8. package/src/components/docs/SplitPane.svelte +98 -0
  9. package/src/components/docs/index.js +14 -0
  10. package/src/components/feedback/Alert.svelte +234 -0
  11. package/src/components/feedback/EmptyState.svelte +6 -6
  12. package/src/components/feedback/ProgressBar.svelte +8 -8
  13. package/src/components/feedback/Skeleton.svelte +4 -4
  14. package/src/components/feedback/Spinner.svelte +1 -1
  15. package/src/components/feedback/Toast.svelte +137 -173
  16. package/src/components/forms/Checkbox.svelte +10 -10
  17. package/src/components/forms/Dropzone.svelte +14 -14
  18. package/src/components/forms/FileUpload.svelte +16 -16
  19. package/src/components/forms/IconInput.svelte +4 -4
  20. package/src/components/forms/Input.svelte +14 -14
  21. package/src/components/forms/NumberInput.svelte +13 -13
  22. package/src/components/forms/PinInput.svelte +8 -8
  23. package/src/components/forms/Radio.svelte +8 -8
  24. package/src/components/forms/RangeSlider.svelte +12 -12
  25. package/src/components/forms/SearchInput.svelte +10 -10
  26. package/src/components/forms/Select.svelte +156 -158
  27. package/src/components/forms/Switch.svelte +4 -4
  28. package/src/components/forms/Textarea.svelte +9 -9
  29. package/src/components/navigation/Accordion.svelte +1 -1
  30. package/src/components/navigation/AccordionItem.svelte +6 -6
  31. package/src/components/navigation/NavigationContainer.svelte +344 -0
  32. package/src/components/navigation/Sidebar.svelte +334 -0
  33. package/src/components/navigation/SidebarAccountGroup.svelte +495 -0
  34. package/src/components/navigation/SidebarAccountItem.svelte +492 -0
  35. package/src/components/navigation/SidebarGroup.svelte +230 -0
  36. package/src/components/navigation/SidebarGroupSwitcher.svelte +262 -0
  37. package/src/components/navigation/SidebarItem.svelte +210 -0
  38. package/src/components/navigation/SidebarNavigationItem.svelte +470 -0
  39. package/src/components/navigation/SidebarPopover.svelte +145 -0
  40. package/src/components/navigation/SidebarSearch.svelte +236 -0
  41. package/src/components/navigation/SidebarSection.svelte +158 -0
  42. package/src/components/navigation/SidebarToggle.svelte +86 -0
  43. package/src/components/navigation/Tabs.svelte +18 -18
  44. package/src/components/navigation/WorkspaceMenu.svelte +416 -0
  45. package/src/components/navigation/blocks/NavigationAccountGroup.svelte +396 -0
  46. package/src/components/navigation/blocks/NavigationCustomBlock.svelte +74 -0
  47. package/src/components/navigation/blocks/NavigationGroupSwitcher.svelte +277 -0
  48. package/src/components/navigation/blocks/NavigationSearch.svelte +300 -0
  49. package/src/components/navigation/blocks/NavigationSection.svelte +230 -0
  50. package/src/components/navigation/index.js +22 -0
  51. package/src/components/overlays/ConfirmDialog.svelte +18 -18
  52. package/src/components/overlays/Dropdown.svelte +2 -2
  53. package/src/components/overlays/DropdownDivider.svelte +1 -1
  54. package/src/components/overlays/DropdownItem.svelte +5 -5
  55. package/src/components/overlays/Modal.svelte +13 -13
  56. package/src/components/overlays/Popover.svelte +3 -3
  57. package/src/components/primitives/Avatar.svelte +12 -12
  58. package/src/components/primitives/Badge.svelte +7 -7
  59. package/src/components/primitives/Button.svelte +126 -174
  60. package/src/components/primitives/Card.svelte +15 -15
  61. package/src/components/primitives/Divider.svelte +3 -3
  62. package/src/components/primitives/LazyImage.svelte +1 -1
  63. package/src/components/primitives/Link.svelte +2 -2
  64. package/src/components/primitives/Stat.svelte +197 -0
  65. package/src/components/primitives/StatusBadge.svelte +24 -24
  66. package/src/index.js +62 -7
  67. package/src/tokens/colors.css +96 -128
  68. package/src/utils/highlighter.js +124 -0
  69. package/src/utils/index.js +7 -2
  70. package/src/utils/navigation.svelte.js +423 -0
  71. package/src/utils/reactive.svelte.js +126 -37
  72. package/src/utils/sidebar.svelte.js +211 -0
@@ -0,0 +1,423 @@
1
+ /**
2
+ * NavigationState - Enterprise-grade reactive state management for navigation
3
+ *
4
+ * Provides centralized state management for complex navigation scenarios including:
5
+ * - Multi-level recursive navigation
6
+ * - Search and filtering across all items
7
+ * - Expansion state management
8
+ * - Active state detection
9
+ * - Breadcrumb generation
10
+ * - Permission-based visibility
11
+ *
12
+ * @example
13
+ * const nav = new NavigationState({
14
+ * items: navigationConfig,
15
+ * persistKey: 'my-app-nav',
16
+ * searchable: true,
17
+ * recursive: true
18
+ * });
19
+ */
20
+
21
+ import { browser } from '$app/environment';
22
+
23
+ export class NavigationState {
24
+ // Core reactive state
25
+ items = $state([]);
26
+ searchQuery = $state('');
27
+ expandedSections = $state({});
28
+ expandedItems = $state({});
29
+ activeItem = $state(null);
30
+ activePath = $state([]);
31
+
32
+ // Configuration
33
+ config = $state({
34
+ persistKey: null,
35
+ searchable: true,
36
+ recursive: true,
37
+ maxDepth: 10,
38
+ autoExpand: false,
39
+ caseSensitiveSearch: false
40
+ });
41
+
42
+ // Computed properties
43
+ filteredItems = $derived.by(() => this.#filterItems(this.items, this.searchQuery));
44
+ breadcrumbs = $derived.by(() => this.#generateBreadcrumbs(this.activeItem));
45
+ totalItemCount = $derived.by(() => this.#countItems(this.items));
46
+ visibleItemCount = $derived.by(() => this.#countItems(this.filteredItems));
47
+
48
+ constructor(options = {}) {
49
+ // Apply configuration
50
+ this.config = { ...this.config, ...options };
51
+
52
+ if (options.items) {
53
+ this.items = this.#normalizeItems(options.items);
54
+ }
55
+
56
+ // Load persisted state
57
+ if (this.config.persistKey && browser) {
58
+ this.#loadPersistedState();
59
+ }
60
+
61
+ // Auto-save state when it changes
62
+ if (this.config.persistKey && browser) {
63
+ $effect(() => {
64
+ this.#savePersistedState();
65
+ });
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Update navigation items with intelligent merging
71
+ */
72
+ setItems(items) {
73
+ this.items = this.#normalizeItems(items);
74
+
75
+ // Auto-expand if configured
76
+ if (this.config.autoExpand) {
77
+ this.#autoExpandItems();
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Set active item by ID or path
83
+ */
84
+ setActive(identifier, shouldExpand = true) {
85
+ const item = typeof identifier === 'string'
86
+ ? this.#findItemById(identifier)
87
+ : identifier;
88
+
89
+ if (item) {
90
+ this.activeItem = item;
91
+ this.activePath = this.#getItemPath(item);
92
+
93
+ if (shouldExpand) {
94
+ this.#expandToItem(item);
95
+ }
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Search navigation items
101
+ */
102
+ search(query) {
103
+ this.searchQuery = query;
104
+
105
+ // Auto-expand search results
106
+ if (query && this.config.autoExpand) {
107
+ this.#expandSearchResults();
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Clear search and collapse expanded results
113
+ */
114
+ clearSearch() {
115
+ this.searchQuery = '';
116
+ }
117
+
118
+ /**
119
+ * Toggle section expansion
120
+ */
121
+ toggleSection(sectionId) {
122
+ this.expandedSections[sectionId] = !this.expandedSections[sectionId];
123
+ }
124
+
125
+ /**
126
+ * Toggle item expansion (for recursive items)
127
+ */
128
+ toggleItem(itemId) {
129
+ this.expandedItems[itemId] = !this.expandedItems[itemId];
130
+ }
131
+
132
+ /**
133
+ * Expand all sections and items
134
+ */
135
+ expandAll() {
136
+ this.#walkItems(this.items, (item) => {
137
+ if (item.children?.length > 0) {
138
+ this.expandedItems[item.id] = true;
139
+ }
140
+ });
141
+
142
+ // Expand all sections
143
+ Object.keys(this.expandedSections).forEach(key => {
144
+ this.expandedSections[key] = true;
145
+ });
146
+ }
147
+
148
+ /**
149
+ * Collapse all sections and items
150
+ */
151
+ collapseAll() {
152
+ this.expandedItems = {};
153
+ Object.keys(this.expandedSections).forEach(key => {
154
+ this.expandedSections[key] = false;
155
+ });
156
+ }
157
+
158
+ /**
159
+ * Get navigation item by ID
160
+ */
161
+ getItem(id) {
162
+ return this.#findItemById(id);
163
+ }
164
+
165
+ /**
166
+ * Get all children of an item
167
+ */
168
+ getChildren(itemId) {
169
+ const item = this.#findItemById(itemId);
170
+ return item?.children || [];
171
+ }
172
+
173
+ /**
174
+ * Get parent of an item
175
+ */
176
+ getParent(itemId) {
177
+ return this.#findParentItem(itemId);
178
+ }
179
+
180
+ /**
181
+ * Get path to root for an item
182
+ */
183
+ getPath(itemId) {
184
+ const item = this.#findItemById(itemId);
185
+ return item ? this.#getItemPath(item) : [];
186
+ }
187
+
188
+ /**
189
+ * Check if item is visible based on permissions
190
+ */
191
+ isVisible(item) {
192
+ if (!item.permissions) return true;
193
+
194
+ // Override in subclass or provide permissions checker
195
+ return this.config.permissionChecker
196
+ ? this.config.permissionChecker(item.permissions)
197
+ : true;
198
+ }
199
+
200
+ /**
201
+ * Check if item is active
202
+ */
203
+ isActive(item) {
204
+ return this.activeItem?.id === item.id;
205
+ }
206
+
207
+ /**
208
+ * Check if item is in active path
209
+ */
210
+ isInActivePath(item) {
211
+ return this.activePath.some(pathItem => pathItem.id === item.id);
212
+ }
213
+
214
+ /**
215
+ * Check if section is expanded
216
+ */
217
+ isSectionExpanded(sectionId) {
218
+ return this.expandedSections[sectionId] ?? false;
219
+ }
220
+
221
+ /**
222
+ * Check if item is expanded
223
+ */
224
+ isItemExpanded(itemId) {
225
+ return this.expandedItems[itemId] ?? false;
226
+ }
227
+
228
+ // Private methods
229
+ #normalizeItems(items) {
230
+ const normalized = [];
231
+
232
+ for (let i = 0; i < items.length; i++) {
233
+ const item = items[i];
234
+ const normalizedItem = {
235
+ id: item.id || `item-${i}`,
236
+ label: item.label || '',
237
+ href: item.href || null,
238
+ icon: item.icon || null,
239
+ badge: item.badge || null,
240
+ children: item.children ? this.#normalizeItems(item.children) : [],
241
+ permissions: item.permissions || null,
242
+ metadata: item.metadata || {},
243
+ depth: item.depth || 0,
244
+ parent: item.parent || null,
245
+ ...item
246
+ };
247
+
248
+ // Set parent references for children
249
+ normalizedItem.children.forEach(child => {
250
+ child.parent = normalizedItem.id;
251
+ child.depth = normalizedItem.depth + 1;
252
+ });
253
+
254
+ normalized.push(normalizedItem);
255
+ }
256
+
257
+ return normalized;
258
+ }
259
+
260
+ #filterItems(items, query) {
261
+ if (!query) return items;
262
+
263
+ const filtered = [];
264
+ const searchLower = this.config.caseSensitiveSearch ? query : query.toLowerCase();
265
+
266
+ for (const item of items) {
267
+ const matches = this.#itemMatches(item, searchLower);
268
+ const filteredChildren = this.#filterItems(item.children || [], query);
269
+
270
+ if (matches || filteredChildren.length > 0) {
271
+ filtered.push({
272
+ ...item,
273
+ children: filteredChildren,
274
+ _matchesSearch: matches
275
+ });
276
+ }
277
+ }
278
+
279
+ return filtered;
280
+ }
281
+
282
+ #itemMatches(item, searchQuery) {
283
+ const searchFields = [
284
+ item.label,
285
+ item.description,
286
+ item.metadata?.tags?.join(' '),
287
+ item.metadata?.keywords?.join(' ')
288
+ ].filter(Boolean);
289
+
290
+ const searchText = this.config.caseSensitiveSearch
291
+ ? searchFields.join(' ')
292
+ : searchFields.join(' ').toLowerCase();
293
+
294
+ return searchText.includes(searchQuery);
295
+ }
296
+
297
+ #findItemById(id, items = this.items) {
298
+ for (const item of items) {
299
+ if (item.id === id) return item;
300
+
301
+ if (item.children) {
302
+ const found = this.#findItemById(id, item.children);
303
+ if (found) return found;
304
+ }
305
+ }
306
+ return null;
307
+ }
308
+
309
+ #findParentItem(itemId, items = this.items, parent = null) {
310
+ for (const item of items) {
311
+ if (item.id === itemId) return parent;
312
+
313
+ if (item.children) {
314
+ const found = this.#findParentItem(itemId, item.children, item);
315
+ if (found) return found;
316
+ }
317
+ }
318
+ return null;
319
+ }
320
+
321
+ #getItemPath(item) {
322
+ const path = [];
323
+ let current = item;
324
+
325
+ while (current) {
326
+ path.unshift(current);
327
+ current = current.parent ? this.#findItemById(current.parent) : null;
328
+ }
329
+
330
+ return path;
331
+ }
332
+
333
+ #expandToItem(item) {
334
+ const path = this.#getItemPath(item);
335
+
336
+ path.forEach(pathItem => {
337
+ if (pathItem.parent) {
338
+ this.expandedItems[pathItem.parent] = true;
339
+ }
340
+ });
341
+ }
342
+
343
+ #expandSearchResults() {
344
+ this.#walkItems(this.filteredItems, (item) => {
345
+ if (item._matchesSearch && item.parent) {
346
+ this.expandedItems[item.parent] = true;
347
+ }
348
+ });
349
+ }
350
+
351
+ #autoExpandItems() {
352
+ this.#walkItems(this.items, (item) => {
353
+ if (item.children?.length > 0) {
354
+ this.expandedItems[item.id] = true;
355
+ }
356
+ });
357
+ }
358
+
359
+ #walkItems(items, callback) {
360
+ for (const item of items) {
361
+ callback(item);
362
+ if (item.children) {
363
+ this.#walkItems(item.children, callback);
364
+ }
365
+ }
366
+ }
367
+
368
+ #countItems(items) {
369
+ let count = 0;
370
+ this.#walkItems(items, () => count++);
371
+ return count;
372
+ }
373
+
374
+ #generateBreadcrumbs(activeItem) {
375
+ if (!activeItem) return [];
376
+ return this.#getItemPath(activeItem);
377
+ }
378
+
379
+ #loadPersistedState() {
380
+ try {
381
+ const saved = localStorage.getItem(this.config.persistKey);
382
+ if (saved) {
383
+ const state = JSON.parse(saved);
384
+ this.expandedSections = state.expandedSections || {};
385
+ this.expandedItems = state.expandedItems || {};
386
+ this.searchQuery = state.searchQuery || '';
387
+ }
388
+ } catch (error) {
389
+ console.warn('Failed to load navigation state:', error);
390
+ }
391
+ }
392
+
393
+ #savePersistedState() {
394
+ try {
395
+ const state = {
396
+ expandedSections: this.expandedSections,
397
+ expandedItems: this.expandedItems,
398
+ searchQuery: this.searchQuery
399
+ };
400
+ localStorage.setItem(this.config.persistKey, JSON.stringify(state));
401
+ } catch (error) {
402
+ console.warn('Failed to save navigation state:', error);
403
+ }
404
+ }
405
+ }
406
+
407
+ /**
408
+ * Create a navigation state instance with common defaults
409
+ */
410
+ export function createNavigationState(options = {}) {
411
+ return new NavigationState({
412
+ searchable: true,
413
+ recursive: true,
414
+ maxDepth: 10,
415
+ autoExpand: false,
416
+ ...options
417
+ });
418
+ }
419
+
420
+ /**
421
+ * Navigation context key for Svelte context
422
+ */
423
+ export const NAVIGATION_CONTEXT_KEY = 'navigation-state';
@@ -5,8 +5,6 @@
5
5
  * Uses Svelte 5 runes ($state, $derived, $effect) internally.
6
6
  */
7
7
 
8
- import { getContext, setContext, onMount } from 'svelte';
9
-
10
8
  /**
11
9
  * Create a reactive store-like object with Svelte 5 runes
12
10
  * Unlike stores, these work seamlessly with runes and don't need $ prefix
@@ -58,15 +56,20 @@ export function createDerived(fn) {
58
56
  }
59
57
 
60
58
  /**
61
- * Theme Context Key
62
- */
63
- const THEME_KEY = Symbol('jera-theme');
64
-
65
- /**
66
- * Theme Reactive State Class
59
+ * Theme Reactive State Class (Singleton Pattern)
67
60
  *
68
61
  * Manages theme state with persistence and SSR support.
69
- * Uses class-based reactive state pattern.
62
+ * Uses singleton pattern for global app-level state.
63
+ *
64
+ * Storage key: 'miozu-theme'
65
+ * Data-theme values: 'miozu-dark' | 'miozu-light'
66
+ *
67
+ * @example
68
+ * import { getTheme } from '@miozu/jera';
69
+ * const theme = getTheme();
70
+ * theme.init(); // Call once in root layout onMount
71
+ * theme.toggle(); // Toggle dark/light
72
+ * theme.set('system'); // Use system preference
70
73
  */
71
74
  export class ThemeState {
72
75
  /** @type {'light' | 'dark' | 'system'} */
@@ -75,8 +78,23 @@ export class ThemeState {
75
78
  /** @type {'light' | 'dark'} */
76
79
  resolved = $derived.by(() => this.#resolveTheme());
77
80
 
81
+ /** @type {'miozu-light' | 'miozu-dark'} */
82
+ dataTheme = $derived.by(() => this.resolved === 'dark' ? 'miozu-dark' : 'miozu-light');
83
+
84
+ /**
85
+ * Alias for dataTheme - backwards compatibility
86
+ * @type {'miozu-light' | 'miozu-dark'}
87
+ */
88
+ currentTheme = $derived.by(() => this.dataTheme);
89
+
78
90
  /** @type {boolean} */
79
- #mounted = false;
91
+ isDark = $derived.by(() => this.resolved === 'dark');
92
+
93
+ /** @type {boolean} */
94
+ isLight = $derived.by(() => this.resolved === 'light');
95
+
96
+ /** @type {boolean} */
97
+ #initialized = false;
80
98
 
81
99
  /** @type {MediaQueryList | null} */
82
100
  #mediaQuery = null;
@@ -84,100 +102,171 @@ export class ThemeState {
84
102
  /** @type {(() => void) | null} */
85
103
  #mediaQueryHandler = null;
86
104
 
105
+ /**
106
+ * Parse theme from cookie string (for SSR)
107
+ * @param {string | null} cookieString - Raw cookie header
108
+ * @returns {'miozu-light' | 'miozu-dark'} - Theme value for data-theme attribute
109
+ */
110
+ static getThemeFromCookieString(cookieString) {
111
+ if (!cookieString) return 'miozu-dark';
112
+
113
+ const match = cookieString.match(/miozu-theme=([^;]+)/);
114
+ const preference = match ? match[1] : null;
115
+
116
+ if (preference === 'light') return 'miozu-light';
117
+ if (preference === 'dark') return 'miozu-dark';
118
+ // 'system' or invalid - default to dark
119
+ return 'miozu-dark';
120
+ }
121
+
87
122
  constructor(initial = 'system') {
88
123
  this.current = initial;
89
124
  }
90
125
 
91
126
  #resolveTheme() {
92
127
  if (this.current === 'system') {
93
- // SSR-safe: default to light if no window
94
- if (typeof window === 'undefined') return 'light';
128
+ // SSR-safe: default to dark if no window (miozu default)
129
+ if (typeof window === 'undefined') return 'dark';
95
130
  return this.#mediaQuery?.matches ? 'dark' : 'light';
96
131
  }
97
132
  return this.current;
98
133
  }
99
134
 
100
135
  /**
101
- * Initialize theme - call in onMount
136
+ * Initialize theme - call once in root layout onMount
137
+ * Loads from storage and sets up system preference listener
102
138
  */
103
139
  init() {
104
140
  if (typeof window === 'undefined') return;
141
+ if (this.#initialized) return; // Prevent double init
105
142
 
106
143
  // Load from storage
107
- const stored = localStorage.getItem('jera-theme');
144
+ const stored = localStorage.getItem('miozu-theme');
108
145
  if (stored && ['light', 'dark', 'system'].includes(stored)) {
109
146
  this.current = stored;
110
147
  }
111
148
 
112
- // Setup media query listener
149
+ // Setup media query listener for system preference
113
150
  this.#mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
114
151
  this.#mediaQueryHandler = () => {
115
- // Trigger re-resolution by reassigning
116
- this.current = this.current;
152
+ // Force re-resolution when system preference changes
153
+ if (this.current === 'system') {
154
+ this.#apply();
155
+ }
117
156
  };
118
157
  this.#mediaQuery.addEventListener('change', this.#mediaQueryHandler);
119
158
 
120
- this.#mounted = true;
159
+ this.#initialized = true;
121
160
  this.#apply();
122
161
  }
123
162
 
124
163
  /**
125
- * Cleanup - call in onDestroy to prevent memory leaks
164
+ * Cleanup media query listener
165
+ * Call in onDestroy if needed (usually not necessary for singleton)
126
166
  */
127
167
  cleanup() {
128
168
  if (this.#mediaQuery && this.#mediaQueryHandler) {
129
169
  this.#mediaQuery.removeEventListener('change', this.#mediaQueryHandler);
130
170
  this.#mediaQueryHandler = null;
131
171
  }
132
- this.#mounted = false;
172
+ this.#initialized = false;
173
+ }
174
+
175
+ /**
176
+ * Sync state with current DOM attribute (for hydration)
177
+ * Call after SSR to ensure state matches what app.html set
178
+ */
179
+ sync() {
180
+ if (typeof document === 'undefined') return;
181
+
182
+ const domTheme = document.documentElement.getAttribute('data-theme');
183
+ if (domTheme === 'miozu-dark') {
184
+ this.current = 'dark';
185
+ } else if (domTheme === 'miozu-light') {
186
+ this.current = 'light';
187
+ }
133
188
  }
134
189
 
135
190
  /**
136
- * Set theme and persist
191
+ * Set theme preference and persist
137
192
  * @param {'light' | 'dark' | 'system'} theme
138
193
  */
139
194
  set(theme) {
140
- this.current = theme;
141
- if (typeof window !== 'undefined') {
142
- localStorage.setItem('jera-theme', theme);
143
- document.cookie = `jera-theme=${theme};path=/;max-age=31536000`;
195
+ if (!['light', 'dark', 'system'].includes(theme)) {
196
+ console.warn(`Invalid theme: ${theme}`);
197
+ return;
144
198
  }
199
+ this.current = theme;
200
+ this.#persist();
145
201
  this.#apply();
146
202
  }
147
203
 
148
204
  /**
149
- * Toggle between light and dark
205
+ * Toggle between light and dark (skips system)
150
206
  */
151
207
  toggle() {
152
208
  this.set(this.resolved === 'light' ? 'dark' : 'light');
153
209
  }
154
210
 
211
+ /**
212
+ * Persist to localStorage and cookie
213
+ */
214
+ #persist() {
215
+ if (typeof window === 'undefined') return;
216
+ localStorage.setItem('miozu-theme', this.current);
217
+ document.cookie = `miozu-theme=${this.current};path=/;max-age=31536000;SameSite=Lax`;
218
+ }
219
+
155
220
  /**
156
221
  * Apply theme to document
157
222
  */
158
223
  #apply() {
159
224
  if (typeof document === 'undefined') return;
160
- document.documentElement.setAttribute('data-theme', this.resolved);
225
+ document.documentElement.setAttribute('data-theme', this.dataTheme);
226
+ document.documentElement.style.colorScheme = this.resolved;
161
227
  }
162
228
  }
163
229
 
230
+ // ============================================
231
+ // SINGLETON INSTANCE
232
+ // ============================================
233
+
234
+ /** @type {ThemeState | null} */
235
+ let themeInstance = null;
236
+
164
237
  /**
165
- * Create and provide theme context
166
- * @param {'light' | 'dark' | 'system'} [initial]
238
+ * Get the global theme singleton
239
+ * Creates instance on first call (lazy initialization)
240
+ *
241
+ * @param {'light' | 'dark' | 'system'} [initial] - Initial theme (only used on first call)
167
242
  * @returns {ThemeState}
243
+ *
244
+ * @example
245
+ * // In root +layout.svelte
246
+ * import { getTheme } from '@miozu/jera';
247
+ * const theme = getTheme();
248
+ * onMount(() => theme.init());
249
+ *
250
+ * // Anywhere else
251
+ * import { getTheme } from '@miozu/jera';
252
+ * const theme = getTheme();
253
+ * theme.toggle();
168
254
  */
169
- export function createThemeContext(initial = 'system') {
170
- const theme = new ThemeState(initial);
171
- setContext(THEME_KEY, theme);
172
- return theme;
255
+ export function getTheme(initial = 'system') {
256
+ if (!themeInstance) {
257
+ themeInstance = new ThemeState(initial);
258
+ }
259
+ return themeInstance;
173
260
  }
174
261
 
175
262
  /**
176
- * Get theme from context
177
- * @returns {ThemeState}
263
+ * Reset theme singleton (for testing)
178
264
  */
179
- export function getThemeContext() {
180
- return getContext(THEME_KEY);
265
+ export function resetTheme() {
266
+ if (themeInstance) {
267
+ themeInstance.cleanup();
268
+ themeInstance = null;
269
+ }
181
270
  }
182
271
 
183
272
  /**