This commit is contained in:
Bennett Tai 2026-05-05 12:14:09 +01:00 committed by GitHub
commit 31ddf20ee2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 177 additions and 9 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

@ -269,7 +269,7 @@ export const RightPanel = () => {
<div className="flex items-center gap-2.5 border-b border-border-subtle bg-elevated/50 px-4 py-3">
<div className="ml-auto flex items-center gap-2">
{!isAgentReady && (
<span className="rounded-full border border-amber-500/30 bg-amber-500/15 px-2 py-1 text-[11px] text-amber-300">
<span className="rounded-full border border-amber-500/30 bg-amber-500/15 px-2 py-1 text-[11px] text-amber-700 dark:text-amber-300">
Configure AI
</span>
)}
@ -283,7 +283,7 @@ export const RightPanel = () => {
{/* Status / errors */}
{agentError && (
<div className="flex items-center gap-2 border-b border-rose-500/30 bg-rose-500/10 px-4 py-3 text-sm text-rose-100">
<div className="flex items-center gap-2 border-b border-rose-500/30 bg-rose-500/10 px-4 py-3 text-sm text-rose-700 dark:text-rose-100">
<AlertTriangle className="h-4 w-4" />
<span>{agentError}</span>
</div>
@ -445,7 +445,7 @@ export const RightPanel = () => {
)}
</div>
{!isAgentReady && !isAgentInitializing && (
<div className="mt-2 flex items-center gap-2 text-xs text-amber-200">
<div className="mt-2 flex items-center gap-2 text-xs text-amber-700 dark:text-amber-200">
<AlertTriangle className="h-3.5 w-3.5" />
<span>
{isProviderConfigured()

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">
@ -437,7 +444,7 @@ export const SettingsPanel = ({
</div>
</div>
<div className="rounded-xl border border-amber-500/30 bg-amber-500/10 p-3 text-xs text-amber-200">
<div className="rounded-xl border border-amber-500/30 bg-amber-500/10 p-3 text-xs text-amber-800 dark:text-amber-200">
API keys are stored in session storage and will be cleared when you close this tab.
</div>

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

@ -1,5 +1,11 @@
@import 'tailwindcss';
/* `dark:` modifier follows our explicit theme attribute, not the OS
media query. The boot script and `useTheme` hook always write a
resolved value (`light` | `dark`) to `<html data-theme>`, so this
keeps the variant consistent with the runtime preference. */
@custom-variant dark (&:where([data-theme='dark'], [data-theme='dark'] *));
/*
TAILWIND V4 THEME CONFIGURATION
*/
@ -19,6 +25,8 @@
--color-text-primary: #e4e4ed;
--color-text-secondary: #8888a0;
--color-text-muted: #5a5a70;
/* Higher-contrast text used for emphasis (h1h4, strong) */
--color-text-strong: #ffffff;
/* Accent */
--color-accent: #7c3aed;
@ -49,16 +57,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 +185,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 +236,7 @@ body {
.chat-prose strong {
font-weight: 600;
color: #fff;
color: var(--color-text-strong);
}
.chat-prose em {
@ -214,7 +250,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;