mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-08 03:08:13 +00:00
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:
parent
f1e9919d39
commit
c86671c675
5 changed files with 167 additions and 5 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
41
gitnexus-web/src/components/ThemeToggle.tsx
Normal file
41
gitnexus-web/src/components/ThemeToggle.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
65
gitnexus-web/src/hooks/useTheme.ts
Normal file
65
gitnexus-web/src/hooks/useTheme.ts
Normal 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 };
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue