@chrysb/alphaclaw 0.8.5 → 0.8.6

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 (30) hide show
  1. package/lib/public/css/explorer.css +48 -0
  2. package/lib/public/css/shell.css +149 -0
  3. package/lib/public/css/tailwind.generated.css +1 -1
  4. package/lib/public/css/theme.css +265 -0
  5. package/lib/public/dist/app.bundle.js +2269 -2200
  6. package/lib/public/js/app.js +4 -0
  7. package/lib/public/js/components/icons.js +38 -0
  8. package/lib/public/js/components/models-tab/provider-auth-card.js +60 -49
  9. package/lib/public/js/components/models-tab/use-models.js +74 -9
  10. package/lib/public/js/components/models.js +52 -37
  11. package/lib/public/js/components/onboarding/use-welcome-codex.js +34 -24
  12. package/lib/public/js/components/onboarding/welcome-config.js +76 -10
  13. package/lib/public/js/components/onboarding/welcome-form-step.js +2 -7
  14. package/lib/public/js/components/onboarding/welcome-header.js +12 -14
  15. package/lib/public/js/components/onboarding/welcome-setup-step.js +3 -3
  16. package/lib/public/js/components/providers.js +53 -42
  17. package/lib/public/js/components/sidebar.js +9 -1
  18. package/lib/public/js/components/theme-toggle.js +113 -0
  19. package/lib/public/js/components/welcome/index.js +0 -2
  20. package/lib/public/js/components/welcome/use-welcome.js +101 -36
  21. package/lib/public/js/lib/codex-oauth-window.js +22 -0
  22. package/lib/public/js/lib/model-catalog.js +20 -0
  23. package/lib/public/js/lib/storage-keys.js +1 -1
  24. package/lib/public/login.html +8 -4
  25. package/lib/public/setup.html +9 -0
  26. package/lib/server/db/webhooks/index.js +48 -8
  27. package/lib/server/model-catalog-cache.js +251 -0
  28. package/lib/server/routes/models.js +14 -23
  29. package/lib/server/routes/webhooks.js +12 -1
  30. package/package.json +1 -1
@@ -11,6 +11,8 @@ export const kGithubFlowImport = "import";
11
11
  export const kGithubTargetRepoModeCreate = "create";
12
12
  export const kGithubTargetRepoModeExistingEmpty = "existing-empty";
13
13
 
14
+ const hasValue = (value) => !!String(value || "").trim();
15
+
14
16
  export const normalizeGithubRepoInput = (repoInput) =>
15
17
  String(repoInput || "")
16
18
  .trim()
@@ -25,6 +27,74 @@ export const isValidGithubRepoInput = (repoInput) => {
25
27
  return parts.length === 2 && !parts.some((part) => /\s/.test(part));
26
28
  };
27
29
 
