diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index ce33c3f13..612c0faff 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -260,6 +260,8 @@ pub(crate) struct AppSettings { pub(crate) last_composer_reasoning_effort: Option, #[serde(default = "default_ui_scale", rename = "uiScale")] pub(crate) ui_scale: f64, + #[serde(default = "default_theme", rename = "theme")] + pub(crate) theme: String, #[serde( default = "default_notification_sounds_enabled", rename = "notificationSoundsEnabled" @@ -323,6 +325,10 @@ fn default_ui_scale() -> f64 { 1.0 } +fn default_theme() -> String { + "system".to_string() +} + fn default_composer_model_shortcut() -> Option { Some("cmd+shift+m".to_string()) } @@ -381,6 +387,7 @@ impl Default for AppSettings { last_composer_model_id: None, last_composer_reasoning_effort: None, ui_scale: 1.0, + theme: default_theme(), notification_sounds_enabled: true, experimental_collab_enabled: false, experimental_steer_enabled: false, @@ -421,6 +428,7 @@ mod tests { assert!(settings.last_composer_model_id.is_none()); assert!(settings.last_composer_reasoning_effort.is_none()); assert!((settings.ui_scale - 1.0).abs() < f64::EPSILON); + assert_eq!(settings.theme, "system"); assert!(settings.notification_sounds_enabled); assert!(!settings.experimental_steer_enabled); assert!(!settings.dictation_enabled); diff --git a/src/App.tsx b/src/App.tsx index f27e476bb..4eda5281d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1149,7 +1149,34 @@ function MainApp() { onDebug: addDebugEntry, }); const isDefaultScale = Math.abs(uiScale - 1) < 0.001; - const appClassName = `app ${isCompact ? "layout-compact" : "layout-desktop"}${ + const [systemTheme, setSystemTheme] = useState<"dark" | "light">(() => { + if (typeof window === "undefined" || !window.matchMedia) { + return "dark"; + } + return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; + }); + useEffect(() => { + if (typeof window === "undefined" || !window.matchMedia) { + return; + } + const media = window.matchMedia("(prefers-color-scheme: dark)"); + const handleChange = (event: MediaQueryListEvent) => { + setSystemTheme(event.matches ? "dark" : "light"); + }; + setSystemTheme(media.matches ? "dark" : "light"); + if (media.addEventListener) { + media.addEventListener("change", handleChange); + return () => media.removeEventListener("change", handleChange); + } + media.addListener(handleChange); + return () => media.removeListener(handleChange); + }, []); + const resolvedTheme = + appSettings.theme === "system" ? systemTheme : appSettings.theme; + const themeClassName = resolvedTheme === "light" ? "theme-light" : "theme-dark"; + const appClassName = `app ${themeClassName} ${ + isCompact ? "layout-compact" : "layout-desktop" + }${ isPhone ? " layout-phone" : "" }${isTablet ? " layout-tablet" : ""}${ reduceTransparency ? " reduced-transparency" : "" @@ -1193,6 +1220,7 @@ function MainApp() { activeRateLimits, approvals, handleApprovalDecision, + themePreference: resolvedTheme, onOpenSettings: () => handleOpenSettings(), onOpenDictationSettings: () => handleOpenSettings("dictation"), onOpenDebug: handleDebugClick, diff --git a/src/features/git/components/GitDiffViewer.tsx b/src/features/git/components/GitDiffViewer.tsx index 5f51eadfe..dfa2b99c0 100644 --- a/src/features/git/components/GitDiffViewer.tsx +++ b/src/features/git/components/GitDiffViewer.tsx @@ -20,6 +20,7 @@ type GitDiffViewerProps = { scrollRequestId?: number; isLoading: boolean; error: string | null; + themePreference?: "system" | "dark" | "light"; pullRequest?: GitHubPullRequest | null; pullRequestComments?: GitHubPullRequestComment[]; pullRequestCommentsLoading?: boolean; @@ -119,6 +120,7 @@ export function GitDiffViewer({ scrollRequestId, isLoading, error, + themePreference = "system", pullRequest, pullRequestComments, pullRequestCommentsLoading = false, @@ -131,10 +133,15 @@ export function GitDiffViewer({ const ignoreActivePathUntilRef = useRef(0); const lastScrollRequestIdRef = useRef(null); const poolOptions = useMemo(() => ({ workerFactory }), []); - const highlighterOptions = useMemo( - () => ({ theme: { dark: "pierre-dark", light: "pierre-light" } }), - [], - ); + const highlighterOptions = useMemo(() => { + if (themePreference === "dark") { + return { theme: "pierre-dark" }; + } + if (themePreference === "light") { + return { theme: "pierre-light" }; + } + return { theme: { dark: "pierre-dark", light: "pierre-light" } }; + }, [themePreference]); const indexByPath = useMemo(() => { const map = new Map(); diffs.forEach((entry, index) => { diff --git a/src/features/layout/hooks/useLayoutNodes.tsx b/src/features/layout/hooks/useLayoutNodes.tsx index 3a4314047..0994c93ef 100644 --- a/src/features/layout/hooks/useLayoutNodes.tsx +++ b/src/features/layout/hooks/useLayoutNodes.tsx @@ -207,6 +207,7 @@ type LayoutNodesOptions = { gitDiffs: GitDiffViewerItem[]; gitDiffLoading: boolean; gitDiffError: string | null; + themePreference?: "system" | "dark" | "light"; onDiffActivePathChange?: (path: string) => void; onSendPrompt: (text: string) => void | Promise; onSendPromptToNewAgent: (text: string) => void | Promise; @@ -597,6 +598,7 @@ export function useLayoutNodes(options: LayoutNodesOptions): LayoutNodesResult { scrollRequestId={options.diffScrollRequestId} isLoading={options.gitDiffLoading} error={options.gitDiffError} + themePreference={options.themePreference} pullRequest={options.selectedPullRequest} pullRequestComments={options.selectedPullRequestComments} pullRequestCommentsLoading={options.selectedPullRequestCommentsLoading} diff --git a/src/features/settings/components/SettingsView.tsx b/src/features/settings/components/SettingsView.tsx index b6b44aa6e..4edf9cad4 100644 --- a/src/features/settings/components/SettingsView.tsx +++ b/src/features/settings/components/SettingsView.tsx @@ -706,6 +706,30 @@ export function SettingsView({ +
+
+
Theme
+
+ Follow macOS appearance or force a light/dark theme. +
+
+ +
Sounds
Control notification audio alerts. diff --git a/src/features/settings/hooks/useAppSettings.ts b/src/features/settings/hooks/useAppSettings.ts index 85a48882c..2c528cbd0 100644 --- a/src/features/settings/hooks/useAppSettings.ts +++ b/src/features/settings/hooks/useAppSettings.ts @@ -15,6 +15,7 @@ const defaultSettings: AppSettings = { lastComposerModelId: null, lastComposerReasoningEffort: null, uiScale: UI_SCALE_DEFAULT, + theme: "system", notificationSoundsEnabled: true, experimentalCollabEnabled: false, experimentalSteerEnabled: false, @@ -27,9 +28,16 @@ const defaultSettings: AppSettings = { }; function normalizeAppSettings(settings: AppSettings): AppSettings { + const theme = + settings.theme === "light" || + settings.theme === "dark" || + settings.theme === "system" + ? settings.theme + : "system"; return { ...settings, uiScale: clampUiScale(settings.uiScale), + theme, }; } diff --git a/src/styles/base.css b/src/styles/base.css index 13f12b09a..f2745b670 100644 --- a/src/styles/base.css +++ b/src/styles/base.css @@ -70,6 +70,14 @@ --ui-scale: 1; } +.app.theme-light { + color-scheme: light; +} + +.app.theme-dark { + color-scheme: dark; +} + .app.reduced-transparency { --surface-sidebar: rgba(18, 18, 18, 0.92); --surface-topbar: rgba(10, 14, 20, 0.94); @@ -95,36 +103,35 @@ --surface-popover: rgba(16, 20, 30, 0.995); } - @media (prefers-color-scheme: light) { - :root { - --text-primary: #1a1d24; - --text-strong: #0e1118; - --text-emphasis: rgba(17, 20, 28, 0.9); - --text-stronger: rgba(17, 20, 28, 0.85); - --text-quiet: rgba(17, 20, 28, 0.75); - --text-muted: rgba(17, 20, 28, 0.7); - --text-subtle: rgba(17, 20, 28, 0.6); - --text-faint: rgba(17, 20, 28, 0.5); - --text-fainter: rgba(17, 20, 28, 0.45); - --text-dim: rgba(17, 20, 28, 0.35); - --surface-sidebar: rgba(246, 247, 250, 0.82); - --surface-topbar: rgba(250, 251, 253, 0.9); - --surface-right-panel: rgba(245, 247, 250, 0.82); - --surface-composer: rgba(250, 251, 253, 0.9); - --surface-messages: rgba(238, 241, 246, 0.9); - --surface-card: rgba(255, 255, 255, 0.72); - --surface-card-strong: rgba(255, 255, 255, 0.92); - --surface-card-muted: rgba(255, 255, 255, 0.7); - --surface-item: rgba(255, 255, 255, 0.6); - --surface-control: rgba(15, 23, 36, 0.08); - --surface-control-hover: rgba(15, 23, 36, 0.12); +.app.theme-light { + --text-primary: #1a1d24; + --text-strong: #0e1118; + --text-emphasis: rgba(17, 20, 28, 0.9); + --text-stronger: rgba(17, 20, 28, 0.85); + --text-quiet: rgba(17, 20, 28, 0.75); + --text-muted: rgba(17, 20, 28, 0.7); + --text-subtle: rgba(17, 20, 28, 0.6); + --text-faint: rgba(17, 20, 28, 0.5); + --text-fainter: rgba(17, 20, 28, 0.45); + --text-dim: rgba(17, 20, 28, 0.35); + --surface-sidebar: rgba(246, 247, 250, 0.82); + --surface-topbar: rgba(250, 251, 253, 0.9); + --surface-right-panel: rgba(245, 247, 250, 0.82); + --surface-composer: rgba(250, 251, 253, 0.9); + --surface-messages: rgba(238, 241, 246, 0.9); + --surface-card: rgba(255, 255, 255, 0.72); + --surface-card-strong: rgba(255, 255, 255, 0.92); + --surface-card-muted: rgba(255, 255, 255, 0.7); + --surface-item: rgba(255, 255, 255, 0.6); + --surface-control: rgba(15, 23, 36, 0.08); + --surface-control-hover: rgba(15, 23, 36, 0.12); --surface-control-disabled: rgba(15, 23, 36, 0.05); --surface-hover: rgba(15, 23, 36, 0.06); --surface-active: rgba(77, 153, 255, 0.18); - --surface-approval: rgba(246, 248, 252, 0.92); - --surface-debug: rgba(242, 244, 248, 0.9); - --surface-command: rgba(245, 247, 250, 0.95); - --surface-diff-card: rgba(240, 243, 248, 0.92); + --surface-approval: rgba(246, 248, 252, 0.92); + --surface-debug: rgba(242, 244, 248, 0.9); + --surface-command: rgba(245, 247, 250, 0.95); + --surface-diff-card: rgba(240, 243, 248, 0.92); --surface-bubble: rgba(255, 255, 255, 0.9); --surface-bubble-user: rgba(77, 153, 255, 0.22); --surface-context-core: rgba(255, 255, 255, 0.9); @@ -135,46 +142,45 @@ --text-review-active: rgba(120, 30, 70, 0.9); --surface-review-done: rgba(140, 235, 200, 0.35); --text-review-done: rgba(20, 90, 60, 0.9); - --border-subtle: rgba(15, 23, 36, 0.08); - --border-muted: rgba(15, 23, 36, 0.06); - --border-strong: rgba(15, 23, 36, 0.14); - --border-stronger: rgba(15, 23, 36, 0.18); - --border-quiet: rgba(15, 23, 36, 0.2); - --border-accent: rgba(77, 153, 255, 0.5); - --border-accent-soft: rgba(77, 153, 255, 0.28); + --border-subtle: rgba(15, 23, 36, 0.08); + --border-muted: rgba(15, 23, 36, 0.06); + --border-strong: rgba(15, 23, 36, 0.14); + --border-stronger: rgba(15, 23, 36, 0.18); + --border-quiet: rgba(15, 23, 36, 0.2); + --border-accent: rgba(77, 153, 255, 0.5); + --border-accent-soft: rgba(77, 153, 255, 0.28); --text-accent: rgba(45, 93, 170, 0.7); --shadow-accent: rgba(90, 140, 210, 0.18); --status-success: rgba(30, 155, 110, 0.9); - --status-warning: rgba(215, 120, 20, 0.9); - --status-error: rgba(200, 45, 45, 0.9); - --status-unknown: rgba(17, 20, 28, 0.25); + --status-warning: rgba(215, 120, 20, 0.9); + --status-error: rgba(200, 45, 45, 0.9); + --status-unknown: rgba(17, 20, 28, 0.25); --select-caret: rgba(15, 23, 36, 0.45); - } +} - .app.reduced-transparency { - --surface-sidebar: rgba(240, 242, 247, 0.98); - --surface-topbar: rgba(244, 246, 250, 0.98); - --surface-right-panel: rgba(242, 244, 248, 0.98); - --surface-composer: rgba(244, 246, 250, 0.98); - --surface-messages: rgba(240, 242, 247, 0.98); - --surface-card: rgba(255, 255, 255, 0.96); - --surface-card-strong: rgba(255, 255, 255, 0.98); - --surface-card-muted: rgba(252, 253, 255, 0.96); - --surface-item: rgba(250, 251, 253, 0.96); - --surface-control: rgba(15, 23, 36, 0.12); - --surface-control-hover: rgba(15, 23, 36, 0.18); - --surface-control-disabled: rgba(15, 23, 36, 0.08); - --surface-hover: rgba(15, 23, 36, 0.1); - --surface-active: rgba(77, 153, 255, 0.22); - --surface-approval: rgba(248, 249, 252, 0.98); - --surface-debug: rgba(246, 248, 252, 0.98); - --surface-command: rgba(250, 251, 253, 0.98); - --surface-diff-card: rgba(244, 246, 250, 0.98); - --surface-bubble: rgba(255, 255, 255, 0.98); - --surface-bubble-user: rgba(77, 153, 255, 0.28); - --surface-context-core: rgba(255, 255, 255, 0.98); - --surface-popover: rgba(255, 255, 255, 0.995); - } +.app.theme-light.reduced-transparency { + --surface-sidebar: rgba(240, 242, 247, 0.98); + --surface-topbar: rgba(244, 246, 250, 0.98); + --surface-right-panel: rgba(242, 244, 248, 0.98); + --surface-composer: rgba(244, 246, 250, 0.98); + --surface-messages: rgba(240, 242, 247, 0.98); + --surface-card: rgba(255, 255, 255, 0.96); + --surface-card-strong: rgba(255, 255, 255, 0.98); + --surface-card-muted: rgba(252, 253, 255, 0.96); + --surface-item: rgba(250, 251, 253, 0.96); + --surface-control: rgba(15, 23, 36, 0.12); + --surface-control-hover: rgba(15, 23, 36, 0.18); + --surface-control-disabled: rgba(15, 23, 36, 0.08); + --surface-hover: rgba(15, 23, 36, 0.1); + --surface-active: rgba(77, 153, 255, 0.22); + --surface-approval: rgba(248, 249, 252, 0.98); + --surface-debug: rgba(246, 248, 252, 0.98); + --surface-command: rgba(250, 251, 253, 0.98); + --surface-diff-card: rgba(244, 246, 250, 0.98); + --surface-bubble: rgba(255, 255, 255, 0.98); + --surface-bubble-user: rgba(77, 153, 255, 0.28); + --surface-context-core: rgba(255, 255, 255, 0.98); + --surface-popover: rgba(255, 255, 255, 0.995); } .popover-surface { @@ -205,6 +211,7 @@ body { display: grid; grid-template-columns: var(--sidebar-width, 280px) 1fr; background: transparent; + color: var(--text-primary); border-radius: 0; overflow: hidden; position: relative; diff --git a/src/styles/composer.css b/src/styles/composer.css index 3795fa842..5abb09a1e 100644 --- a/src/styles/composer.css +++ b/src/styles/composer.css @@ -198,15 +198,13 @@ position: relative; } -@media (prefers-color-scheme: light) { - .composer-action { - border-color: var(--border-strong); - color: var(--text-strong); - } +.app.theme-light .composer-action { + border-color: var(--border-strong); + color: var(--text-strong); +} - .composer-action:hover { - color: var(--text-strong); - } +.app.theme-light .composer-action:hover { + color: var(--text-strong); } .composer-action--mic.is-active { diff --git a/src/styles/messages.css b/src/styles/messages.css index 1da7dd190..d605ac25b 100644 --- a/src/styles/messages.css +++ b/src/styles/messages.css @@ -73,11 +73,9 @@ animation: working-shimmer 2.2s ease-in-out infinite; } -@media (prefers-color-scheme: light) { - .working-text { - color: var(--text-muted); - background: none; - } +.app.theme-light .working-text { + color: var(--text-muted); + background: none; } .turn-complete { diff --git a/src/styles/settings.css b/src/styles/settings.css index acd90ad8b..b1991dc86 100644 --- a/src/styles/settings.css +++ b/src/styles/settings.css @@ -223,6 +223,10 @@ font-size: 11px; } +.settings-select--theme { + min-width: 180px; +} + .settings-section-title { font-size: 15px; font-weight: 600; diff --git a/src/types.ts b/src/types.ts index 3ad56871c..2e83595fe 100644 --- a/src/types.ts +++ b/src/types.ts @@ -70,6 +70,7 @@ export type ReviewTarget = export type AccessMode = "read-only" | "current" | "full-access"; export type BackendMode = "local" | "remote"; +export type ThemePreference = "system" | "dark" | "light"; export type AppSettings = { codexBin: string | null; @@ -83,6 +84,7 @@ export type AppSettings = { lastComposerModelId: string | null; lastComposerReasoningEffort: string | null; uiScale: number; + theme: ThemePreference; notificationSoundsEnabled: boolean; experimentalCollabEnabled: boolean; experimentalSteerEnabled: boolean;