@demicodes/web-ui 0.3.1

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 (114) hide show
  1. package/package.json +55 -0
  2. package/src/agent/AgentMessageInput.vue +164 -0
  3. package/src/agent/AgentMessageList.vue +124 -0
  4. package/src/agent/AgentPanel.vue +18 -0
  5. package/src/agent/AgentRoot.vue +17 -0
  6. package/src/agent/AgentTabBar.vue +422 -0
  7. package/src/agent/AgentTabItem.vue +150 -0
  8. package/src/agent/ContextUsageIndicator.vue +136 -0
  9. package/src/agent/ConversationListDropdown.vue +71 -0
  10. package/src/agent/ConversationStatusDot.vue +26 -0
  11. package/src/agent/ConversationView.vue +82 -0
  12. package/src/agent/MessageQueueBar.vue +67 -0
  13. package/src/agent/ModelSelector.vue +125 -0
  14. package/src/agent/ReasoningSelector.vue +106 -0
  15. package/src/agent/SelectorTrigger.vue +17 -0
  16. package/src/agent/__tests__/block-helpers.test.ts +73 -0
  17. package/src/agent/__tests__/input-actions.test.ts +130 -0
  18. package/src/agent/__tests__/input-editor.test.ts +15 -0
  19. package/src/agent/__tests__/pending-steers.test.ts +99 -0
  20. package/src/agent/__tests__/queue-submit.test.ts +14 -0
  21. package/src/agent/__tests__/reasoning.test.ts +32 -0
  22. package/src/agent/__tests__/tail-loading.test.ts +131 -0
  23. package/src/agent/__tests__/tool-rendering.test.ts +35 -0
  24. package/src/agent/__tests__/visible-blocks.test.ts +125 -0
  25. package/src/agent/block-helpers.ts +59 -0
  26. package/src/agent/block-types.ts +12 -0
  27. package/src/agent/blocks/AbortedBlock.vue +26 -0
  28. package/src/agent/blocks/AgentMessageVirtualBlock.vue +88 -0
  29. package/src/agent/blocks/AnsiText.vue +75 -0
  30. package/src/agent/blocks/AssistantTextBlock.vue +34 -0
  31. package/src/agent/blocks/CompactionBlock.vue +36 -0
  32. package/src/agent/blocks/ErrorBlock.vue +79 -0
  33. package/src/agent/blocks/FunctionalBlock.vue +118 -0
  34. package/src/agent/blocks/LoadingBlock.vue +28 -0
  35. package/src/agent/blocks/ThinkingBlock.vue +86 -0
  36. package/src/agent/blocks/ToolCallBlock.vue +40 -0
  37. package/src/agent/blocks/ToolGenericBlock.vue +43 -0
  38. package/src/agent/blocks/ToolShellAbortBlock.vue +13 -0
  39. package/src/agent/blocks/ToolShellBlock.vue +47 -0
  40. package/src/agent/blocks/ToolShellControlBlock.vue +51 -0
  41. package/src/agent/blocks/ToolShellStatusBlock.vue +13 -0
  42. package/src/agent/blocks/ToolShellWriteBlock.vue +13 -0
  43. package/src/agent/blocks/ToolStatusBadge.vue +13 -0
  44. package/src/agent/blocks/ToolYieldBlock.vue +13 -0
  45. package/src/agent/blocks/UserBlock.vue +112 -0
  46. package/src/agent/conversation-runtime.ts +205 -0
  47. package/src/agent/conversation-status.ts +12 -0
  48. package/src/agent/message-input/input-model.ts +33 -0
  49. package/src/agent/message-input/useAgentInputActions.ts +96 -0
  50. package/src/agent/message-input/useAgentInputEditor.ts +90 -0
  51. package/src/agent/message-input/useAgentInputSessionState.ts +48 -0
  52. package/src/agent/pending-steers.ts +134 -0
  53. package/src/agent/providers/ProviderIcon.vue +32 -0
  54. package/src/agent/providers/icons/claude.svg +1 -0
  55. package/src/agent/providers/icons/openai.svg +3 -0
  56. package/src/agent/queue-submit.ts +4 -0
  57. package/src/agent/reasoning.ts +45 -0
  58. package/src/agent/tail-loading.ts +31 -0
  59. package/src/agent/thinking-streaming.ts +7 -0
  60. package/src/agent/tool-rendering.ts +59 -0
  61. package/src/agent/types.ts +39 -0
  62. package/src/agent/ui-options.ts +21 -0
  63. package/src/agent/visible-blocks.ts +25 -0
  64. package/src/agent/workspace.ts +355 -0
  65. package/src/composables/useBlockVirtualizer.ts +290 -0
  66. package/src/composables/useContextMenuOwner.ts +44 -0
  67. package/src/composables/useOverlay.ts +18 -0
  68. package/src/composables/useTerminalTheme.ts +75 -0
  69. package/src/env.d.ts +10 -0
  70. package/src/infra/errors.ts +29 -0
  71. package/src/infra/i18n.ts +41 -0
  72. package/src/markdown/filePath.ts +54 -0
  73. package/src/markdown/highlight.ts +139 -0
  74. package/src/markdown/md.ts +58 -0
  75. package/src/markdown/render.ts +117 -0
  76. package/src/markdown/types.ts +20 -0
  77. package/src/overlay/appOverlay.ts +3 -0
  78. package/src/overlay/overlayStore.ts +54 -0
  79. package/src/store/createStore.ts +33 -0
  80. package/src/store/useStore.ts +7 -0
  81. package/src/styles/base.css +342 -0
  82. package/src/theme/appTheme.ts +56 -0
  83. package/src/theme/codeThemes.ts +454 -0
  84. package/src/theme/themeStore.ts +33 -0
  85. package/src/transport/agent-socket.ts +20 -0
  86. package/src/transport/control-client.ts +64 -0
  87. package/src/transport/protocol.ts +57 -0
  88. package/src/ui/Button.vue +30 -0
  89. package/src/ui/Checkbox.vue +25 -0
  90. package/src/ui/ContextMenu.vue +33 -0
  91. package/src/ui/Dialog.vue +48 -0
  92. package/src/ui/DropdownMenu.vue +77 -0
  93. package/src/ui/ErrorBox.vue +43 -0
  94. package/src/ui/HighlightText.vue +39 -0
  95. package/src/ui/IconButton.vue +30 -0
  96. package/src/ui/IndeterminateSpinner.vue +72 -0
  97. package/src/ui/InlineError.vue +25 -0
  98. package/src/ui/LoadMore.vue +32 -0
  99. package/src/ui/MarkdownPreview.vue +31 -0
  100. package/src/ui/Menu.vue +8 -0
  101. package/src/ui/MenuDivider.vue +6 -0
  102. package/src/ui/MenuItem.vue +48 -0
  103. package/src/ui/OptionMenu.vue +8 -0
  104. package/src/ui/OptionMenuGroup.vue +16 -0
  105. package/src/ui/OptionMenuItem.vue +28 -0
  106. package/src/ui/Popover.vue +90 -0
  107. package/src/ui/ScrollToBottomButton.vue +32 -0
  108. package/src/ui/SelectDropdown.vue +231 -0
  109. package/src/ui/Switch.vue +41 -0
  110. package/src/ui/TextInput.vue +44 -0
  111. package/src/ui/ThemeToggle.vue +49 -0
  112. package/src/ui/ToggleSwitch.vue +34 -0
  113. package/src/ui/Tooltip.vue +143 -0
  114. package/src/ui/blankClickGuard.ts +12 -0