30
+ const getGithubGroupError = (vals) => {
31
+ const githubFlow = vals._GITHUB_FLOW || kGithubFlowFresh;
32
+ if (!hasValue(vals.GITHUB_TOKEN)) {
33
+ return "Enter a GitHub personal access token to continue.";
34
+ }
35
+ if (!hasValue(vals.GITHUB_WORKSPACE_REPO)) {
36
+ return 'Enter the target repo as "owner/repo".';
37
+ }
38
+ if (!isValidGithubRepoInput(vals.GITHUB_WORKSPACE_REPO)) {
39
+ return 'Target repo must be in "owner/repo" format.';
40
+ }
41
+ if (githubFlow === kGithubFlowImport) {
42
+ if (!hasValue(vals._GITHUB_SOURCE_REPO)) {
43
+ return 'Enter the source repo as "owner/repo".';
44
+ }
45
+ if (!isValidGithubRepoInput(vals._GITHUB_SOURCE_REPO)) {
46
+ return 'Source repo must be in "owner/repo" format.';
47
+ }
48
+ }
49
+ return "";
50
+ };
51
+
52
+ const getAiGroupError = (vals, ctx = {}) => {
53
+ if (!hasValue(vals.MODEL_KEY) || !String(vals.MODEL_KEY).includes("/")) {
54
+ return "Choose a model to continue.";
55
+ }
56
+ if (ctx.selectedProvider === "openai-codex" && ctx.codexLoading) {
57
+ return "Checking Codex OAuth status. Try Next again in a moment.";
58
+ }
59
+ if (!ctx.hasAi) {
60
+ return ctx.selectedProvider === "openai-codex"
61
+ ? "Connect Codex OAuth to continue."
62
+ : "Add credentials for the selected model provider to continue.";
63
+ }
64
+ return "";
65
+ };
66
+
67
+ const getChannelsGroupError = (vals) => {
68
+ const hasTelegram = hasValue(vals.TELEGRAM_BOT_TOKEN);
69
+ const hasDiscord = hasValue(vals.DISCORD_BOT_TOKEN);
70
+ const hasSlackBot = hasValue(vals.SLACK_BOT_TOKEN);
71
+ const hasSlackApp = hasValue(vals.SLACK_APP_TOKEN);
72
+
73
+ if (hasSlackBot && !hasSlackApp) {
74
+ return "Add the Slack app token to continue with Slack.";
75
+ }
76
+ if (!hasSlackBot && hasSlackApp) {
77
+ return "Add the Slack bot token to continue with Slack.";
78
+ }
79
+ if (!hasTelegram && !hasDiscord && !(hasSlackBot && hasSlackApp)) {
80
+ return "Add at least one channel to continue.";
81
+ }
82
+ return "";
83
+ };
84
+
85
+ export const getWelcomeGroupError = (groupId, vals, ctx = {}) => {
86
+ switch (groupId) {
87
+ case "github":
88
+ return getGithubGroupError(vals);
89
+ case "ai":
90
+ return getAiGroupError(vals, ctx);
91
+ case "channels":
92
+ return getChannelsGroupError(vals);
93
+ default:
94
+ return "";
95
+ }
96
+ };
97
+
28
98
  export const kWelcomeGroups = [
29
99
  {
30
100
  id: "github",
@@ -64,21 +134,14 @@ export const kWelcomeGroups = [
64
134
  placeholder: "ghp_... or github_pat_...",
65
135
  },
66
136
  ],
67
- validate: (vals) => {
68
- const githubFlow = vals._GITHUB_FLOW || kGithubFlowFresh;
69
- const hasTarget = isValidGithubRepoInput(vals.GITHUB_WORKSPACE_REPO);
70
- const hasSource =
71
- githubFlow !== kGithubFlowImport ||
72
- isValidGithubRepoInput(vals._GITHUB_SOURCE_REPO);
73
- return !!(vals.GITHUB_TOKEN && hasTarget && hasSource);
74
- },
137
+ validate: (vals, ctx = {}) => !getWelcomeGroupError("github", vals, ctx),
75
138
  },
76
139
  {
77
140
  id: "ai",
78
141
  title: "Primary Agent Model",
79
142
  description: "Choose your main model and authenticate its provider",
80
143
  fields: kAllAiAuthFields,
81
- validate: (vals, ctx = {}) => !!(vals.MODEL_KEY && ctx.hasAi),
144
+ validate: (vals, ctx = {}) => !getWelcomeGroupError("ai", vals, ctx),
82
145
  },
83
146
  {
84
147
  id: "channels",
@@ -152,7 +215,7 @@ export const kWelcomeGroups = [
152
215
  placeholder: "xapp-...",
153
216
  },
154
217
  ],
155
- validate: (vals) => !!(vals.TELEGRAM_BOT_TOKEN || vals.DISCORD_BOT_TOKEN || (vals.SLACK_BOT_TOKEN && vals.SLACK_APP_TOKEN)),
218
+ validate: (vals, ctx = {}) => !getWelcomeGroupError("channels", vals, ctx),
156
219
  },
157
220
  {
158
221
  id: "tools",
@@ -175,3 +238,6 @@ export const kWelcomeGroups = [
175
238
  validate: () => true,
176
239
  },
177
240
  ];
