@miozu/jera 0.8.1 → 0.8.3

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/CLAUDE.md CHANGED
@@ -58,13 +58,28 @@ Singleton pattern with `miozu-theme` storage key.
58
58
 
59
59
  **Full reference:** `docs/ai-context/theme-management.md`
60
60
 
61
+ **ThemeToggle:** Accessible toggle with animated sun/moon icons
61
62
  ```svelte
62
- <!-- +layout.svelte -->
63
- <script>
64
- import { getTheme } from '@miozu/jera';
65
- const themeState = getTheme();
66
- onMount(() => { themeState.sync(); themeState.init(); });
67
- </script>
63
+ import { ThemeToggle } from '@miozu/jera';
64
+ <ThemeToggle />
65
+ <ThemeToggle size="sm" variant="outline" />
66
+ ```
67
+
68
+ **ThemeSelect:** Three-option selector (light/dark/system)
69
+ ```svelte
70
+ import { ThemeSelect } from '@miozu/jera';
71
+ <ThemeSelect />
72
+ <ThemeSelect variant="dropdown" />
73
+ ```
74
+
75
+ **ThemeState API:**
76
+ ```javascript
77
+ import { getTheme } from '@miozu/jera';
78
+ const theme = getTheme();
79
+ theme.init(); // Call once in root onMount
80
+ theme.toggle(); // Switch dark/light
81
+ theme.set('system'); // Follow OS preference
82
+ theme.isDark; // boolean reactive property
68
83
  ```
69
84
 
70
85
  ## Svelte 5 Patterns
package/README.md CHANGED
@@ -176,23 +176,68 @@ button({ variant: 'secondary' }); // => "inline-flex items-center bg-surface h-1
176
176
 
177
177
  Dark theme is default. Uses singleton pattern with `miozu-theme` storage key.
178
178
 
179
- ```javascript
180
- // In root +layout.svelte
181
- import { getTheme } from '@miozu/jera';
182
- import { onMount } from 'svelte';
179
+ ### Setup
183
180
 
184
- const theme = getTheme();
185
- onMount(() => theme.init());
181
+ ```svelte
182
+ <!-- +layout.svelte -->
183
+ <script>
184
+ import { getTheme } from '@miozu/jera';
185
+ import { onMount } from 'svelte';
186
+
187
+ const theme = getTheme();
188
+ onMount(() => theme.init());
189
+ </script>
190
+ ```
191
+
192
+ ### ThemeToggle
193
+
194
+ Accessible toggle button with animated sun/moon icons.
195
+
196
+ ```svelte
197
+ <script>
198
+ import { ThemeToggle } from '@miozu/jera';
199
+ </script>
200
+
201
+ <ThemeToggle />
202
+ <ThemeToggle size="sm" variant="outline" />
203
+ <ThemeToggle size="lg" variant="subtle" />
204
+ ```
205
+
206
+ Props: `size` (sm|md|lg), `variant` (ghost|outline|subtle), `themeState` (optional)
207
+
208
+ ### ThemeSelect
209
+
210
+ Three-option selector for light/dark/system preference.
211
+
212
+ ```svelte
213
+ <script>
214
+ import { ThemeSelect } from '@miozu/jera';
215
+ </script>
216
+
217
+ <!-- Segmented control (default) -->
218
+ <ThemeSelect />
219
+
220
+ <!-- Dropdown -->
221
+ <ThemeSelect variant="dropdown" />
222
+
223
+ <!-- Custom labels -->
224
+ <ThemeSelect labels={{ light: 'Light', dark: 'Dark', system: 'Auto' }} />
186
225
  ```
187
226
 
227
+ Props: `variant` (segmented|dropdown), `size` (sm|md|lg), `labels`, `showIcons`
228
+
229
+ ### ThemeState API
230
+
188
231
  ```javascript
189
- // Anywhere in your app
190
232
  import { getTheme } from '@miozu/jera';
191
233
 
192
234
  const theme = getTheme();
193
235
  theme.toggle(); // Switch between light/dark
194
236
  theme.set('system'); // Follow system preference