@@ -0,0 +1,342 @@
1
+ @theme {
2
+ --color-surface-base: var(--surface-base);
3
+ --color-surface: var(--surface);
4
+ --color-surface-editor: var(--surface-editor);
5
+ --color-surface-raised: var(--surface-raised);
6
+ --color-overlay: var(--overlay);
7
+ --color-fg-emphasis: var(--fg-emphasis);
8
+ --color-fg: var(--fg);
9
+ --color-fg-body: var(--fg-body);
10
+ --color-fg-muted: var(--fg-muted);
11
+ --color-fg-subtle: var(--fg-subtle);
12
+ --color-fg-faint: var(--fg-faint);
13
+ --color-fg-ghost: var(--fg-ghost);
14
+ --color-hover: var(--hover);
15
+ --color-active: var(--active);
16
+ --color-line: var(--line);
17
+ --color-line-subtle: var(--line-subtle);
18
+ --color-line-strong: var(--line-strong);
19
+ --color-line-focus: var(--line-focus);
20
+ --color-tint-danger: var(--tint-danger);
21
+ --color-tint-danger-strong: var(--tint-danger-strong);
22
+ --color-tint-success: var(--tint-success);
23
+ --color-tint-warning: var(--tint-warning);
24
+ --color-tint-accent: var(--tint-accent);
25
+ --color-tint-highlight: var(--tint-highlight);
26
+ --color-on-danger: var(--on-danger);
27
+ --color-on-danger-muted: var(--on-danger-muted);
28
+ --color-on-success: var(--on-success);
29
+ --color-on-warning: var(--on-warning);
30
+ --color-on-accent: var(--on-accent);
31
+ --color-on-highlight: var(--on-highlight);
32
+ --color-on-violet: var(--on-violet);
33
+ --shadow-sm: var(--th-shadow-sm);
34
+ --shadow-md: var(--th-shadow-md);
35
+ --shadow-lg: var(--th-shadow-lg);
36
+ --shadow-xl: var(--th-shadow-xl);
37
+ --shadow-2xl: var(--th-shadow-2xl);
38
+ }
39
+
40
+ :root,
41
+ [data-theme="dark"] {
42
+ color-scheme: dark;
43
+ --surface-base: #0a0a0a;
44
+ --surface: #171717;
45
+ --surface-editor: #1e1e1e;
46
+ --surface-raised: #262626;
47
+ --overlay: white;
48
+ --fg-emphasis: #f5f5f5;
49
+ --fg: #e5e5e5;
50
+ --fg-body: #d4d4d4;
51
+ --fg-muted: #a3a3a3;
52
+ --fg-subtle: #737373;
53
+ --fg-faint: #525252;
54
+ --fg-ghost: #404040;
55
+ --hover: rgba(255, 255, 255, 0.06);
56
+ --active: rgba(255, 255, 255, 0.10);
57
+ --line-subtle: rgba(255, 255, 255, 0.06);
58
+ --line: rgba(255, 255, 255, 0.08);
59
+ --line-strong: rgba(255, 255, 255, 0.10);
60
+ --line-focus: rgba(255, 255, 255, 0.18);
61
+ --tint-danger: rgba(239, 68, 68, 0.15);
62
+ --tint-danger-strong: rgba(239, 68, 68, 0.25);
63
+ --tint-success: rgba(16, 185, 129, 0.15);
64
+ --tint-warning: rgba(245, 158, 11, 0.15);
65
+ --tint-accent: rgba(59, 130, 246, 0.15);
66
+ --tint-highlight: rgba(234, 179, 8, 0.20);
67
+ --on-danger: #f87171;
68
+ --on-danger-muted: rgba(248, 113, 113, 0.6);
69
+ --on-success: #34d399;
70
+ --on-warning: #fbbf24;
71
+ --on-accent: #60a5fa;
72
+ --on-highlight: #fde68a;
73
+ --on-violet: #a78bfa;
74
+ --th-shadow-sm: 0 1px 3px 0 rgba(0, 0, 0, 0.3), 0 1px 2px -1px rgba(0, 0, 0, 0.3);
75
+ --th-shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -2px rgba(0, 0, 0, 0.3);
76
+ --th-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.4), 0 4px 6px -4px rgba(0, 0, 0, 0.4);
77
+ --th-shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.4), 0 8px 10px -6px rgba(0, 0, 0, 0.3);
78
+ --th-shadow-2xl: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
79
+ --shadow-float: 0 -8px 24px rgba(0, 0, 0, 0.12);
80
+ --shadow-btn: 0 0 0 1px rgba(255, 255, 255, 0.10), 0 1px 2px rgba(0, 0, 0, 0.4), 0 2px 6px rgba(0, 0, 0, 0.15);
81
+ --shadow-btn-hover: 0 0 0 1px rgba(255, 255, 255, 0.15), 0 1px 3px rgba(0, 0, 0, 0.5), 0 3px 8px rgba(0, 0, 0, 0.2);
82
+ --btn-bg: rgba(255, 255, 255, 0.06);
83
+ --btn-bg-hover: rgba(255, 255, 255, 0.10);
84
+ }
85
+
86
+ [data-theme="light"] {
87
+ color-scheme: light;
88
+ --surface-base: #f0f1f5;
89
+ --surface: #f9fafb;
90
+ --surface-editor: #f9fafb;
91
+ --surface-raised: #edeef3;
92
+ --overlay: black;
93
+ --fg-emphasis: #1a1d24;
94
+ --fg: #2e3138;
95
+ --fg-body: #404550;
96
+ --fg-muted: #626873;
97
+ --fg-subtle: #8b919c;
98
+ --fg-faint: #b4b9c2;
99
+ --fg-ghost: #dadde3;
100
+ --hover: rgba(0, 0, 0, 0.06);
101
+ --active: rgba(0, 0, 0, 0.10);
102
+ --line-subtle: rgba(0, 0, 0, 0.05);
103
+ --line: rgba(0, 0, 0, 0.09);
104
+ --line-strong: rgba(0, 0, 0, 0.13);
105
+ --line-focus: rgba(0, 0, 0, 0.28);
106
+ --tint-danger: rgba(220, 38, 38, 0.08);
107
+ --tint-danger-strong: rgba(220, 38, 38, 0.12);
108
+ --tint-success: rgba(22, 163, 74, 0.08);
109
+ --tint-warning: rgba(217, 119, 6, 0.08);
110
+ --tint-accent: rgba(37, 99, 235, 0.08);
111
+ --tint-highlight: rgba(202, 138, 4, 0.12);
112
+ --on-danger: #dc2626;
113
+ --on-danger-muted: rgba(220, 38, 38, 0.6);
114
+ --on-success: #16a34a;
115
+ --on-warning: #d97706;
116
+ --on-accent: #2563eb;
117
+ --on-highlight: #92400e;
118
+ --on-violet: #7c3aed;
119
+ --th-shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
120
+ --th-shadow-md: 0 3px 10px rgba(0, 0, 0, 0.06), 0 1px 4px rgba(0, 0, 0, 0.04);
121
+ --th-shadow-lg: 0 6px 20px rgba(0, 0, 0, 0.06), 0 2px 8px rgba(0, 0, 0, 0.03);
122
+ --th-shadow-xl: 0 10px 32px rgba(0, 0, 0, 0.07), 0 4px 12px rgba(0, 0, 0, 0.03);
123
+ --th-shadow-2xl: 0 20px 48px rgba(0, 0, 0, 0.08), 0 8px 20px rgba(0, 0, 0, 0.04);
124
+ --shadow-float: 0 -4px 16px rgba(0, 0, 0, 0.03);
125
+ --shadow-btn: 0 0 0 1px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.05), 0 0 0 0.5px rgba(0, 0, 0, 0.04);
126
+ --shadow-btn-hover: 0 0 0 1px rgba(0, 0, 0, 0.12), 0 1px 3px rgba(0, 0, 0, 0.08), 0 0 0 0.5px rgba(0, 0, 0, 0.04);
127
+ --btn-bg: #ffffff;
128
+ --btn-bg-hover: #f7f8fa;
129
+ }
130
+
131
+ html,
132
+ body,
133
+ #app {
134
+ overflow: hidden;
135
+ height: 100%;
136
+ -webkit-font-smoothing: auto;
137
+ -moz-osx-font-smoothing: auto;
138
+ }
139
+
140
+ * {
141
+ scrollbar-width: thin;
142
+ scrollbar-color: color-mix(in srgb, var(--color-overlay) 12%, transparent) transparent;
143
+ }
144
+
145
+ .scrollbar-hidden {
146
+ scrollbar-color: transparent transparent;
147
+ transition: scrollbar-color 0.3s ease;
148
+ }
149
+
150
+ .scrollbar-hidden.scrollbar-active {
151
+ scrollbar-color: color-mix(in srgb, var(--color-overlay) 12%, transparent) transparent;
152
+ }
153
+
154
+ .thinking-shimmer {
155
+ background: linear-gradient(
156
+ 100deg,
157
+ rgb(105 105 105) 0%,
158
+ rgb(105 105 105) 38%,
159
+ rgb(250 250 250) 50%,
160
+ rgb(105 105 105) 62%,
161
+ rgb(105 105 105) 100%
162
+ );
163
+ background-size: 200% 100%;
164
+ background-clip: text;
165
+ -webkit-background-clip: text;
166
+ -webkit-text-fill-color: transparent;
167
+ animation: thinking-shimmer 1.6s ease-in-out infinite;
168
+ }
169
+
170
+ @keyframes thinking-shimmer {
171
+ 0% { background-position: 100% 0; }
172
+ 100% { background-position: -100% 0; }
173
+ }
174
+
175
+ .markdown-body > *:first-child {
176
+ margin-top: 0;
177
+ }
178
+
179
+ .markdown-body > *:last-child {
180
+ margin-bottom: 0;
181
+ }
182
+
183
+ .markdown-body p {
184
+ margin: 0.5em 0;
185
+ }
186
+
187
+ .markdown-body strong {
188
+ font-weight: 600;
189
+ color: var(--fg-body);
190
+ }
191
+
192
+ .markdown-body a {
193
+ color: color-mix(in srgb, var(--color-overlay) 70%, transparent);
194
+ text-decoration: underline;
195
+ text-decoration-style: dotted;
196
+ text-decoration-color: color-mix(in srgb, var(--color-overlay) 25%, transparent);
197
+ text-underline-offset: 2px;
198
+ transition: color 0.15s, text-decoration-color 0.15s;
199
+ }
200
+
201
+ .markdown-body a:hover {
202
+ color: var(--color-overlay);
203
+ text-decoration-color: color-mix(in srgb, var(--color-overlay) 50%, transparent);
204
+ }
205
+
206
+ .markdown-body a.file-link {
207
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
208
+ font-size: 0.85em;
209
+ color: rgb(160 200 255);
210
+ background: rgb(160 200 255 / 0.12);
211
+ padding: 0.15em 0.35em;
212
+ border-radius: 4px;
213
+ text-decoration: underline;
214
+ text-decoration-style: dotted;
215
+ text-decoration-color: transparent;
216
+ cursor: pointer;
217
+ }
218
+
219
+ .markdown-body a.file-link:hover {
220
+ color: rgb(190 220 255);
221
+ background: rgb(160 200 255 / 0.18);
222
+ text-decoration: underline;
223
+ text-decoration-style: dotted;
224
+ text-decoration-color: rgb(160 200 255 / 0.4);
225
+ }
226
+
227
+ .markdown-body code {
228
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
229
+ font-size: 0.85em;
230
+ background: color-mix(in srgb, var(--color-overlay) 8%, transparent);
231
+ padding: 0.15em 0.35em;
232
+ border-radius: 4px;
233
+ }
234
+
235
+ .markdown-body pre {
236
+ margin: 0.75em 0;
237
+ padding: 0.75em 1em;
238
+ background: color-mix(in srgb, var(--color-overlay) 4%, transparent);
239
+ border-radius: 8px;
240
+ overflow-x: auto;
241
+ }
242
+
243
+ .markdown-body pre code {
244
+ background: none;
245
+ padding: 0;
246
+ font-size: 0.85em;
247
+ line-height: 1.6;
248
+ }
249
+
250
+ .markdown-body ul,
251
+ .markdown-body ol {
252
+ margin: 0.5em 0;
253
+ padding-inline-start: 1.5em;
254
+ }
255
+
256
+ .markdown-body ul {
257
+ list-style-type: disc;
258
+ }
259
+
260
+ .markdown-body ol {
261
+ list-style-type: decimal;
262
+ }
263
+
264
+ .markdown-body li {
265
+ margin: 0.25em 0;
266
+ }
267
+
268
+ .markdown-body blockquote {
269
+ margin: 0.5em 0;
270
+ padding-inline-start: 1em;
271
+ border-inline-start: 3px solid color-mix(in srgb, var(--color-overlay) 15%, transparent);
272
+ color: var(--fg-muted);
273
+ }
274
+
275
+ .markdown-body hr {
276
+ margin: 1em 0;
277
+ border: none;
278
+ border-top: 1px solid color-mix(in srgb, var(--color-overlay) 10%, transparent);
279
+ }
280
+
281
+ .markdown-body h1,
282
+ .markdown-body h2,
283
+ .markdown-body h3 {
284
+ font-weight: 600;
285
+ color: var(--fg-body);
286
+ margin: 0.75em 0 0.25em;
287
+ text-wrap: balance;
288
+ }
289
+
290
+ .markdown-body h1 { font-size: 1.25em; }
291
+ .markdown-body h2 { font-size: 1.125em; }
292
+ .markdown-body h3 { font-size: 1em; }
293
+
294
+ .markdown-body table {
295
+ width: 100%;
296
+ border-collapse: collapse;
297
+ margin: 0.75em 0;
298
+ }
299
+
300
+ .markdown-body th,
301
+ .markdown-body td {
302
+ padding: 0.4em 0.75em;
303
+ border: 1px solid var(--fg-ghost);
304
+ }
305
+
306
+ .markdown-body th {
307
+ font-weight: 600;
308
+ background-color: color-mix(in srgb, var(--color-overlay) 4%, transparent);
309
+ }
310
+
311
+ .markdown-body li:has(> input[type="checkbox"]) {
312
+ list-style: none;
313
+ margin-inline-start: -1.5em;
314
+ }
315
+
316
+ .markdown-body li > input[type="checkbox"] {
317
+ appearance: none;
318
+ width: 14px;
319
+ height: 14px;
320
+ margin-right: 0.4em;
321
+ border: 1.5px solid var(--fg-faint);
322
+ border-radius: 3px;
323
+ background: transparent;
324
+ vertical-align: -2px;
325
+ cursor: pointer;
326
+ }
327
+
328
+ .markdown-body li > input[type="checkbox"]:checked {
329
+ border-color: #519aba;
330
+ background: #519aba;
331
+ background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 14 14' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M3.5 7L6 9.5L10.5 4.5' stroke='%231e1e1e' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
332
+ }
333
+
334
+ .markdown-body li:has(> input[type="checkbox"]:checked) {
335
+ text-decoration: line-through;
336
+ opacity: 0.5;
337
+ }
338
+
339
+ .markdown-body img {
340
+ max-width: 100%;
341
+ border-radius: 0.375rem;
342
+ }
@@ -0,0 +1,56 @@
1
+ import { computed, type ComputedRef } from 'vue'
2
+ import { createThemeStore, type ThemeStoreState } from './themeStore'
3
+
4
+ type ThemeMode = ThemeStoreState['mode']
5
+
6
+ const STORAGE_KEY = 'demi-theme-mode'
7
+
8
+ function systemPrefersLight(): boolean {
9
+ return (
10
+ typeof window !== 'undefined' &&
11
+ typeof window.matchMedia === 'function' &&
12
+ window.matchMedia('(prefers-color-scheme: light)').matches
13
+ )
14
+ }
15
+
16
+ function storedMode(): ThemeMode | null {
17
+ if (typeof localStorage === 'undefined') return null
18
+ const value = localStorage.getItem(STORAGE_KEY)
19
+ return value === 'light' || value === 'dark' ? value : null
20
+ }
21
+
22
+ /** Initial mode: an explicit saved choice, else the OS preference, else dark. */
23
+ function initialMode(): ThemeMode {
24
+ return storedMode() ?? (systemPrefersLight() ? 'light' : 'dark')
25
+ }
26
+
27
+ export const appThemeStore = createThemeStore({ mode: initialMode() })
28
+
29
+ export function useTheme(): { theme: ComputedRef<ThemeMode> } {
30
+ return { theme: computed(() => appThemeStore.state.mode) }
31
+ }
32
+
33
+ /** Set the theme and remember the choice (so it survives reloads and stops following the OS). */
34
+ export function setTheme(mode: ThemeMode): void {
35
+ appThemeStore.setMode(mode)
36
+ if (typeof localStorage !== 'undefined') localStorage.setItem(STORAGE_KEY, mode)
37
+ }
38
+
39
+ /** Flip between light and dark, remembering the choice. */
40
+ export function toggleTheme(): void {
41
+ setTheme(appThemeStore.state.mode === 'dark' ? 'light' : 'dark')
42
+ }
43
+
44
+ /** Mirror the active mode onto `<html data-theme>`, and follow the OS until the user chooses explicitly. */
45
+ export function applyThemeToDocument(): void {
46
+ const apply = (): void => document.documentElement.setAttribute('data-theme', appThemeStore.state.mode)
47
+ apply()
48
+ appThemeStore.subscribe(apply)
49
+
50
+ if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') {
51
+ window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', (event) => {
52
+ if (storedMode()) return // user made an explicit choice — don't override it
53
+ appThemeStore.setMode(event.matches ? 'light' : 'dark')
54
+ })
55
+ }
56
+ }