diff --git a/ui/src/components/workspace/Terminal.tsx b/ui/src/components/workspace/Terminal.tsx index acd1e735..7570924c 100644 --- a/ui/src/components/workspace/Terminal.tsx +++ b/ui/src/components/workspace/Terminal.tsx @@ -11,7 +11,8 @@ import { type ClientControlMessage, } from './protocol'; import { attachWebglRenderer } from './renderer'; -import { darkTheme } from './theme'; +import { darkTheme, lightTheme } from './theme'; +import { useEffectiveTheme } from '../../theme/useEffectiveTheme'; // Lazy-import so the demo subtree (transcripts, fixtures, handlers) is // dynamic-imported only when demo mode is actually on. With a static import, // Rollup is conservative about module side-effects (the transcript file @@ -99,6 +100,19 @@ export function TerminalView(props: TerminalViewProps): ReactElement { const onSessionLostRef = useRef(props.onSessionLost); onSessionLostRef.current = props.onSessionLost; + // Terminal palette follows the app theme (auto resolves via the OS). Read the + // current value through a ref so the connect effect doesn't recreate the + // terminal on a theme flip — a separate effect re-skins the live instance. + const effectiveTheme = useEffectiveTheme(); + const xtermTheme = effectiveTheme === 'light' ? lightTheme : darkTheme; + const themeRef = useRef(xtermTheme); + themeRef.current = xtermTheme; + const termRef = useRef(null); + + useEffect(() => { + if (termRef.current) termRef.current.options.theme = xtermTheme; + }, [xtermTheme]); + useEffect(() => { const container = containerRef.current; if (!container) return undefined; @@ -110,7 +124,7 @@ export function TerminalView(props: TerminalViewProps): ReactElement { setChildExited(false); const term = new Xterm({ - theme: darkTheme, + theme: themeRef.current, fontFamily: 'ui-monospace, "SF Mono", Menlo, Monaco, "Cascadia Mono", "DejaVu Sans Mono", monospace', fontSize: 13, @@ -121,6 +135,7 @@ export function TerminalView(props: TerminalViewProps): ReactElement { macOptionIsMeta: true, convertEol: false, }); + termRef.current = term; const fit = new FitAddon(); term.loadAddon(fit); @@ -313,6 +328,7 @@ export function TerminalView(props: TerminalViewProps): ReactElement { } webgl?.dispose(); term.dispose(); + termRef.current = null; }; }, [wsId, sessionId, wsUrl]); diff --git a/ui/src/components/workspace/theme.ts b/ui/src/components/workspace/theme.ts index 67892e48..6a4ae557 100644 --- a/ui/src/components/workspace/theme.ts +++ b/ui/src/components/workspace/theme.ts @@ -1,5 +1,36 @@ import type { ITheme } from '@xterm/xterm'; +/** + * Light xterm palette (GitHub-light-ish, warmed to sit on Daybreak's cream). + * ANSI `white` maps to a mid-grey and `brightWhite` to near-black, the way + * every light terminal theme does, so CLI output that prints as bright-white + * on a dark terminal becomes dark + legible here instead of vanishing. + * Picked by the active theme in Terminal.tsx (`auto` resolves via the OS). + */ +export const lightTheme: ITheme = { + background: '#faf8f1', + foreground: '#1c2a41', + cursor: '#2f62b0', + cursorAccent: '#faf8f1', + selectionBackground: 'rgba(47, 98, 176, 0.22)', + black: '#1c2a41', + red: '#cf222e', + green: '#116329', + yellow: '#7a4d05', + blue: '#0969da', + magenta: '#8250df', + cyan: '#1b7c83', + white: '#6e7781', + brightBlack: '#57606a', + brightRed: '#a40e26', + brightGreen: '#1a7f37', + brightYellow: '#633c01', + brightBlue: '#218bff', + brightMagenta: '#a475f9', + brightCyan: '#3192aa', + brightWhite: '#24292f', +}; + export const darkTheme: ITheme = { background: '#0b0d10', foreground: '#e6edf3', diff --git a/ui/src/components/workspace/workspaces.css b/ui/src/components/workspace/workspaces.css index d59b037f..4b8fec04 100644 --- a/ui/src/components/workspace/workspaces.css +++ b/ui/src/components/workspace/workspaces.css @@ -1,13 +1,18 @@ .workspaces-root { - color-scheme: dark; - --bg: #0b0d10; - --panel: #11151a; - --sidebar: #0d1014; - --border: #21262d; - --fg-dim: #8b949e; - --fg: #e6edf3; - --accent: #79c0ff; - --danger: #ff7b72; + /* Alias the local palette to the global theme tokens so the whole + Workspaces surface (session view + sidebar) follows light / dark. + `color-scheme` is inherited from :root (per-theme), not pinned dark. + The terminal itself is themed separately (xterm ITheme in Terminal.tsx). + Remaining hardcoded semantic chips (agent badges, status pills) are + tracked in ANG-110. */ + --bg: var(--color-bg); + --panel: var(--color-bg-secondary); + --sidebar: var(--color-bg-secondary); + --border: var(--color-border); + --fg-dim: var(--color-text-muted); + --fg: var(--color-text); + --accent: var(--color-accent); + --danger: var(--color-red); } .app { @@ -247,7 +252,7 @@ transition: background 0.12s, color 0.12s, border-color 0.12s; } .sidebar-overview-btn:hover { - background: rgba(255, 255, 255, 0.04); + background: var(--color-overlay); color: var(--fg); } .sidebar-overview-btn.is-active { @@ -290,7 +295,7 @@ font: inherit; } .sidebar-row-main:hover { - background: rgba(255, 255, 255, 0.04); + background: var(--color-overlay); } .sidebar-status-dot { @@ -370,7 +375,7 @@ transition: background 80ms; } .sidebar-session:hover { - background: rgba(255, 255, 255, 0.03); + background: var(--color-overlay); } .sidebar-session.is-active { background: rgba(121, 192, 255, 0.10); @@ -610,7 +615,7 @@ transition: background 80ms; } .sidebar-headless-item:hover { - background: rgba(255, 255, 255, 0.03); + background: var(--color-overlay); } .sidebar-headless-item .sidebar-status-dot { width: 5px; @@ -700,7 +705,7 @@ margin: 0 0 14px; border: 1px solid var(--border); border-radius: 6px; - background: rgba(255, 255, 255, 0.02); + background: var(--color-overlay); } .resume-tui-meta-strong { font-weight: 600; @@ -712,7 +717,7 @@ .resume-tui-prompt { display: flex; gap: 10px; - background: rgba(255, 255, 255, 0.04); + background: var(--color-overlay); padding: 8px 14px; margin: 6px 0; border-radius: 4px; @@ -995,7 +1000,7 @@ } .workspace-cta-hint kbd { font-family: ui-monospace, "SF Mono", Menlo, monospace; - background: rgba(255, 255, 255, 0.06); + background: var(--color-overlay-strong); border: 1px solid var(--border); border-radius: 3px; padding: 0 4px; @@ -1149,7 +1154,7 @@ align-items: center; gap: 8px; padding: 8px 12px; - background: linear-gradient(180deg, #161b22, #11151a); + background: linear-gradient(180deg, var(--color-bg-tertiary), var(--color-bg-secondary)); border-bottom: 1px solid var(--border); font-size: 11px; color: var(--fg-dim); @@ -1179,7 +1184,7 @@ transition: background 0.12s, color 0.12s; } .panel-collapse:hover { - background: rgba(255, 255, 255, 0.06); + background: var(--color-overlay-strong); color: var(--fg); } @@ -1326,7 +1331,7 @@ align-items: baseline; } .git-log-row:hover { - background: rgba(255, 255, 255, 0.03); + background: var(--color-overlay); } .git-log-hash { @@ -1396,7 +1401,7 @@ } .files-row.files-dir:hover, .files-row.files-symlink:hover { - background: rgba(255, 255, 255, 0.03); + background: var(--color-overlay); } .files-icon { @@ -1439,7 +1444,7 @@ align-items: center; gap: 10px; padding: 8px 12px; - background: linear-gradient(180deg, #161b22, #11151a); + background: linear-gradient(180deg, var(--color-bg-tertiary), var(--color-bg-secondary)); border-bottom: 1px solid var(--border); font-size: 12px; color: var(--fg-dim); diff --git a/ui/src/theme/useEffectiveTheme.ts b/ui/src/theme/useEffectiveTheme.ts new file mode 100644 index 00000000..0ad744f1 --- /dev/null +++ b/ui/src/theme/useEffectiveTheme.ts @@ -0,0 +1,29 @@ +import { useSyncExternalStore } from 'react' + +import { useThemeStore } from './store' + +const mq = window.matchMedia('(prefers-color-scheme: dark)') + +function subscribeSystem(cb: () => void): () => void { + mq.addEventListener('change', cb) + return () => mq.removeEventListener('change', cb) +} + +/** + * Resolve the preference (`light | dark | auto`) to a concrete `'light' | 'dark'` + * — `auto` follows the OS. Use this for the rare surfaces that need the actual + * value in JS rather than via CSS: notably the xterm terminal, whose palette is + * a JS object, not a CSS variable. CSS-driven surfaces should just read the + * `--color-*` tokens (which already resolve `data-theme` + prefers-color-scheme). + */ +export function useEffectiveTheme(): 'light' | 'dark' { + const theme = useThemeStore((s) => s.theme) + const systemDark = useSyncExternalStore( + subscribeSystem, + () => mq.matches, + () => true, + ) + if (theme === 'light') return 'light' + if (theme === 'dark') return 'dark' + return systemDark ? 'dark' : 'light' +}