Skip to content

Commit 907ee15

Browse files
committed
feat(web): add light/dark theme toggle
Adds a theme switcher to the Web UI with light and dark mode support. Defaults to dark (preserving existing appearance). User preference is persisted in localStorage and applied before React hydrates to prevent flash of wrong theme. Changes: - index.css: Add :root:not(.dark) light theme variables, rename :root to :root.dark for dark theme - index.html: Add inline script to read localStorage and set .dark class before first paint (flash prevention) - useTheme.ts: New hook that toggles .dark class on <html> and persists to localStorage - TopNav.tsx: Add Sun/Moon toggle button next to version number
1 parent 7bdf931 commit 907ee15

4 files changed

Lines changed: 121 additions & 6 deletions

File tree

packages/web/index.html

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,27 @@
1212
rel="stylesheet"
1313
/>
1414
<style>
15-
body {
15+
/* Prevent flash: apply theme before React hydrates */
16+
html:not(.dark) body {
17+
background-color: #f9f9fb;
18+
}
19+
html.dark body {
1620
background-color: #0f1117;
1721
}
1822
</style>
1923
</head>
2024
<body>
25+
<script>
26+
// Apply saved theme before React renders to prevent flash
27+
(function () {
28+
var theme = localStorage.getItem('archon-theme');
29+
if (theme === 'light') {
30+
document.documentElement.classList.remove('dark');
31+
} else {
32+
document.documentElement.classList.add('dark');
33+
}
34+
})();
35+
</script>
2136
<div id="root"></div>
2237
<script type="module" src="/src/main.tsx"></script>
2338
</body>

packages/web/src/components/layout/TopNav.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { NavLink, Link } from 'react-router';
22
import { useQuery } from '@tanstack/react-query';
3-
import { LayoutDashboard, MessageSquare, Workflow, Settings } from 'lucide-react';
3+
import { LayoutDashboard, MessageSquare, Workflow, Settings, Sun, Moon } from 'lucide-react';
44
import { listDashboardRuns, getUpdateCheck } from '@/lib/api';
55
import { cn } from '@/lib/utils';
6+
import { useTheme } from '@/hooks/useTheme';
67

78
const tabs = [
89
{ to: '/chat', end: false, icon: MessageSquare, label: 'Chat' },
@@ -12,6 +13,7 @@ const tabs = [
1213
] as const;
1314

1415
export function TopNav(): React.ReactElement {
16+
const { theme, toggleTheme } = useTheme();
1517
// We only need `counts.running` — a server-side aggregate independent of
1618
// the `runs` array. `limit: 1` minimises the `runs` payload that the API
1719
// returns alongside the counts (we discard it).
@@ -66,7 +68,15 @@ export function TopNav(): React.ReactElement {
6668
)}
6769
</NavLink>
6870
))}
69-
<span className="ml-auto text-xs text-text-secondary">
71+
<span className="ml-auto flex items-center gap-3 text-xs text-text-secondary">
72+
<button
73+
onClick={toggleTheme}
74+
className="rounded-md p-1.5 text-text-secondary hover:text-text-primary hover:bg-surface-hover transition-colors"
75+
title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
76+
aria-label={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
77+
>
78+
{theme === 'dark' ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
79+
</button>
7080
v{import.meta.env.VITE_APP_VERSION as string}
7181
{updateCheck?.updateAvailable && updateCheck.releaseUrl && (
7282
<a

packages/web/src/hooks/useTheme.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { useState, useEffect, useCallback } from 'react';
2+
3+
type Theme = 'light' | 'dark';
4+
5+
const STORAGE_KEY = 'archon-theme';
6+
7+
function getInitialTheme(): Theme {
8+
if (typeof window === 'undefined') return 'dark';
9+
const stored = localStorage.getItem(STORAGE_KEY);
10+
if (stored === 'light' || stored === 'dark') return stored;
11+
return 'dark';
12+
}
13+
14+
export function useTheme(): { theme: Theme; toggleTheme: () => void } {
15+
const [theme, setTheme] = useState<Theme>(getInitialTheme);
16+
17+
useEffect(() => {
18+
const root = document.documentElement;
19+
if (theme === 'dark') {
20+
root.classList.add('dark');
21+
} else {
22+
root.classList.remove('dark');
23+
}
24+
localStorage.setItem(STORAGE_KEY, theme);
25+
}, [theme]);
26+
27+
const toggleTheme = useCallback(() => {
28+
setTheme(prev => (prev === 'dark' ? 'light' : 'dark'));
29+
}, []);
30+
31+
return { theme, toggleTheme };
32+
}

packages/web/src/index.css

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,66 @@
66

77
@custom-variant dark (&:is(.dark *));
88

9-
/* Dark-only theme - uses our custom palette as root defaults */
10-
:root {
9+
/* Light theme (default when no .dark class) */
10+
:root:not(.dark) {
11+
/* App-specific colors */
12+
--surface: oklch(0.97 0.005 260);
13+
--surface-elevated: oklch(1 0 0);
14+
--border-bright: oklch(0.78 0.005 260);
15+
--text-primary: oklch(0.15 0.01 260);
16+
--text-secondary: oklch(0.42 0.01 260);
17+
--text-tertiary: oklch(0.6 0.01 260);
18+
--accent-hover: oklch(0.52 0.18 250);
19+
--accent-muted: oklch(0.88 0.06 250);
20+
--surface-inset: oklch(0.93 0.005 260);
21+
--surface-hover: oklch(0.94 0.005 260);
22+
--accent-bright: oklch(0.45 0.18 250);
23+
--success: oklch(0.45 0.17 155);
24+
--warning: oklch(0.55 0.18 75);
25+
--error: oklch(0.5 0.2 25);
26+
27+
/* Node type colors */
28+
--node-command: oklch(0.45 0.18 250);
29+
--node-prompt: oklch(0.42 0.19 290);
30+
--node-bash: oklch(0.55 0.18 75);
31+
32+
/* shadcn variables mapped to light theme */
33+
--radius: 0.625rem;
34+
--background: oklch(0.985 0.002 260);
35+
--foreground: oklch(0.15 0.01 260);
36+
--card: oklch(0.97 0.005 260);
37+
--card-foreground: oklch(0.15 0.01 260);
38+
--popover: oklch(1 0 0);
39+
--popover-foreground: oklch(0.15 0.01 260);
40+
--primary: oklch(0.45 0.18 250);
41+
--primary-foreground: oklch(0.98 0.005 260);
42+
--secondary: oklch(0.94 0.005 260);
43+
--secondary-foreground: oklch(0.15 0.01 260);
44+
--muted: oklch(0.94 0.005 260);
45+
--muted-foreground: oklch(0.42 0.01 260);
46+
--accent: oklch(0.92 0.03 250);
47+
--accent-foreground: oklch(0.15 0.01 260);
48+
--destructive: oklch(0.5 0.2 25);
49+
--border: oklch(0.88 0.005 260);
50+
--input: oklch(0.88 0.005 260);
51+
--ring: oklch(0.45 0.18 250);
52+
--chart-1: oklch(0.45 0.18 250);
53+
--chart-2: oklch(0.45 0.17 155);
54+
--chart-3: oklch(0.55 0.18 75);
55+
--chart-4: oklch(0.5 0.2 25);
56+
--chart-5: oklch(0.42 0.18 250);
57+
--sidebar: oklch(0.97 0.005 260);
58+
--sidebar-foreground: oklch(0.15 0.01 260);
59+
--sidebar-primary: oklch(0.45 0.18 250);
60+
--sidebar-primary-foreground: oklch(0.98 0.005 260);
61+
--sidebar-accent: oklch(0.92 0.03 250);
62+
--sidebar-accent-foreground: oklch(0.15 0.01 260);
63+
--sidebar-border: oklch(0.88 0.005 260);
64+
--sidebar-ring: oklch(0.45 0.18 250);
65+
}
66+
67+
/* Dark theme */
68+
:root.dark {
1169
/* App-specific colors */
1270
--surface: oklch(0.18 0.008 260);
1371
--surface-elevated: oklch(0.22 0.01 260);
@@ -146,7 +204,7 @@ body {
146204
font-family: var(--font-sans);
147205
}
148206

149-
/* Scrollbar styling for dark theme */
207+
/* Scrollbar styling */
150208
::-webkit-scrollbar {
151209
width: 8px;
152210
height: 8px;

0 commit comments

Comments
 (0)