241
+
242
+ export const findFirstInvalidWelcomeGroup = (vals, ctx = {}) =>
243
+ kWelcomeGroups.find((group) => getWelcomeGroupError(group.id, vals, ctx)) || null;
@@ -50,12 +50,10 @@ export const WelcomeFormStep = ({
50
50
  error,
51
51
  step,
52
52
  totalGroups,
53
- currentGroupValid,
54
53
  goBack,
55
54
  goNext,
56
55
  loading,
57
56
  githubStepLoading,
58
- allValid,
59
57
  handleSubmit,
60
58
  }) => {
61
59
  const [showOptionalOpenai, setShowOptionalOpenai] = useState(false);
@@ -294,13 +292,12 @@ export const WelcomeFormStep = ({
294
292
  />
295
293
  `}
296
294
  </div>
297
- ${!codexStatus.connected &&
298
- codexAuthStarted &&
295
+ ${codexAuthStarted &&
299
296
  html`
300
297
  <div class="space-y-1 pt-1">
301
298
  <p class="text-xs text-fg-muted">
302
299
  ${codexAuthWaiting
303
- ? "Complete login in the popup, then paste the full redirect URL from the address bar (starts with "
300
+ ? "Complete login in the popup. AlphaClaw should finish automatically, but if it doesn't, paste the full redirect URL from the address bar (starts with "
304
301
  : "Paste the full redirect URL from the address bar (starts with "}
305
302
  <code class="text-xs bg-field px-1 rounded"
306
303
  >http://localhost:1455/auth/callback</code
@@ -442,7 +439,6 @@ export const WelcomeFormStep = ({
442
439
  : html`<div class="w-full"></div>`}
443
440
  <${ActionButton}
444
441
  onClick=${goNext}
445
- disabled=${!currentGroupValid}
446
442
  loading=${activeGroup.id === "github" && githubStepLoading}
447
443
  tone="primary"
448
444
  size="md"
@@ -466,7 +462,6 @@ export const WelcomeFormStep = ({
466
462
  : html`<div class="w-full"></div>`}
467
463
  <${ActionButton}
468
464
  onClick=${handleSubmit}
469
- disabled=${!allValid}
470
465
  loading=${loading}
471
466
  tone="primary"
472
467
  size="md"
@@ -20,13 +20,11 @@ export const WelcomeHeader = ({
20
20
 
21
21
  return html`
22
22
  <div class="text-center mb-1">
23
- <img
24
- src="./img/logo.svg"
25
- alt="alphaclaw"
26
- class="mx-auto mb-3"
27
- width="32"
28
- height="33"
29
- />
23
+ <span
24
+ class="ac-logo-mark block mx-auto mb-3"
25
+ style="--ac-logo-width: 32px; --ac-logo-height: 33px;"
26
+ aria-hidden="true"
27
+ ></span>
30
28
  <h1 class="text-2xl font-semibold mb-2">Setup</h1>
31
29
  <p style="color: var(--text-muted)" class="text-sm">
32
30
  Let's get your agent running
@@ -34,7 +32,7 @@ export const WelcomeHeader = ({
34
32
  <div class="mt-4 mb-2 flex items-center justify-center">
35
33
  <span
36
34
  class="text-[11px] px-2.5 py-1 rounded-full border border-border font-medium"
37
- style="background: rgba(0, 0, 0, 0.3); color: var(--text-muted)"
35
+ style="background: var(--field-bg-contrast); color: var(--text-muted)"
38
36
  >
39
37
  ${isPreStep
40
38
  ? "Choose your destiny"
@@ -51,16 +49,16 @@ export const WelcomeHeader = ({
51
49
  const isPairingComplete =
52
50
  idx < step || (isPairingStep && group.id === "pairing");
53
51
  const bg = isPreStep
54
- ? "rgba(82, 94, 122, 0.45)"
52
+ ? "var(--border-strong)"
55
53
  : isActive
56
- ? "rgba(99, 235, 255, 0.9)"
54
+ ? "var(--accent)"
57
55
  : group.id === "pairing"
58
56
  ? isPairingComplete
59
- ? "rgba(99, 235, 255, 0.55)"
60
- : "rgba(82, 94, 122, 0.45)"
57
+ ? "var(--accent-dim)"
58
+ : "var(--border-strong)"
61
59
  : isComplete
62
- ? "rgba(99, 235, 255, 0.55)"
63
- : "rgba(82, 94, 122, 0.45)";
60
+ ? "var(--accent-dim)"
61
+ : "var(--border-strong)";
64
62
  return html`
65
63
  <div
66
64
  class="h-1 flex-1 rounded-full transition-colors ${isActive ? "ac-step-pill-pulse" : ""}"
@@ -45,7 +45,7 @@ export const WelcomeSetupStep = ({ error, loading, onRetry, onBack }) => {
45
45
  if (error) {
46
46
  return html`
47
47
  <div class="py-4 flex flex-col items-center text-center gap-3">
48
- <h3 class="text-lg font-semibold text-white">Setup failed</h3>
48
+ <h3 class="text-lg font-semibold text-body">Setup failed</h3>
49
49
  <p class="text-sm text-fg-muted">Fix the values and try again.</p>
50
50
  </div>
51
51
  <div
@@ -83,8 +83,8 @@ export const WelcomeSetupStep = ({ error, loading, onRetry, onBack }) => {
83
83
  <div
84
84
  class="flex-1 flex flex-col items-center justify-center text-center gap-4"
85
85
  >
86
- <${LoadingSpinner} className="h-8 w-8 text-white" />
87
- <h3 class="text-lg font-semibold text-white">
86
+ <${LoadingSpinner} className="h-8 w-8 text-body" />
87
+ <h3 class="text-lg font-semibold text-body">
88
88
  Initializing OpenClaw...
89
89
  </h3>
90
90
  <p class="text-sm text-fg-muted">This could take 10-15 seconds</p>
@@ -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 = window.open(
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
- if (!codexManualInput.trim() || codexExchanging) return;
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
- ${codexStatus.connected
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
- : !codexAuthStarted
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
- ${!codexStatus.connected && codexAuthStarted
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
@@ -33,6 +33,7 @@ import {
33
33
  getSessionDisplayLabel,
34
34
  getSessionRowKey,
35
35
  } from "../lib/session-keys.js";
36
+ import { ThemeToggle } from "./theme-toggle.js";
36
37
 
37
38
  const html = htm.bind(h);
38
39
  const kBrowseBottomPanelUiSettingKey = "browseBottomPanelHeightPx";
@@ -246,8 +247,14 @@ export const AppSidebar = ({
246
247
  return html`
247
248
  <div class=${`app-sidebar ${mobileSidebarOpen ? "mobile-open" : ""}`}>
248
249
  <div class="sidebar-brand">
249
- <img src="./img/logo.svg" alt="" width="20" height="20" />
250
+ <span
251
+ class="ac-logo-mark"
252
+ style="--ac-logo-width: 20px; --ac-logo-height: 20px;"
253
+ aria-hidden="true"
254
+ ></span>
250
255
  <span><span style="color: var(--accent)">alpha</span>claw</span>
256
+ <span style="margin-left: auto; display: inline-flex; align-items: center; gap: 4px;">
257
+ <${ThemeToggle} />
251
258
  ${authEnabled && html`
252
259
  <${OverflowMenu}
253
260
  open=${menuOpen}
@@ -262,6 +269,7 @@ export const AppSidebar = ({
262
269
  </${OverflowMenuItem}>
263
270
  </${OverflowMenu}>
264
271
  `}
272
+ </span>
265
273
  </div>
266
274
  <div class="sidebar-tabs">
267
275
  <button
@@ -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
+ };
@@ -102,12 +102,10 @@ export const Welcome = ({ onComplete, acVersion }) => {
102
102
  error=${state.formError}
103
103
  step=${state.step}
104
104
  totalGroups=${kWelcomeGroups.length}
105
- currentGroupValid=${state.currentGroupValid}
106
105
  goBack=${actions.goBack}
107
106
  goNext=${actions.goNext}
108
107
  loading=${state.loading}
109
108
  githubStepLoading=${state.githubStepLoading}
110
- allValid=${state.allValid}
111
109
  handleSubmit=${actions.handleSubmit}
112
110
  />
113
111
  `}