feat(web): add system / light / dark theme switcher

The web UI was hardcoded dark. Add a theme preference (system | light |
dark) persisted to localStorage and exposed via a segmented control in the existing Settings panel.
Implementation:
-  hook owns the preference, resolves  against
, and applies  to <html>. When in
 mode it subscribes to OS changes so the UI tracks live.
- An inline boot script in index.html applies the resolved theme before
React mounts, preventing a flash of the wrong theme.
- Light palette overrides only the chrome tokens defined in the
Tailwind v4  block. Brand accent (purple) and graph node
colors are intentionally identical across themes — they're brand /
data viz, not chrome.
- A few hardcoded  and scrollbar colors in  /
 move behind a new  token and existing chrome tokens so they follow the active theme.
This commit is contained in:
r09722050akabennett 2026-04-29 15:02:31 +08:00
parent f1e9919d39
commit c86671c675
5 changed files with 167 additions and 5 deletions

View file

@ -10,6 +10,25 @@
href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Outfit:wght@300;400;500;600;700&display=swap"
rel="stylesheet"
/>
<script>
// Apply persisted theme before paint to avoid FOUC.
(function () {
try {
var stored = localStorage.getItem('gitnexus.theme');
var preference =
stored === 'light' || stored === 'dark' || stored === 'system' ? stored : 'system';
var resolved =
preference === 'system'
? window.matchMedia('(prefers-color-scheme: light)').matches
? 'light'
: 'dark'
: preference;
document.documentElement.dataset.theme = resolved;
} catch (e) {
document.documentElement.dataset.theme = 'dark';
}
})();
</script>
</head>
<body>
<div id="root"></div>

View file

