From f1e9919d390574eb58bed1c00502c9bc53eaa787 Mon Sep 17 00:00:00 2001 From: r09722050akabennett Date: Wed, 29 Apr 2026 10:04:35 +0800 Subject: [PATCH 1/3] fix(docker): use HEAD probe so SSE heartbeat doesn't time out healthcheck MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/api/heartbeat` is a `text/event-stream` endpoint that keeps the connection open and streams events. `curl -fsS` reads until the body ends, which never happens, so every probe hits the 5s timeout and Docker marks the server unhealthy — which in turn blocks `gitnexus-web` from starting via `condition: service_healthy`. Switch to `curl -fsSI` (HEAD): the server still returns 200 instantly, but there's no body to read so curl exits cleanly. Co-Authored-By: Claude Opus 4.7 (1M context) --- docker-compose.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yaml b/docker-compose.yaml index d17f012db..297eab85e 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -19,7 +19,7 @@ services: - ${WORKSPACE_DIR:-./workspace}:/workspace:ro restart: unless-stopped healthcheck: - test: ['CMD', 'curl', '-fsS', 'http://localhost:4747/api/heartbeat'] + test: ['CMD', 'curl', '-fsSI', 'http://localhost:4747/api/heartbeat'] interval: 30s timeout: 5s retries: 3 From c86671c675dcda8b5bf0066a2946c4bcf84718f0 Mon Sep 17 00:00:00 2001 From: r09722050akabennett Date: Wed, 29 Apr 2026 15:02:31 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat(web):=20add=20system=20/=20light=20/?= =?UTF-8?q?=20dark=20theme=20switcher=20The=20web=20UI=20was=20hardcoded?= =?UTF-8?q?=20dark.=20Add=20a=20theme=20preference=20(system=20|=20light?= =?UTF-8?q?=20|=20dark)=20persisted=20to=20localStorage=20and=20exposed=20?= =?UTF-8?q?via=20a=20segmented=20control=20in=20the=20existing=20Settings?= =?UTF-8?q?=20panel.=20Implementation:=20-=20=20hook=20owns=20the=20prefer?= =?UTF-8?q?ence,=20resolves=20=20against=20,=20and=20applies=20=20to=20.=20When=20in=20=20mode=20it=20subscribes=20to=20OS=20change?= =?UTF-8?q?s=20so=20the=20UI=20tracks=20live.=20-=20An=20inline=20boot=20s?= =?UTF-8?q?cript=20in=20index.html=20applies=20the=20resolved=20theme=20be?= =?UTF-8?q?fore=20React=20mounts,=20preventing=20a=20flash=20of=20the=20wr?= =?UTF-8?q?ong=20theme.=20-=20Light=20palette=20overrides=20only=20the=20c?= =?UTF-8?q?hrome=20tokens=20defined=20in=20the=20Tailwind=20v4=20=20block.?= =?UTF-8?q?=20Brand=20accent=20(purple)=20and=20graph=20node=20colors=20ar?= =?UTF-8?q?e=20intentionally=20identical=20across=20themes=20=E2=80=94=20t?= =?UTF-8?q?hey're=20brand=20/=20data=20viz,=20not=20chrome.=20-=20A=20few?= =?UTF-8?q?=20hardcoded=20=20and=20scrollbar=20colors=20in=20=20/=20=20mov?= =?UTF-8?q?e=20behind=20a=20new=20=20token=20and=20existing=20chrome=20tok?= =?UTF-8?q?ens=20so=20they=20follow=20the=20active=20theme.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gitnexus-web/index.html | 19 ++++++ gitnexus-web/src/components/SettingsPanel.tsx | 7 ++ gitnexus-web/src/components/ThemeToggle.tsx | 41 ++++++++++++ gitnexus-web/src/hooks/useTheme.ts | 65 +++++++++++++++++++ gitnexus-web/src/index.css | 40 ++++++++++-- 5 files changed, 167 insertions(+), 5 deletions(-) create mode 100644 gitnexus-web/src/components/ThemeToggle.tsx create mode 100644 gitnexus-web/src/hooks/useTheme.ts 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/SettingsPanel.tsx b/gitnexus-web/src/components/SettingsPanel.tsx index 4ba6a3f74..eafdb5210 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 && (
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..67962d776 100644 --- a/gitnexus-web/src/index.css +++ b/gitnexus-web/src/index.css @@ -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; From d0772b80748f5591d9107f77b2d71c2e88f6538c Mon Sep 17 00:00:00 2001 From: r09722050akabennett Date: Wed, 29 Apr 2026 15:22:02 +0800 Subject: [PATCH 3/3] fix(web): readable light-mode contrast for warning surfaces --- gitnexus-web/src/components/RightPanel.tsx | 6 +++--- gitnexus-web/src/components/SettingsPanel.tsx | 2 +- gitnexus-web/src/index.css | 6 ++++++ 3 files changed, 10 insertions(+), 4 deletions(-) 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 eafdb5210..1bc8e19b5 100644 --- a/gitnexus-web/src/components/SettingsPanel.tsx +++ b/gitnexus-web/src/components/SettingsPanel.tsx @@ -444,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/index.css b/gitnexus-web/src/index.css index 67962d776..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 ═══════════════════════════════════════════════════════════════ */