diff --git a/gitnexus-web/index.html b/gitnexus-web/index.html index 95f25b846..fd713f133 100644 --- a/gitnexus-web/index.html +++ b/gitnexus-web/index.html @@ -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" /> +
diff --git a/gitnexus-web/src/components/RightPanel.tsx b/gitnexus-web/src/components/RightPanel.tsx index 3e063a168..5899eca09 100644 --- a/gitnexus-web/src/components/RightPanel.tsx +++ b/gitnexus-web/src/components/RightPanel.tsx @@ -269,7 +269,7 @@ export const RightPanel = () => {
{!isAgentReady && ( - + Configure AI )} @@ -283,7 +283,7 @@ export const RightPanel = () => { {/* Status / errors */} {agentError && ( -
+
{agentError}
@@ -445,7 +445,7 @@ export const RightPanel = () => { )}
{!isAgentReady && !isAgentInitializing && ( -
+
{isProviderConfigured() diff --git a/gitnexus-web/src/components/SettingsPanel.tsx b/gitnexus-web/src/components/SettingsPanel.tsx index 4ba6a3f74..1bc8e19b5 100644 --- a/gitnexus-web/src/components/SettingsPanel.tsx +++ b/gitnexus-web/src/components/SettingsPanel.tsx @@ -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 */}
+ {/* Appearance */} +
+ + +
+ {/* Local Server */} {backendUrl !== undefined && onBackendUrlChange && (
@@ -437,7 +444,7 @@ export const SettingsPanel = ({
-
+
API keys are stored in session storage and will be cleared when you close this tab.
diff --git a/gitnexus-web/src/components/ThemeToggle.tsx b/gitnexus-web/src/components/ThemeToggle.tsx new file mode 100644 index 000000000..1bc4192c9 --- /dev/null +++ b/gitnexus-web/src/components/ThemeToggle.tsx @@ -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 ( +
+ {OPTIONS.map(({ value, label, Icon }) => { + const active = preference === value; + return ( + + ); + })} +
+ ); +}; diff --git a/gitnexus-web/src/hooks/useTheme.ts b/gitnexus-web/src/hooks/useTheme.ts new file mode 100644 index 000000000..39e70b3e5 --- /dev/null +++ b/gitnexus-web/src/hooks/useTheme.ts @@ -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(readStoredPreference); + const [resolved, setResolved] = useState(() => 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 }; +}; diff --git a/gitnexus-web/src/index.css b/gitnexus-web/src/index.css index 6c660b0a7..f1de1c2b4 100644 --- a/gitnexus-web/src/index.css +++ b/gitnexus-web/src/index.css @@ -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 ``, 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 (h1–h4, 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;