195
- theme.isDark; // boolean reactive property
237
+ theme.isDark; // boolean - resolved dark mode
238
+ theme.isLight; // boolean - resolved light mode
239
+ theme.current; // 'light' | 'dark' | 'system'
240
+ theme.dataTheme; // 'miozu-light' | 'miozu-dark'
196
241
  ```
197
242
 
198
243
  Data-theme values: `miozu-dark` (default) or `miozu-light`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miozu/jera",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
4
4
  "description": "Zero-dependency, AI-first component library for Svelte 5",
5
5
  "type": "module",
6
6
  "svelte": "./src/index.js",
@@ -2,28 +2,24 @@
2
2
  @component SettingCard
3
3
 
4
4
  A card container for settings sections with optional danger variant.
5
- Provides consistent layout for settings items with labels, descriptions, and actions.
5
+ Use with SettingItem for structured setting rows.
6
6
 
7
7
  @example Basic settings card
8
8
  <SettingCard title="Account Settings">
9
- <div class="setting-item">
10
- <div class="setting-content">
11
- <h4 class="setting-label">Display Name</h4>
12
- <p class="setting-description">Your public display name</p>
13
- </div>
14
- <Input value={name} />
15
- </div>
9
+ <SettingItem label="Display Name" description="Your public display name">
10
+ {#snippet action()}
11
+ <Input value={name} />
12
+ {/snippet}
13
+ </SettingItem>
16
14
  </SettingCard>
17
15
 
18
16
  @example Danger zone
19
17
  <SettingCard title="Danger Zone" variant="danger">
20
- <div class="setting-item">
21
- <div class="setting-content">
22
- <h4 class="setting-label">Delete Account</h4>
23
- <p class="setting-description">This cannot be undone</p>
24
- </div>
25
- <Button variant="danger">Delete</Button>
26
- </div>
18
+ <SettingItem label="Delete Account" description="This cannot be undone">
19
+ {#snippet action()}
20
+ <Button variant="danger">Delete</Button>
21
+ {/snippet}
22
+ </SettingItem>
27
23
  </SettingCard>
28
24
  -->
29
25
  <script>
@@ -83,71 +79,4 @@
83
79
  display: flex;
84
80
  flex-direction: column;
85
81
  }
86
-
87
- /* Utility classes for setting items - exposed globally */
88
- :global(.setting-item) {
89
- display: flex;
90
- align-items: center;
91
- justify-content: space-between;
92
- gap: var(--space-4);
93
- padding: var(--space-4) 0;
94
- border-bottom: 1px solid var(--color-base02);
95
- }
96
-
97
- :global(.setting-item:last-child) {
98
- border-bottom: none;
99
- padding-bottom: 0;
100
- }
101
-
102
- :global(.setting-item:first-child) {
103
- padding-top: 0;
104
- }
105
-
106
- :global(.setting-content) {
107
- flex: 1;
108
- min-width: 0;
109
- }
110
-
111
- :global(.setting-label) {
112
- margin: 0 0 var(--space-1);
113
- font-size: var(--text-sm);
114
- font-weight: 500;
115
- color: var(--color-base06);
116
- }
117
-
118
- :global(.setting-description) {
119
- margin: 0;
120
- font-size: var(--text-xs);
121
- color: var(--color-base04);
122
- line-height: 1.5;
123
- }
124
-
125
- :global(.setting-item-icon) {
126
- display: flex;
127
- align-items: center;
128
- gap: var(--space-3);
129
- color: color-mix(in srgb, var(--color-base04) 80%, transparent);
130
- }
131
-
132
- :global(.setting-item-with-icon) {
133
- display: flex;
134
- align-items: flex-start;
135
- gap: var(--space-3);
136
- padding: var(--space-4) 0;
137
- border-bottom: 1px solid var(--color-base02);
138
- }
139
-
140
- :global(.setting-item-with-icon:last-child) {
141
- border-bottom: none;
142
- padding-bottom: 0;
143
- }
144
-
145
- /* Responsive */
146
- @media (max-width: 640px) {
147
- :global(.setting-item) {
148
- flex-direction: column;
149
- align-items: flex-start;
150
- gap: var(--space-3);
151
- }
152
- }
153
82
  </style>
@@ -0,0 +1,116 @@
1
+ <!--
2
+ @component SettingItem
3
+
4
+ A structured setting row for use inside SettingCard.
5
+ Replaces magic class names with a composable component API.
6
+
7
+ @example Basic setting
8
+ <SettingItem label="Display Name" description="Your public display name">
9
+ {#snippet action()}
10
+ <Input value={name} />
11
+ {/snippet}
12
+ </SettingItem>
13
+
14
+ @example With icon
15
+ <SettingItem label="Active Sessions" description="Manage your devices">
16
+ {#snippet leading()}
17
+ <Monitor size={16} />
18
+ {/snippet}
19
+ {#snippet action()}
20
+ <Button size="sm">Manage</Button>
21
+ {/snippet}
22
+ </SettingItem>
23
+ -->
24
+ <script>
25
+ let {
26
+ label = '',
27
+ description = '',
28
+ leading,
29
+ action,
30
+ class: className = ''
31
+ } = $props();
32
+ </script>
33
+
34
+ <div class="setting-item {className}" class:has-leading={leading}>
35
+ {#if leading}
36
+ <div class="setting-leading">
37
+ {@render leading()}
38
+ </div>
39
+ {/if}
40
+ <div class="setting-content">
41
+ {#if label}
42
+ <h4 class="setting-label">{label}</h4>
43
+ {/if}
44
+ {#if description}
45
+ <p class="setting-description">{description}</p>
46
+ {/if}
47
+ </div>
48
+ {#if action}
49
+ <div class="setting-action">
50
+ {@render action()}
51
+ </div>
52
+ {/if}
53
+ </div>
54
+
55
+ <style>
56
+ .setting-item {
57
+ display: flex;
58
+ align-items: center;
59
+ justify-content: space-between;
60
+ gap: var(--space-4);
61
+ padding: var(--space-4) 0;
62
+ border-bottom: 1px solid var(--color-base02);
63
+ }
64
+
65
+ .setting-item:last-child {
66
+ border-bottom: none;
67
+ padding-bottom: 0;
68
+ }
69
+
70
+ .setting-item:first-child {
71
+ padding-top: 0;
72
+ }
73
+
74
+ .has-leading {
75
+ justify-content: flex-start;
76
+ }
77
+
78
+ .setting-leading {
79
+ display: flex;
80
+ align-items: center;
81
+ color: color-mix(in srgb, var(--color-base04) 80%, transparent);
82
+ flex-shrink: 0;
83
+ }
84
+
85
+ .setting-content {
86
+ flex: 1;
87
+ min-width: 0;
88
+ }
89
+
90
+ .setting-label {
91
+ margin: 0 0 var(--space-1);
92
+ font-size: var(--text-sm);
93
+ font-weight: 500;
94
+ color: var(--color-base06);
95
+ }
96
+
97
+ .setting-description {
98
+ margin: 0;
99
+ font-size: var(--text-xs);
100
+ color: var(--color-base04);
101
+ line-height: 1.5;
102
+ }
103
+
104
+ .setting-action {
105
+ flex-shrink: 0;
106
+ }
107
+
108
+ /* Responsive */
109
+ @media (max-width: 640px) {
110
+ .setting-item:not(.has-leading) {
111
+ flex-direction: column;
112
+ align-items: flex-start;
113
+ gap: var(--space-3);
114
+ }
115
+ }
116
+ </style>
@@ -45,10 +45,34 @@
45
45
  active = tab.id;
46
46
  onchange?.(tab);
47
47
  }
48
+
49
+ function handleKeydown(e, index) {
50
+ let nextIndex = index;
51
+
52
+ if (e.key === 'ArrowRight') {
53
+ nextIndex = (index + 1) % tabs.length;
54
+ } else if (e.key === 'ArrowLeft') {
55
+ nextIndex = (index - 1 + tabs.length) % tabs.length;
56
+ } else if (e.key === 'Home') {
57
+ nextIndex = 0;
58
+ } else if (e.key === 'End') {
59
+ nextIndex = tabs.length - 1;
60
+ } else {
61
+ return;
62
+ }
63
+
64
+ e.preventDefault();
65
+ const nextTab = tabs[nextIndex];
66
+ if (!nextTab.disabled) {
67
+ handleTabClick(nextTab);
68
+ const buttons = e.currentTarget.parentElement.querySelectorAll('[role="tab"]');
69
+ buttons[nextIndex]?.focus();
70
+ }
71
+ }
48
72
  </script>
49
73
 
50
74
  <nav class="tab-nav tab-nav-{variant} tab-nav-{size} {className}" role="tablist">
51
- {#each tabs as tab}
75
+ {#each tabs as tab, index}
52
76
  <button
53
77
  type="button"
54
78
  class="tab-item"
@@ -57,7 +81,9 @@
57
81
  role="tab"
58
82
  aria-selected={active === tab.id}
59
83
  aria-disabled={tab.disabled}
84
+ tabindex={active === tab.id ? 0 : -1}
60
85
  onclick={() => handleTabClick(tab)}
86
+ onkeydown={(e) => handleKeydown(e, index)}
61
87
  >
62
88
  {#if tab.icon}
63
89
  <span class="tab-icon">
@@ -1,7 +1,7 @@
1
1
  <!--
2
2
  @component Tabs
3
3
 
4
- Tabbed navigation component.
4
+ Tabbed navigation component with keyboard navigation.
5
5
 
6
6
  @example Basic usage
7
7
  <Tabs
@@ -13,7 +13,7 @@
13
13
  bind:active={activeTab}
14
14
  />
15
15
 
16
- @example With icons
16
+ @example With icons (component reference)
17
17
  <Tabs
18
18
  tabs={[
19
19
  { id: 'home', label: 'Home', icon: HomeIcon },
@@ -64,7 +64,6 @@
64
64
  const nextTab = tabs[nextIndex];
65
65
  if (!nextTab.disabled) {
66
66
  selectTab(nextTab);
67
- // Focus the next tab button
68
67
  const buttons = e.currentTarget.parentElement.querySelectorAll('[role="tab"]');
69
68
  buttons[nextIndex]?.focus();
70
69
  }
@@ -90,8 +89,9 @@
90
89
  onkeydown={(e) => handleKeydown(e, tab, index)}
91
90
  >
92
91
  {#if tab.icon}
92
+ {@const Icon = tab.icon}
93
93
  <span class="tab-icon">
94
- <svelte:component this={tab.icon} size={16} />
94
+ <Icon size={16} />
95
95
  </span>
96
96
  {/if}
97
97
  {#if tab.label}
@@ -107,10 +107,10 @@
107
107
  <style>
108
108
  .tabs {
109
109
  display: inline-flex;
110
- gap: 0.25rem;
110
+ gap: var(--space-1);
111
111
  background: var(--color-base01);
112
- border-radius: 0.5rem;
113
- padding: 0.25rem;
112
+ border-radius: var(--radius-lg);
113
+ padding: var(--space-1);
114
114
  }
115
115
 
116
116
  .tabs-full-width {
@@ -126,12 +126,12 @@
126
126
  .tab {
127
127
  display: inline-flex;
128
128
  align-items: center;
129
- gap: 0.5rem;
130
- padding: 0.5rem 1rem;
129
+ gap: var(--space-2);
130
+ padding: var(--space-2) var(--space-4);
131
131
  background: transparent;
132
132
  border: none;
133
- border-radius: 0.375rem;
134
- font-size: 0.875rem;
133
+ border-radius: var(--radius-md);
134
+ font-size: var(--text-sm);
135
135
  font-weight: 500;
136
136
  color: var(--color-base05);
137
137
  cursor: pointer;
@@ -141,13 +141,13 @@
141
141
 
142
142
  /* Size variants */
143
143
  .tabs-sm .tab {
144
- padding: 0.375rem 0.75rem;
145
- font-size: 0.75rem;
144
+ padding: var(--space-1) var(--space-3);
145
+ font-size: var(--text-xs);
146
146
  }
147
147
 
148
148
  .tabs-lg .tab {
149
- padding: 0.625rem 1.25rem;
150
- font-size: 1rem;
149
+ padding: var(--space-3) var(--space-5);
150
+ font-size: var(--text-base);
151
151
  }
152
152
 
153
153
  .tab:hover:not(.tab-disabled) {
@@ -180,12 +180,12 @@
180
180
  display: inline-flex;
181
181
  align-items: center;
182
182
  justify-content: center;
183
- min-width: 1.25rem;
184
- height: 1.25rem;
185
- padding: 0 0.375rem;
183
+ min-width: var(--space-5);
184
+ height: var(--space-5);
185
+ padding: 0 var(--space-1);
186
186
  background: var(--color-base03);
187
187
  border-radius: 9999px;
188
- font-size: 0.75rem;
188
+ font-size: var(--text-xs);
189
189
  font-weight: 600;
190
190
  }
191
191
 
@@ -224,7 +224,7 @@
224
224
  .tabs-pills {
225
225
  background: transparent;
226
226
  padding: 0;
227
- gap: 0.5rem;
227
+ gap: var(--space-2);
228
228
  }
229
229
 
230
230
  .tabs-pills .tab {
@@ -0,0 +1,260 @@
1
+ <!--
2
+ @component ThemeSelect
3
+
4
+ A dropdown/segmented selector for theme preference with light/dark/system options.
5
+ Uses the Jera ThemeState singleton for theme management.
6
+
7
+ @example Basic dropdown
8
+ <ThemeSelect />
9
+
10
+ @example Segmented control style
11
+ <ThemeSelect variant="segmented" />
12
+
13
+ @example With custom theme state
14
+ <ThemeSelect themeState={myThemeState} />
15
+
16
+ @example Custom labels
17
+ <ThemeSelect
18
+ labels={{ light: 'Light', dark: 'Dark', system: 'Auto' }}
19
+ />
20
+ -->
21
+ <script>
22
+ import { cn } from '../../utils/cn.svelte.js';
23
+ import { getTheme, generateId } from '../../utils/reactive.svelte.js';
24
+
25
+ let {
26
+ themeState,
27
+ variant = 'segmented',
28
+ size = 'md',
29
+ labels = { light: 'Light', dark: 'Dark', system: 'System' },
30
+ showIcons = true,
31
+ class: className = '',
32
+ ...rest
33
+ } = $props();
34
+
35
+ // Use provided themeState or fall back to singleton
36
+ const theme = themeState ?? getTheme();
37
+ const groupId = generateId();
38
+
39
+ // Current preference (not resolved)
40
+ const current = $derived(theme.current);
41
+
42
+ // Size mappings
43
+ const sizes = {
44
+ sm: { wrapper: 'theme-select-sm', icon: 14 },
45
+ md: { wrapper: 'theme-select-md', icon: 16 },
46
+ lg: { wrapper: 'theme-select-lg', icon: 18 }
47
+ };
48
+
49
+ const currentSize = $derived(sizes[size] || sizes.md);
50
+
51
+ const options = [
52
+ { value: 'light', label: labels.light, icon: 'sun' },
53
+ { value: 'dark', label: labels.dark, icon: 'moon' },
54
+ { value: 'system', label: labels.system, icon: 'monitor' }
55
+ ];
56
+
57
+ function handleChange(value) {
58
+ theme.set(value);
59
+ }
60
+ </script>
61
+
62
+ {#if variant === 'segmented'}
63
+ <div
64
+ class={cn('theme-select-segmented', currentSize.wrapper, className)}
65
+ role="radiogroup"
66
+ aria-label="Theme preference"
67
+ {...rest}
68
+ >
69
+ {#each options as option (option.value)}
70
+ {@const isActive = current === option.value}
71
+ <button
72
+ type="button"
73
+ role="radio"
74
+ aria-checked={isActive}
75
+ class={cn('theme-select-option', isActive && 'theme-select-option-active')}
76
+ onclick={() => handleChange(option.value)}
77
+ >
78
+ {#if showIcons}
79
+ <span class="theme-select-icon" aria-hidden="true">
80
+ {#if option.icon === 'sun'}
81
+ <svg width={currentSize.icon} height={currentSize.icon} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
82
+ <circle cx="12" cy="12" r="4" />
83
+ <path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
84
+ </svg>
85
+ {:else if option.icon === 'moon'}
86
+ <svg width={currentSize.icon} height={currentSize.icon} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
87
+ <path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
88
+ </svg>
89
+ {:else if option.icon === 'monitor'}
90
+ <svg width={currentSize.icon} height={currentSize.icon} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
91
+ <rect width="20" height="14" x="2" y="3" rx="2" />
92
+ <line x1="8" x2="16" y1="21" y2="21" />
93
+ <line x1="12" x2="12" y1="17" y2="21" />
94
+ </svg>
95
+ {/if}
96
+ </span>
97
+ {/if}
98
+ <span class="theme-select-label">{option.label}</span>
99
+ </button>
100
+ {/each}
101
+ </div>
102
+ {:else}
103
+ <!-- Dropdown variant -->
104
+ <div class={cn('theme-select-dropdown', currentSize.wrapper, className)} {...rest}>
105
+ <select
106
+ id={groupId}
107
+ value={current}
108
+ onchange={(e) => handleChange(e.target.value)}
109
+ aria-label="Theme preference"
110
+ class="theme-select-native"
111
+ >
112
+ {#each options as option (option.value)}
113
+ <option value={option.value}>{option.label}</option>
114
+ {/each}
115
+ </select>
116
+ <span class="theme-select-dropdown-icon" aria-hidden="true">
117
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
118
+ <path d="m6 9 6 6 6-6" />
119
+ </svg>
120
+ </span>
121
+ </div>
122
+ {/if}
123
+
124
+ <style>
125
+ /* ============================================
126
+ SEGMENTED CONTROL
127
+ ============================================ */
128
+ .theme-select-segmented {
129
+ display: inline-flex;
130
+ align-items: center;
131
+ background-color: var(--color-base01, #282828);
132
+ border-radius: var(--radius-md, 0.375rem);
133
+ padding: 2px;
134
+ gap: 2px;
135
+ }
136
+
137
+ .theme-select-option {
138
+ display: inline-flex;
139
+ align-items: center;
140
+ justify-content: center;
141
+ gap: var(--space-1, 0.25rem);
142
+ padding: var(--space-1, 0.25rem) var(--space-3, 0.75rem);
143
+ border: none;
144
+ border-radius: calc(var(--radius-md, 0.375rem) - 2px);
145
+ background-color: transparent;
146
+ color: var(--color-base04, #665c54);
147
+ font-size: var(--text-sm, 0.875rem);
148
+ font-weight: 500;
149
+ cursor: pointer;
150
+ transition:
151
+ background-color var(--duration-fast, 150ms) var(--ease-out, ease-out),
152
+ color var(--duration-fast, 150ms) var(--ease-out, ease-out);
153
+ }
154
+
155
+ .theme-select-option:hover:not(.theme-select-option-active) {
156
+ color: var(--color-base05, #a89984);
157
+ }
158
+
159
+ .theme-select-option:focus-visible {
160
+ outline: 2px solid var(--color-base0D, #83a598);
161
+ outline-offset: -2px;
162
+ }
163
+
164
+ .theme-select-option-active {
165
+ background-color: var(--color-base02, #3c3836);
166
+ color: var(--color-base06, #d5c4a1);
167
+ box-shadow: var(--shadow-sm, 0 1px 2px rgba(0, 0, 0, 0.1));
168
+ }
169
+
170
+ .theme-select-icon {
171
+ display: flex;
172
+ align-items: center;
173
+ justify-content: center;
174
+ }
175
+
176
+ .theme-select-label {
177
+ white-space: nowrap;
178
+ }
179
+
180
+ /* ============================================
181
+ SIZES - SEGMENTED
182
+ ============================================ */
183
+ .theme-select-sm .theme-select-option {
184
+ padding: 2px var(--space-2, 0.5rem);
185
+ font-size: var(--text-xs, 0.75rem);
186
+ }
187
+
188
+ .theme-select-md .theme-select-option {
189
+ padding: var(--space-1, 0.25rem) var(--space-3, 0.75rem);
190
+ font-size: var(--text-sm, 0.875rem);
191
+ }
192
+
193
+ .theme-select-lg .theme-select-option {
194
+ padding: var(--space-2, 0.5rem) var(--space-4, 1rem);
195
+ font-size: var(--text-base, 1rem);
196
+ }
197
+
198
+ /* ============================================
199
+ DROPDOWN
200
+ ============================================ */
201
+ .theme-select-dropdown {
202
+ position: relative;
203
+ display: inline-flex;
204
+ align-items: center;
205
+ }
206
+
207
+ .theme-select-native {
208
+ appearance: none;
209
+ background-color: var(--color-base01, #282828);
210
+ border: 1px solid var(--color-base03, #504945);
211
+ border-radius: var(--radius-md, 0.375rem);
212
+ color: var(--color-base06, #d5c4a1);
213
+ font-size: var(--text-sm, 0.875rem);
214
+ padding: var(--space-2, 0.5rem) var(--space-8, 2rem) var(--space-2, 0.5rem) var(--space-3, 0.75rem);
215
+ cursor: pointer;
216
+ transition:
217
+ border-color var(--duration-fast, 150ms) var(--ease-out, ease-out),
218
+ background-color var(--duration-fast, 150ms) var(--ease-out, ease-out);
219
+ }
220
+
221
+ .theme-select-native:hover {
222
+ border-color: var(--color-base04, #665c54);
223
+ }
224
+
225
+ .theme-select-native:focus {
226
+ outline: none;
227
+ border-color: var(--color-base0D, #83a598);
228
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-base0D, #83a598) 20%, transparent);
229
+ }
230
+
231
+ .theme-select-dropdown-icon {
232
+ position: absolute;
233
+ right: var(--space-2, 0.5rem);
234
+ pointer-events: none;
235
+ color: var(--color-base04, #665c54);
236
+ }
237
+
238
+ /* ============================================
239
+ SIZES - DROPDOWN
240
+ ============================================ */
241
+ .theme-select-dropdown.theme-select-sm .theme-select-native {
242
+ padding: var(--space-1, 0.25rem) var(--space-6, 1.5rem) var(--space-1, 0.25rem) var(--space-2, 0.5rem);
243
+ font-size: var(--text-xs, 0.75rem);
244
+ }
245
+
246
+ .theme-select-dropdown.theme-select-lg .theme-select-native {
247
+ padding: var(--space-3, 0.75rem) var(--space-10, 2.5rem) var(--space-3, 0.75rem) var(--space-4, 1rem);
248
+ font-size: var(--text-base, 1rem);
249
+ }
250
+
251
+ /* ============================================
252
+ REDUCED MOTION
253
+ ============================================ */
254
+ @media (prefers-reduced-motion: reduce) {
255
+ .theme-select-option,
256
+ .theme-select-native {
257
+ transition: none;
258
+ }
259
+ }
260
+ </style>
@@ -0,0 +1,263 @@
1
+ <!--
2
+ @component ThemeToggle
3
+
4
+ An accessible theme toggle button with animated sun/moon icons.
5
+ Uses the Jera ThemeState singleton for theme management.
6
+
7
+ @example Basic usage
8
+ <ThemeToggle />
9
+
10
+ @example With custom theme state
11
+ <ThemeToggle themeState={myThemeState} />
12
+
13
+ @example Different sizes
14
+ <ThemeToggle size="sm" />
15
+ <ThemeToggle size="lg" />
16
+
17
+ @example With label
18
+ <ThemeToggle>
19
+ {#snippet label()}Dark mode{/snippet}
20
+ </ThemeToggle>
21
+
22
+ @example Custom icons via snippets
23
+ <ThemeToggle>
24
+ {#snippet sunIcon()}<MyCustomSun />{/snippet}
25
+ {#snippet moonIcon()}<MyCustomMoon />{/snippet}
26
+ </ThemeToggle>
27
+ -->
28
+ <script>
29
+ import { cn } from '../../utils/cn.svelte.js';
30
+ import { getTheme } from '../../utils/reactive.svelte.js';
31
+
32
+ let {
33
+ themeState,
34
+ size = 'md',
35
+ variant = 'ghost',
36
+ class: className = '',
37
+ sunIcon,
38
+ moonIcon,
39
+ label,
40
+ ...rest
41
+ } = $props();
42
+
43
+ // Use provided themeState or fall back to singleton
44
+ const theme = themeState ?? getTheme();
45
+
46
+ // Derive current state
47
+ const isDark = $derived(theme.isDark);
48
+
49
+ // Size mappings
50
+ const sizes = {
51
+ sm: { button: 'theme-toggle-sm', icon: 16 },
52
+ md: { button: 'theme-toggle-md', icon: 20 },
53
+ lg: { button: 'theme-toggle-lg', icon: 24 }
54
+ };
55
+
56
+ const currentSize = $derived(sizes[size] || sizes.md);
57
+
58
+ function handleToggle() {
59
+ theme.toggle();
60
+ }
61
+ </script>
62
+
63
+ <button
64
+ type="button"
65
+ onclick={handleToggle}
66
+ aria-pressed={isDark}
67
+ aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
68
+ class={cn('theme-toggle', `theme-toggle-${variant}`, currentSize.button, className)}
69
+ {...rest}
70
+ >
71
+ <span class="theme-toggle-icon-wrapper">
72
+ <!-- Sun icon (visible in dark mode, click to switch to light) -->
73
+ <span class={cn('theme-toggle-icon', 'theme-toggle-sun', isDark && 'theme-toggle-icon-active')}>
74
+ {#if sunIcon}
75
+ {@render sunIcon()}
76
+ {:else}
77
+ <svg
78
+ width={currentSize.icon}
79
+ height={currentSize.icon}
80
+ viewBox="0 0 24 24"
81
+ fill="none"
82
+ stroke="currentColor"
83
+ stroke-width="2"
84
+ stroke-linecap="round"
85
+ stroke-linejoin="round"
86
+ aria-hidden="true"
87
+ >
88
+ <circle cx="12" cy="12" r="4" />
89
+ <path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
90
+ </svg>
91
+ {/if}
92
+ </span>
93
+
94
+ <!-- Moon icon (visible in light mode, click to switch to dark) -->
95
+ <span class={cn('theme-toggle-icon', 'theme-toggle-moon', !isDark && 'theme-toggle-icon-active')}>
96
+ {#if moonIcon}
97
+ {@render moonIcon()}
98
+ {:else}
99
+ <svg
100
+ width={currentSize.icon}
101
+ height={currentSize.icon}
102
+ viewBox="0 0 24 24"
103
+ fill="none"
104
+ stroke="currentColor"
105
+ stroke-width="2"
106
+ stroke-linecap="round"
107
+ stroke-linejoin="round"
108
+ aria-hidden="true"
109
+ >
110
+ <path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
111
+ </svg>
112
+ {/if}
113
+ </span>
114
+ </span>
115
+
116
+ {#if label}
117
+ <span class="theme-toggle-label">
118
+ {@render label()}
119
+ </span>
120
+ {/if}
121
+ </button>
122
+
123
+ <style>
124
+ /* ============================================
125
+ BASE STYLES
126
+ ============================================ */
127
+ .theme-toggle {
128
+ position: relative;
129
+ display: inline-flex;
130
+ align-items: center;
131
+ justify-content: center;
132
+ gap: var(--space-2, 0.5rem);
133
+ border: none;
134
+ border-radius: var(--radius-md, 0.375rem);
135
+ cursor: pointer;
136
+ transition: background-color var(--duration-fast, 150ms) var(--ease-out, ease-out);
137
+ }
138
+
139
+ .theme-toggle:focus-visible {
140
+ outline: 2px solid var(--color-base0D, #83a598);
141
+ outline-offset: 2px;
142
+ }
143
+
144
+ /* ============================================
145
+ VARIANTS
146
+ ============================================ */
147
+ .theme-toggle-ghost {
148
+ background-color: transparent;
149
+ color: var(--color-base05, #a89984);
150
+ }
151
+
152
+ .theme-toggle-ghost:hover {
153
+ background-color: var(--color-base02, #3c3836);
154
+ color: var(--color-base06, #d5c4a1);
155
+ }
156
+
157
+ .theme-toggle-outline {
158
+ background-color: transparent;
159
+ color: var(--color-base05, #a89984);
160
+ border: 1px solid var(--color-base03, #504945);
161
+ }
162
+
163
+ .theme-toggle-outline:hover {
164
+ background-color: var(--color-base02, #3c3836);
165
+ border-color: var(--color-base04, #665c54);
166
+ color: var(--color-base06, #d5c4a1);
167
+ }
168
+
169
+ .theme-toggle-subtle {
170
+ background-color: var(--color-base01, #282828);
171
+ color: var(--color-base05, #a89984);
172
+ }
173
+
174
+ .theme-toggle-subtle:hover {
175
+ background-color: var(--color-base02, #3c3836);
176
+ color: var(--color-base06, #d5c4a1);
177
+ }
178
+
179
+ /* ============================================
180
+ SIZES
181
+ ============================================ */
182
+ .theme-toggle-sm {
183
+ padding: var(--space-1, 0.25rem);
184
+ min-width: 1.75rem;
185
+ min-height: 1.75rem;
186
+ }
187
+
188
+ .theme-toggle-md {
189
+ padding: var(--space-2, 0.5rem);
190
+ min-width: 2.25rem;
191
+ min-height: 2.25rem;
192
+ }
193
+
194
+ .theme-toggle-lg {
195
+ padding: var(--space-3, 0.75rem);
196
+ min-width: 2.75rem;
197
+ min-height: 2.75rem;
198
+ }
199
+
200
+ /* ============================================
201
+ ICON ANIMATION
202
+ ============================================ */
203
+ .theme-toggle-icon-wrapper {
204
+ position: relative;
205
+ display: flex;
206
+ align-items: center;
207
+ justify-content: center;
208
+ }
209
+
210
+ .theme-toggle-icon {
211
+ position: absolute;
212
+ display: flex;
213
+ align-items: center;
214
+ justify-content: center;
215
+ opacity: 0;
216
+ transform: rotate(-90deg) scale(0.5);
217
+ transition:
218
+ opacity var(--duration-normal, 200ms) var(--ease-out, ease-out),
219
+ transform var(--duration-normal, 200ms) var(--ease-out, ease-out);
220
+ }
221
+
222
+ .theme-toggle-icon-active {
223
+ position: relative;
224
+ opacity: 1;
225
+ transform: rotate(0deg) scale(1);
226
+ }
227
+
228
+ /* Sun specific - warm color when active */
229
+ .theme-toggle-sun.theme-toggle-icon-active {
230
+ color: var(--color-base0A, #fabd2f);
231
+ }
232
+
233
+ /* Moon specific - cool color when active */
234
+ .theme-toggle-moon.theme-toggle-icon-active {
235
+ color: var(--color-base0D, #83a598);
236
+ }
237
+
238
+ /* ============================================
239
+ REDUCED MOTION
240
+ ============================================ */
241
+ @media (prefers-reduced-motion: reduce) {
242
+ .theme-toggle-icon {
243
+ transition: opacity var(--duration-fast, 150ms) var(--ease-out, ease-out);
244
+ transform: none;
245
+ }
246
+
247
+ .theme-toggle-icon-active {
248
+ transform: none;
249
+ }
250
+ }
251
+
252
+ /* ============================================
253
+ LABEL
254
+ ============================================ */
255
+ .theme-toggle-label {
256
+ font-size: var(--text-sm, 0.875rem);
257
+ color: var(--color-base05, #a89984);
258
+ }
259
+
260
+ .theme-toggle:hover .theme-toggle-label {
261
+ color: var(--color-base06, #d5c4a1);
262
+ }
263
+ </style>
package/src/index.js CHANGED
@@ -40,6 +40,8 @@ export { default as FilterChip } from './components/primitives/FilterChip.svelte
40
40
  export { default as StatusLine } from './components/primitives/StatusLine.svelte';
41
41
  export { default as Tooltip } from './components/primitives/Tooltip.svelte';
42
42
  export { default as MemberCard } from './components/primitives/MemberCard.svelte';
43
+ export { default as ThemeToggle } from './components/primitives/ThemeToggle.svelte';
44
+ export { default as ThemeSelect } from './components/primitives/ThemeSelect.svelte';
43
45
 
44
46
  // ============================================
45
47
  // COMPONENTS - Forms
@@ -139,6 +141,7 @@ export { default as DropdownContainer } from './components/navigation/DropdownCo
139
141
 
140
142
  export { default as PageHeader } from './components/layout/PageHeader.svelte';
141
143
  export { default as SettingCard } from './components/layout/SettingCard.svelte';
144
+ export { default as SettingItem } from './components/layout/SettingItem.svelte';
142
145
 
143
146
  // ============================================
144
147
  // COMPONENTS - Documentation