@chrysb/alphaclaw 0.9.0-beta.7 → 0.9.1-beta.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.
- package/bin/alphaclaw.js +26 -25
- package/lib/cli/git-runtime.js +97 -0
- package/lib/public/css/chat.css +0 -12
- package/lib/public/css/explorer.css +48 -0
- package/lib/public/css/shell.css +149 -0
- package/lib/public/css/tailwind.generated.css +1 -1
- package/lib/public/css/theme.css +265 -0
- package/lib/public/dist/app.bundle.js +2770 -2762
- package/lib/public/js/app.js +26 -14
- package/lib/public/js/components/agents-tab/create-channel-modal.js +259 -59
- package/lib/public/js/components/gateway.js +0 -286
- package/lib/public/js/components/general/index.js +0 -7
- package/lib/public/js/components/icons.js +26 -25
- package/lib/public/js/components/modal-shell.js +1 -1
- package/lib/public/js/components/models-tab/provider-auth-card.js +60 -49
- package/lib/public/js/components/models-tab/use-models.js +74 -9
- package/lib/public/js/components/models.js +52 -37
- package/lib/public/js/components/onboarding/use-welcome-codex.js +34 -24
- package/lib/public/js/components/onboarding/welcome-config.js +76 -10
- package/lib/public/js/components/onboarding/welcome-form-step.js +2 -7
- package/lib/public/js/components/onboarding/welcome-header.js +12 -14
- package/lib/public/js/components/onboarding/welcome-setup-step.js +3 -3
- package/lib/public/js/components/providers.js +53 -42
- package/lib/public/js/components/routes/chat-route.js +2 -9
- package/lib/public/js/components/routes/general-route.js +0 -6
- package/lib/public/js/components/routes/index.js +0 -1
- package/lib/public/js/components/routes/watchdog-route.js +0 -6
- package/lib/public/js/components/sidebar.js +21 -7
- package/lib/public/js/components/theme-toggle.js +113 -0
- package/lib/public/js/components/update-modal.js +174 -51
- package/lib/public/js/components/watchdog-tab/index.js +0 -6
- package/lib/public/js/components/welcome/index.js +0 -2
- package/lib/public/js/components/welcome/use-welcome.js +107 -36
- package/lib/public/js/hooks/use-app-shell-controller.js +16 -33
- package/lib/public/js/lib/api.js +0 -28
- package/lib/public/js/lib/app-navigation.js +0 -2
- package/lib/public/js/lib/channel-provider-availability.js +1 -2
- package/lib/public/js/lib/codex-oauth-window.js +22 -0
- package/lib/public/js/lib/model-catalog.js +31 -0
- package/lib/public/js/lib/storage-keys.js +1 -1
- package/lib/public/login.html +8 -4
- package/lib/public/setup.html +9 -0
- package/lib/scripts/git +110 -16
- package/lib/server/agents/channels.js +1 -4
- package/lib/server/alphaclaw-version.js +590 -132
- package/lib/server/constants.js +5 -0
- package/lib/server/db/webhooks/index.js +48 -8
- package/lib/server/exec-defaults-config.js +163 -0
- package/lib/server/gateway.js +1 -0
- package/lib/server/init/register-server-routes.js +0 -8
- package/lib/server/init/server-lifecycle.js +2 -0
- package/lib/server/model-catalog-cache.js +251 -0
- package/lib/server/onboarding/github.js +83 -2
- package/lib/server/onboarding/index.js +7 -0
- package/lib/server/routes/models.js +14 -23
- package/lib/server/routes/nodes.js +9 -23
- package/lib/server/routes/system.js +3 -16
- package/lib/server/routes/webhooks.js +12 -1
- package/lib/server/startup.js +8 -0
- package/lib/server/watchdog-notify.js +172 -55
- package/lib/server.js +17 -2
- package/lib/setup/core-prompts/AGENTS.md +12 -0
- package/lib/setup/core-prompts/TOOLS.md +12 -0
- package/package.json +2 -2
- package/patches/openclaw+2026.4.9.patch +13 -0
- package/lib/public/js/components/mcp-tab/index.js +0 -237
- package/lib/public/js/components/routes/mcp-route.js +0 -7
- package/lib/server/mcp-bridge.js +0 -158
- package/lib/server/routes/mcp.js +0 -292
- package/patches/openclaw+2026.3.28.patch +0 -13
|
@@ -27,6 +27,10 @@ import {
|
|
|
27
27
|
kProviderFeatures,
|
|
28
28
|
kCoreProviders,
|
|
29
29
|
} from "../lib/model-config.js";
|
|
30
|
+
import {
|
|
31
|
+
isCodexAuthCallbackMessage,
|
|
32
|
+
openCodexAuthWindow,
|
|
33
|
+
} from "../lib/codex-oauth-window.js";
|
|
30
34
|
|
|
31
35
|
const html = htm.bind(h);
|
|
32
36
|
|
|
@@ -89,6 +93,7 @@ export const Providers = ({ onRestartRequired = () => {} }) => {
|
|
|
89
93
|
() => kProvidersTabCache?.savedAiValues || {},
|
|
90
94
|
);
|
|
91
95
|
const [showMoreProviders, setShowMoreProviders] = useState(false);
|
|
96
|
+
const codexExchangeInFlightRef = useRef(false);
|
|
92
97
|
const codexPopupPollRef = useRef(null);
|
|
93
98
|
|
|
94
99
|
const refresh = async () => {
|
|
@@ -171,11 +176,37 @@ export const Providers = ({ onRestartRequired = () => {} }) => {
|
|
|
171
176
|
[],
|
|
172
177
|
);
|
|
173
178
|
|
|
179
|
+
const submitCodexAuthInput = async (input) => {
|
|
180
|
+
const normalizedInput = String(input || "").trim();
|
|
181
|
+
if (!normalizedInput || codexExchangeInFlightRef.current) return;
|
|
182
|
+
codexExchangeInFlightRef.current = true;
|
|
183
|
+
setCodexManualInput(normalizedInput);
|
|
184
|
+
setCodexExchanging(true);
|
|
185
|
+
try {
|
|
186
|
+
const result = await exchangeCodexOAuth(normalizedInput);
|
|
187
|
+
if (!result.ok)
|
|
188
|
+
throw new Error(result.error || "Codex OAuth exchange failed");
|
|
189
|
+
setCodexManualInput("");
|
|
190
|
+
showToast("Codex connected", "success");
|
|
191
|
+
setCodexAuthStarted(false);
|
|
192
|
+
setCodexAuthWaiting(false);
|
|
193
|
+
await refreshCodexConnection();
|
|
194
|
+
} catch (err) {
|
|
195
|
+
setCodexAuthWaiting(false);
|
|
196
|
+
showToast(err.message || "Codex OAuth exchange failed", "error");
|
|
197
|
+
} finally {
|
|
198
|
+
codexExchangeInFlightRef.current = false;
|
|
199
|
+
setCodexExchanging(false);
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
|
|
174
203
|
useEffect(() => {
|
|
175
204
|
const onMessage = async (e) => {
|
|
176
205
|
if (e.data?.codex === "success") {
|
|
177
206
|
showToast("Codex connected", "success");
|
|
178
207
|
await refreshCodexConnection();
|
|
208
|
+
} else if (isCodexAuthCallbackMessage(e.data)) {
|
|
209
|
+
await submitCodexAuthInput(e.data.input);
|
|
179
210
|
} else if (e.data?.codex === "error") {
|
|
180
211
|
showToast(
|
|
181
212
|
`Codex auth failed: ${e.data.message || "unknown error"}`,
|
|
@@ -185,7 +216,7 @@ export const Providers = ({ onRestartRequired = () => {} }) => {
|
|
|
185
216
|
};
|
|
186
217
|
window.addEventListener("message", onMessage);
|
|
187
218
|
return () => window.removeEventListener("message", onMessage);
|
|
188
|
-
}, []);
|
|
219
|
+
}, [submitCodexAuthInput]);
|
|
189
220
|
|
|
190
221
|
const setEnvValue = (key, value) => {
|
|
191
222
|
setEnvVars((prev) => {
|
|
@@ -296,14 +327,9 @@ export const Providers = ({ onRestartRequired = () => {} }) => {
|
|
|
296
327
|
if (codexStatus.connected) return;
|
|
297
328
|
setCodexAuthStarted(true);
|
|
298
329
|
setCodexAuthWaiting(true);
|
|
299
|
-
const popup =
|
|
300
|
-
"/auth/codex/start",
|
|
301
|
-
"codex-auth",
|
|
302
|
-
"popup=yes,width=640,height=780",
|
|
303
|
-
);
|
|
330
|
+
const popup = openCodexAuthWindow();
|
|
304
331
|
if (!popup || popup.closed) {
|
|
305
332
|
setCodexAuthWaiting(false);
|
|
306
|
-
window.location.href = "/auth/codex/start";
|
|
307
333
|
return;
|
|
308
334
|
}
|
|
309
335
|
if (codexPopupPollRef.current) {
|
|
@@ -319,22 +345,7 @@ export const Providers = ({ onRestartRequired = () => {} }) => {
|
|
|
319
345
|
};
|
|
320
346
|
|
|
321
347
|
const completeCodexAuth = async () => {
|
|
322
|
-
|
|
323
|
-
setCodexExchanging(true);
|
|
324
|
-
try {
|
|
325
|
-
const result = await exchangeCodexOAuth(codexManualInput.trim());
|
|
326
|
-
if (!result.ok)
|
|
327
|
-
throw new Error(result.error || "Codex OAuth exchange failed");
|
|
328
|
-
setCodexManualInput("");
|
|
329
|
-
showToast("Codex connected", "success");
|
|
330
|
-
setCodexAuthStarted(false);
|
|
331
|
-
setCodexAuthWaiting(false);
|
|
332
|
-
await refreshCodexConnection();
|
|
333
|
-
} catch (err) {
|
|
334
|
-
showToast(err.message || "Codex OAuth exchange failed", "error");
|
|
335
|
-
} finally {
|
|
336
|
-
setCodexExchanging(false);
|
|
337
|
-
}
|
|
348
|
+
await submitCodexAuthInput(codexManualInput);
|
|
338
349
|
};
|
|
339
350
|
|
|
340
351
|
const handleCodexDisconnect = async () => {
|
|
@@ -385,7 +396,23 @@ export const Providers = ({ onRestartRequired = () => {} }) => {
|
|
|
385
396
|
? html`<${Badge} tone="success">Connected</${Badge}>`
|
|
386
397
|
: html`<${Badge} tone="warning">Not connected</${Badge}>`}
|
|
387
398
|
</div>
|
|
388
|
-
${
|
|
399
|
+
${codexAuthStarted
|
|
400
|
+
? html`
|
|
401
|
+
<div class="flex items-center justify-between gap-2">
|
|
402
|
+
<p class="text-xs text-fg-muted">
|
|
403
|
+
${codexAuthWaiting
|
|
404
|
+
? "Complete login in the popup. AlphaClaw should finish automatically, but you can paste the redirect URL below if it doesn't."
|
|
405
|
+
: "Paste the redirect URL from your browser to finish connecting."}
|
|
406
|
+
</p>
|
|
407
|
+
<button
|
|
408
|
+
onclick=${startCodexAuth}
|
|
409
|
+
class="text-xs font-medium px-3 py-1.5 rounded-lg ac-btn-secondary shrink-0"
|
|
410
|
+
>
|
|
411
|
+
Restart
|
|
412
|
+
</button>
|
|
413
|
+
</div>
|
|
414
|
+
`
|
|
415
|
+
: codexStatus.connected
|
|
389
416
|
? html`
|
|
390
417
|
<div class="flex gap-2">
|
|
391
418
|
<button
|
|
@@ -402,31 +429,15 @@ export const Providers = ({ onRestartRequired = () => {} }) => {
|
|
|
402
429
|
</button>
|
|
403
430
|
</div>
|
|
404
431
|
`
|
|
405
|
-
:
|
|
406
|
-
? html`
|
|
432
|
+
: html`
|
|
407
433
|
<button
|
|
408
434
|
onclick=${startCodexAuth}
|
|
409
435
|
class="text-xs font-medium px-3 py-1.5 rounded-lg ac-btn-cyan"
|
|
410
436
|
>
|
|
411
437
|
Connect Codex OAuth
|
|
412
438
|
</button>
|
|
413
|
-
`
|
|
414
|
-
: html`
|
|
415
|
-
<div class="flex items-center justify-between gap-2">
|
|
416
|
-
<p class="text-xs text-fg-muted">
|
|
417
|
-
${codexAuthWaiting
|
|
418
|
-
? "Complete login in the popup, then paste the redirect URL."
|
|
419
|
-
: "Paste the redirect URL from your browser to finish connecting."}
|
|
420
|
-
</p>
|
|
421
|
-
<button
|
|
422
|
-
onclick=${startCodexAuth}
|
|
423
|
-
class="text-xs font-medium px-3 py-1.5 rounded-lg ac-btn-secondary shrink-0"
|
|
424
|
-
>
|
|
425
|
-
Restart
|
|
426
|
-
</button>
|
|
427
|
-
</div>
|
|
428
439
|
`}
|
|
429
|
-
${
|
|
440
|
+
${codexAuthStarted
|
|
430
441
|
? html`
|
|
431
442
|
<p class="text-xs text-fg-muted">
|
|
432
443
|
After login, copy the full redirect URL (starts with
|
|
@@ -870,15 +870,8 @@ export const ChatRoute = ({ sessions = [], selectedSessionKey = "" }) => {
|
|
|
870
870
|
<div>
|
|
871
871
|
<div class="chat-route-title">Chat</div>
|
|
872
872
|
<div class="chat-route-subtitle">
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
"Pick a session in the sidebar"}</span
|
|
876
|
-
>
|
|
877
|
-
${selectedSessionKey
|
|
878
|
-
? html`<span class="chat-route-session-key" title="Session key"
|
|
879
|
-
>${selectedSessionKey}</span
|
|
880
|
-
>`
|
|
881
|
-
: null}
|
|
873
|
+
${getSessionDisplayLabel(selectedSession) ||
|
|
874
|
+
"Pick a session in the sidebar"}
|
|
882
875
|
</div>
|
|
883
876
|
${connectionError
|
|
884
877
|
? html`<div class="chat-route-warning">${connectionError}</div>`
|
|
@@ -16,9 +16,6 @@ export const GeneralRoute = ({
|
|
|
16
16
|
restartingGateway = false,
|
|
17
17
|
onRestartGateway = () => {},
|
|
18
18
|
restartSignal = 0,
|
|
19
|
-
openclawUpdateInProgress = false,
|
|
20
|
-
onOpenclawVersionActionComplete = () => {},
|
|
21
|
-
onOpenclawUpdate = () => {},
|
|
22
19
|
onRestartRequired = () => {},
|
|
23
20
|
onDismissDoctorWarning = () => {},
|
|
24
21
|
}) => html`
|
|
@@ -37,9 +34,6 @@ export const GeneralRoute = ({
|
|
|
37
34
|
restartingGateway=${restartingGateway}
|
|
38
35
|
onRestartGateway=${onRestartGateway}
|
|
39
36
|
restartSignal=${restartSignal}
|
|
40
|
-
openclawUpdateInProgress=${openclawUpdateInProgress}
|
|
41
|
-
onOpenclawVersionActionComplete=${onOpenclawVersionActionComplete}
|
|
42
|
-
onOpenclawUpdate=${onOpenclawUpdate}
|
|
43
37
|
onRestartRequired=${onRestartRequired}
|
|
44
38
|
onDismissDoctorWarning=${onDismissDoctorWarning}
|
|
45
39
|
/>
|
|
@@ -5,7 +5,6 @@ export { CronRoute } from "./cron-route.js";
|
|
|
5
5
|
export { DoctorRoute } from "./doctor-route.js";
|
|
6
6
|
export { EnvarsRoute } from "./envars-route.js";
|
|
7
7
|
export { GeneralRoute } from "./general-route.js";
|
|
8
|
-
export { McpRoute } from "./mcp-route.js";
|
|
9
8
|
export { ModelsRoute } from "./models-route.js";
|
|
10
9
|
export { NodesRoute } from "./nodes-route.js";
|
|
11
10
|
export { ProvidersRoute } from "./providers-route.js";
|
|
@@ -11,9 +11,6 @@ export const WatchdogRoute = ({
|
|
|
11
11
|
restartingGateway = false,
|
|
12
12
|
onRestartGateway = () => {},
|
|
13
13
|
restartSignal = 0,
|
|
14
|
-
openclawUpdateInProgress = false,
|
|
15
|
-
onOpenclawVersionActionComplete = () => {},
|
|
16
|
-
onOpenclawUpdate = () => {},
|
|
17
14
|
}) => html`
|
|
18
15
|
<div class="pt-4">
|
|
19
16
|
<${WatchdogTab}
|
|
@@ -24,9 +21,6 @@ export const WatchdogRoute = ({
|
|
|
24
21
|
restartingGateway=${restartingGateway}
|
|
25
22
|
onRestartGateway=${onRestartGateway}
|
|
26
23
|
restartSignal=${restartSignal}
|
|
27
|
-
openclawUpdateInProgress=${openclawUpdateInProgress}
|
|
28
|
-
onOpenclawVersionActionComplete=${onOpenclawVersionActionComplete}
|
|
29
|
-
onOpenclawUpdate=${onOpenclawUpdate}
|
|
30
24
|
/>
|
|
31
25
|
</div>
|
|
32
26
|
`;
|
|
@@ -12,11 +12,10 @@ import {
|
|
|
12
12
|
ComputerLineIcon,
|
|
13
13
|
EyeLineIcon,
|
|
14
14
|
FolderLineIcon,
|
|
15
|
-
LinksLineIcon,
|
|
16
15
|
HomeLineIcon,
|
|
17
16
|
PulseLineIcon,
|
|
18
17
|
RobotLineIcon,
|
|
19
|
-
|
|
18
|
+
SignalTowerLineIcon,
|
|
20
19
|
} from "./icons.js";
|
|
21
20
|
import { FileTree } from "./file-tree.js";
|
|
22
21
|
import { OverflowMenu, OverflowMenuItem } from "./overflow-menu.js";
|
|
@@ -34,6 +33,7 @@ import {
|
|
|
34
33
|
getSessionDisplayLabel,
|
|
35
34
|
getSessionRowKey,
|
|
36
35
|
} from "../lib/session-keys.js";
|
|
36
|
+
import { ThemeToggle } from "./theme-toggle.js";
|
|
37
37
|
|
|
38
38
|
const html = htm.bind(h);
|
|
39
39
|
const kBrowseBottomPanelUiSettingKey = "browseBottomPanelHeightPx";
|
|
@@ -58,9 +58,8 @@ const kSidebarNavIconsById = {
|
|
|
58
58
|
watchdog: EyeLineIcon,
|
|
59
59
|
models: Brain2LineIcon,
|
|
60
60
|
envars: BracesLineIcon,
|
|
61
|
-
webhooks:
|
|
61
|
+
webhooks: SignalTowerLineIcon,
|
|
62
62
|
nodes: ComputerLineIcon,
|
|
63
|
-
mcp: LinksLineIcon,
|
|
64
63
|
};
|
|
65
64
|
|
|
66
65
|
const readStoredBrowseBottomPanelHeight = () => {
|
|
@@ -110,7 +109,11 @@ export const AppSidebar = ({
|
|
|
110
109
|
onSelectBrowseFile = () => {},
|
|
111
110
|
onPreviewBrowseFile = () => {},
|
|
112
111
|
acHasUpdate = false,
|
|
112
|
+
acVersion = "",
|
|
113
|
+
acCurrentOpenclawVersion = "",
|
|
113
114
|
acLatest = "",
|
|
115
|
+
acLatestOpenclawVersion = "",
|
|
116
|
+
acUpdateStrategy = null,
|
|
114
117
|
acUpdating = false,
|
|
115
118
|
onAcUpdate = () => {},
|
|
116
119
|
agents = [],
|
|
@@ -248,8 +251,14 @@ export const AppSidebar = ({
|
|
|
248
251
|
return html`
|
|
249
252
|
<div class=${`app-sidebar ${mobileSidebarOpen ? "mobile-open" : ""}`}>
|
|
250
253
|
<div class="sidebar-brand">
|
|
251
|
-
<
|
|
254
|
+
<span
|
|
255
|
+
class="ac-logo-mark"
|
|
256
|
+
style="--ac-logo-width: 20px; --ac-logo-height: 20px;"
|
|
257
|
+
aria-hidden="true"
|
|
258
|
+
></span>
|
|
252
259
|
<span><span style="color: var(--accent)">alpha</span>claw</span>
|
|
260
|
+
<span style="margin-left: auto; display: inline-flex; align-items: center; gap: 4px;">
|
|
261
|
+
<${ThemeToggle} />
|
|
253
262
|
${authEnabled && html`
|
|
254
263
|
<${OverflowMenu}
|
|
255
264
|
open=${menuOpen}
|
|
@@ -264,6 +273,7 @@ export const AppSidebar = ({
|
|
|
264
273
|
</${OverflowMenuItem}>
|
|
265
274
|
</${OverflowMenu}>
|
|
266
275
|
`}
|
|
276
|
+
</span>
|
|
267
277
|
</div>
|
|
268
278
|
<div class="sidebar-tabs">
|
|
269
279
|
<button
|
|
@@ -351,13 +361,13 @@ export const AppSidebar = ({
|
|
|
351
361
|
`,
|
|
352
362
|
)}
|
|
353
363
|
<div class="sidebar-footer">
|
|
354
|
-
${acHasUpdate
|
|
364
|
+
${acHasUpdate
|
|
355
365
|
? html`
|
|
356
366
|
<${UpdateActionButton}
|
|
357
367
|
onClick=${() => setUpdateModalOpen(true)}
|
|
358
368
|
loading=${acUpdating}
|
|
359
369
|
warning=${true}
|
|
360
|
-
idleLabel
|
|
370
|
+
idleLabel="Update available"
|
|
361
371
|
loadingLabel="Updating..."
|
|
362
372
|
className="w-full justify-center"
|
|
363
373
|
/>
|
|
@@ -487,7 +497,11 @@ export const AppSidebar = ({
|
|
|
487
497
|
if (acUpdating) return;
|
|
488
498
|
setUpdateModalOpen(false);
|
|
489
499
|
}}
|
|
500
|
+
currentVersion=${acVersion}
|
|
501
|
+
currentOpenclawVersion=${acCurrentOpenclawVersion}
|
|
490
502
|
version=${acLatest}
|
|
503
|
+
latestOpenclawVersion=${acLatestOpenclawVersion}
|
|
504
|
+
updateStrategy=${acUpdateStrategy}
|
|
491
505
|
onUpdate=${onAcUpdate}
|
|
492
506
|
updating=${acUpdating}
|
|
493
507
|
/>
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { h } from "preact";
|
|
2
|
+
import { useEffect, useRef, useState } from "preact/hooks";
|
|
3
|
+
import htm from "htm";
|
|
4
|
+
import { ComputerLineIcon, MoonIcon, SunIcon } from "./icons.js";
|
|
5
|
+
import { kThemeStorageKey } from "../lib/storage-keys.js";
|
|
6
|
+
|
|
7
|
+
const html = htm.bind(h);
|
|
8
|
+
|
|
9
|
+
const kOptions = [
|
|
10
|
+
{ id: "dark", label: "Dark", Icon: MoonIcon },
|
|
11
|
+
{ id: "light", label: "Light", Icon: SunIcon },
|
|
12
|
+
{ id: "system", label: "System", Icon: ComputerLineIcon },
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
/** Map a preference to the icon component shown on the trigger button. */
|
|
16
|
+
const kPrefIcon = { dark: MoonIcon, light: SunIcon, system: ComputerLineIcon };
|
|
17
|
+
|
|
18
|
+
/** Resolve a preference string to an effective "dark" | "light" value. */
|
|
19
|
+
const resolveEffective = (pref) => {
|
|
20
|
+
if (pref === "dark" || pref === "light") return pref;
|
|
21
|
+
try {
|
|
22
|
+
return window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark";
|
|
23
|
+
} catch {
|
|
24
|
+
return "dark";
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** Read the stored preference. Falls back to "dark" (not OS). */
|
|
29
|
+
const readPreference = () => {
|
|
30
|
+
try {
|
|
31
|
+
const saved = localStorage.getItem(kThemeStorageKey);
|
|
32
|
+
if (saved === "dark" || saved === "light" || saved === "system") return saved;
|
|
33
|
+
} catch {}
|
|
34
|
+
return "dark";
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const applyEffective = (effective) => {
|
|
38
|
+
document.documentElement.dataset.theme = effective;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const savePreference = (pref) => {
|
|
42
|
+
try { localStorage.setItem(kThemeStorageKey, pref); } catch {}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const ThemeToggle = () => {
|
|
46
|
+
const [pref, setPref] = useState(readPreference);
|
|
47
|
+
const [open, setOpen] = useState(false);
|
|
48
|
+
const menuRef = useRef(null);
|
|
49
|
+
|
|
50
|
+
// Apply effective theme whenever preference changes (and listen for OS changes when "system").
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
applyEffective(resolveEffective(pref));
|
|
53
|
+
|
|
54
|
+
if (pref !== "system") return;
|
|
55
|
+
|
|
56
|
+
const mql = window.matchMedia("(prefers-color-scheme: light)");
|
|
57
|
+
const onChange = () => applyEffective(resolveEffective("system"));
|
|
58
|
+
mql.addEventListener("change", onChange);
|
|
59
|
+
return () => mql.removeEventListener("change", onChange);
|
|
60
|
+
}, [pref]);
|
|
61
|
+
|
|
62
|
+
// Close dropdown on outside click.
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (!open) return;
|
|
65
|
+
const handler = (e) => {
|
|
66
|
+
if (menuRef.current && !menuRef.current.contains(e.target)) setOpen(false);
|
|
67
|
+
};
|
|
68
|
+
window.addEventListener("click", handler, true);
|
|
69
|
+
return () => window.removeEventListener("click", handler, true);
|
|
70
|
+
}, [open]);
|
|
71
|
+
|
|
72
|
+
const select = (id) => {
|
|
73
|
+
setPref(id);
|
|
74
|
+
savePreference(id);
|
|
75
|
+
applyEffective(resolveEffective(id));
|
|
76
|
+
setOpen(false);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const TriggerIcon = kPrefIcon[pref] || MoonIcon;
|
|
80
|
+
|
|
81
|
+
return html`
|
|
82
|
+
<div
|
|
83
|
+
ref=${menuRef}
|
|
84
|
+
class="theme-toggle-menu"
|
|
85
|
+
>
|
|
86
|
+
<button
|
|
87
|
+
type="button"
|
|
88
|
+
onclick=${() => setOpen((o) => !o)}
|
|
89
|
+
title="Theme"
|
|
90
|
+
aria-label="Toggle theme"
|
|
91
|
+
aria-expanded=${open}
|
|
92
|
+
class="theme-toggle-trigger"
|
|
93
|
+
>
|
|
94
|
+
<${TriggerIcon} className="w-3.5 h-3.5" />
|
|
95
|
+
</button>
|
|
96
|
+
${open && html`
|
|
97
|
+
<div class="theme-toggle-dropdown">
|
|
98
|
+
${kOptions.map(({ id, label, Icon }) => html`
|
|
99
|
+
<button
|
|
100
|
+
key=${id}
|
|
101
|
+
type="button"
|
|
102
|
+
class="theme-toggle-option ${pref === id ? "active" : ""}"
|
|
103
|
+
onclick=${() => select(id)}
|
|
104
|
+
>
|
|
105
|
+
<${Icon} className="w-3.5 h-3.5" />
|
|
106
|
+
<span>${label}</span>
|
|
107
|
+
</button>
|
|
108
|
+
`)}
|
|
109
|
+
</div>
|
|
110
|
+
`}
|
|
111
|
+
</div>
|
|
112
|
+
`;
|
|
113
|
+
};
|