@ -23,6 +23,7 @@ import {
import type { LLMSettings, LLMProvider } from '../core/llm/types';
import { DEFAULT_OLLAMA_BASE_URL } from '../config/ui-constants';
import { ProviderConfigCard } from './settings/ProviderConfigCard';
import { ThemeToggle } from './ThemeToggle';
interface SettingsPanelProps {
isOpen: boolean;
@ -368,6 +369,12 @@ export const SettingsPanel = ({
{/* Content */}
<div className="flex-1 space-y-6 overflow-y-auto p-6">
{/* Appearance */}
<div className="space-y-3">
<label className="block text-sm font-medium text-text-secondary">Appearance</label>
<ThemeToggle />
</div>
{/* Local Server */}
{backendUrl !== undefined && onBackendUrlChange && (
<div className="space-y-3">

View file

@ -0,0 +1,41 @@
import { Monitor, Moon, Sun } from 'lucide-react';
import { useTheme, type ThemePreference } from '../hooks/useTheme';
const OPTIONS: Array<{ value: ThemePreference; label: string; Icon: typeof Sun }> = [
{ value: 'system', label: 'System', Icon: Monitor },
{ value: 'light', label: 'Light', Icon: Sun },
{ value: 'dark', label: 'Dark', Icon: Moon },
];
export const ThemeToggle = () => {
const { preference, setPreference } = useTheme();
return (
<div
role="radiogroup"
aria-label="Theme"
className="grid grid-cols-3 gap-2 rounded-xl border border-border-subtle bg-elevated p-1"
>
{OPTIONS.map(({ value, label, Icon }) => {
const active = preference === value;
return (
<button
key={value}
type="button"
role="radio"
aria-checked={active}
onClick={() => setPreference(value)}
className={`flex items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
active
? 'bg-accent/15 text-text-primary ring-1 ring-accent/40'
: 'text-text-secondary hover:bg-hover hover:text-text-primary'
}`}
>
<Icon className="h-4 w-4" />
<span>{label}</span>
</button>
);
})}
</div>
);
};

View file

@ -0,0 +1,65 @@
import { useCallback, useEffect, useState } from 'react';
export type ThemePreference = 'system' | 'dark' | 'light';
export type ResolvedTheme = 'dark' | 'light';
const STORAGE_KEY = 'gitnexus.theme';
const LIGHT_QUERY = '(prefers-color-scheme: light)';
const isPreference = (v: unknown): v is ThemePreference =>
v === 'system' || v === 'dark' || v === 'light';
const readStoredPreference = (): ThemePreference => {
try {
const v = localStorage.getItem(STORAGE_KEY);
return isPreference(v) ? v : 'system';
} catch {
return 'system';
}
};
const resolve = (pref: ThemePreference): ResolvedTheme => {
if (pref === 'system') {
return window.matchMedia(LIGHT_QUERY).matches ? 'light' : 'dark';
}
return pref;
};
const apply = (resolved: ResolvedTheme) => {
document.documentElement.dataset.theme = resolved;
};
export const useTheme = () => {
const [preference, setPreferenceState] = useState<ThemePreference>(readStoredPreference);
const [resolved, setResolved] = useState<ResolvedTheme>(() => resolve(readStoredPreference()));
useEffect(() => {
const next = resolve(preference);
setResolved(next);
apply(next);
}, [preference]);
// When tracking the OS, follow live changes to the system preference.
useEffect(() => {
if (preference !== 'system') return;
const mq = window.matchMedia(LIGHT_QUERY);
const onChange = () => {
const next: ResolvedTheme = mq.matches ? 'light' : 'dark';
setResolved(next);
apply(next);
};
mq.addEventListener('change', onChange);
return () => mq.removeEventListener('change', onChange);
}, [preference]);
const setPreference = useCallback((p: ThemePreference) => {
try {
localStorage.setItem(STORAGE_KEY, p);
} catch {
// localStorage may be unavailable (private mode); fall through.
}
setPreferenceState(p);
}, []);
return { preference, resolved, setPreference };
};

View file

@ -19,6 +19,8 @@
--color-text-primary: #e4e4ed;
--color-text-secondary: #8888a0;
--color-text-muted: #5a5a70;
/* Higher-contrast text used for emphasis (h1–h4, strong) */
--color-text-strong: #ffffff;
/* Accent */
--color-accent: #7c3aed;
@ -49,16 +51,44 @@
--shadow-glow-soft: 0 0 40px rgba(124, 58, 237, 0.15);
}
/* ═══════════════════════════════════════════════════════════════
LIGHT THEME — overrides applied when html[data-theme='light'].
`@theme` above defines defaults (dark); these win by cascade.
Only chrome tokens flip; node colors and accent stay constant
so graph and brand identity are preserved across themes.
═══════════════════════════════════════════════════════════════ */
[data-theme='light'] {
--color-void: #ffffff;
--color-deep: #f8f9fb;
--color-surface: #ffffff;
--color-elevated: #f3f4f7;
--color-hover: #e9ebf0;
--color-border-subtle: #e5e7eb;
--color-border-default: #d1d5db;
--color-text-primary: #1a1a23;
--color-text-secondary: #525266;
--color-text-muted: #80809a;
--color-text-strong: #0a0a14;
--color-accent: #7c3aed;
--color-accent-dim: #a78bfa;
--shadow-glow: 0 0 20px rgba(124, 58, 237, 0.18);
--shadow-glow-soft: 0 0 40px rgba(124, 58, 237, 0.1);
}
/* Keyframes */
@keyframes breathe {
0%,
100% {
border-color: #2a2a3a;
border-color: var(--color-border-default);
box-shadow: 0 0 0 0 rgba(124, 58, 237, 0.3);
}
50% {
border-color: #7c3aed;
border-color: var(--color-accent);
box-shadow: 0 0 40px 10px rgba(124, 58, 237, 0.3);
}
}
@ -149,7 +179,7 @@ body {
═══════════════════════════════════════════════════════════════ */
.scrollbar-thin {
scrollbar-width: thin;
scrollbar-color: #2a2a3a #0a0a10;
scrollbar-color: var(--color-border-default) var(--color-deep);
}
.scrollbar-thin::-webkit-scrollbar {
@ -200,7 +230,7 @@ body {
.chat-prose strong {
font-weight: 600;
color: #fff;
color: var(--color-text-strong);
}
.chat-prose em {
@ -214,7 +244,7 @@ body {
.chat-prose h3,
.chat-prose h4 {
font-weight: 600;
color: #fff;
color: var(--color-text-strong);
margin-top: 1.5em;
margin-bottom: 0.5em;
line-height: 1.3;