From 853885eee9018d9c7a06d3d5f02d701ea5a2f8d4 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Sat, 15 Nov 2025 20:51:49 -0500 Subject: [PATCH 1/8] =?UTF-8?q?=F0=9F=A4=96=20feat:=20add=20light=20theme?= =?UTF-8?q?=20support=20with=20toggle=20keybind?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement a light theme for mux with keyboard shortcut to toggle between light and dark themes. Changes: - Add light theme CSS variables in globals.css using [data-theme='light'] selector - Add theme state management in App.tsx using usePersistedState (global, not per-workspace) - Add 'Toggle Theme' command in command palette with Cmd+Option+T / Ctrl+Alt+T keybind - Update Shiki syntax highlighting to dynamically use theme-appropriate colors (min-light vs min-dark) - Update Mermaid diagrams to re-initialize with current theme on render - Add THEME_KEY constant to storage.ts for theme persistence - Default theme remains dark for existing users The theme preference persists across reloads via localStorage and applies to all code blocks, diffs, and mermaid diagrams. _Generated with `mux`_ --- src/browser/App.tsx | 20 +- .../Messages/MarkdownComponents.tsx | 5 +- src/browser/components/Messages/Mermaid.tsx | 53 ++++-- src/browser/styles/globals.css | 176 ++++++++++++++++++ src/browser/utils/commandIds.ts | 1 + src/browser/utils/commands/sources.test.ts | 1 + src/browser/utils/commands/sources.ts | 8 + .../utils/highlighting/highlightDiffChunk.ts | 4 +- .../utils/highlighting/shiki-shared.ts | 10 +- .../utils/highlighting/shikiHighlighter.ts | 5 +- src/browser/utils/ui/keybinds.ts | 4 + src/common/constants/storage.ts | 7 + 12 files changed, 262 insertions(+), 32 deletions(-) diff --git a/src/browser/App.tsx b/src/browser/App.tsx index 6afb546e1a..f44c25e0fa 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -28,7 +28,7 @@ import { buildCoreSources, type BuildSourcesParams } from "./utils/commands/sour import type { ThinkingLevel } from "@/common/types/thinking"; import { CUSTOM_EVENTS } from "@/common/constants/events"; import { isWorkspaceForkSwitchEvent } from "./utils/workspaceFork"; -import { getThinkingLevelKey } from "@/common/constants/storage"; +import { getThinkingLevelKey, THEME_KEY } from "@/common/constants/storage"; import type { BranchListResult } from "@/common/types/ipc"; import { useTelemetry } from "./hooks/useTelemetry"; import { useStartWorkspaceCreation, getFirstProjectPath } from "./hooks/useStartWorkspaceCreation"; @@ -60,6 +60,15 @@ function AppInner() { // Auto-collapse sidebar on mobile by default const isMobile = typeof window !== "undefined" && window.innerWidth <= 768; const [sidebarCollapsed, setSidebarCollapsed] = usePersistedState("sidebarCollapsed", isMobile); + + // Theme state (global, not workspace-specific) + const [theme, setTheme] = usePersistedState<"light" | "dark">(THEME_KEY, "dark"); + + // Apply theme to document root + useEffect(() => { + document.documentElement.dataset.theme = theme; + }, [theme]); + const defaultProjectPath = getFirstProjectPath(projects); const creationChatInputRef = useRef(null); const creationProjectPath = !selectedWorkspace @@ -381,6 +390,10 @@ function AppInner() { setSidebarCollapsed((prev) => !prev); }, [setSidebarCollapsed]); + const toggleTheme = useCallback(() => { + setTheme((prev) => (prev === "dark" ? "light" : "dark")); + }, [setTheme]); + const navigateWorkspaceFromPalette = useCallback( (dir: "next" | "prev") => { handleNavigateWorkspace(dir); @@ -402,6 +415,7 @@ function AppInner() { onAddProject: addProjectFromPalette, onRemoveProject: removeProjectFromPalette, onToggleSidebar: toggleSidebarFromPalette, + onToggleTheme: toggleTheme, onNavigateWorkspace: navigateWorkspaceFromPalette, onOpenWorkspaceInTerminal: openWorkspaceInTerminal, }; @@ -449,6 +463,9 @@ function AppInner() { } else if (matchesKeybind(e, KEYBINDS.TOGGLE_SIDEBAR)) { e.preventDefault(); setSidebarCollapsed((prev) => !prev); + } else if (matchesKeybind(e, KEYBINDS.TOGGLE_THEME)) { + e.preventDefault(); + toggleTheme(); } }; @@ -457,6 +474,7 @@ function AppInner() { }, [ handleNavigateWorkspace, setSidebarCollapsed, + toggleTheme, isCommandPaletteOpen, closeCommandPalette, openCommandPalette, diff --git a/src/browser/components/Messages/MarkdownComponents.tsx b/src/browser/components/Messages/MarkdownComponents.tsx index be98648e85..19327d4d6a 100644 --- a/src/browser/components/Messages/MarkdownComponents.tsx +++ b/src/browser/components/Messages/MarkdownComponents.tsx @@ -4,9 +4,8 @@ import { Mermaid } from "./Mermaid"; import { getShikiHighlighter, mapToShikiLang, - SHIKI_THEME, } from "@/browser/utils/highlighting/shikiHighlighter"; -import { extractShikiLines } from "@/browser/utils/highlighting/shiki-shared"; +import { extractShikiLines, getShikiTheme } from "@/browser/utils/highlighting/shiki-shared"; import { CopyButton } from "@/browser/components/ui/CopyButton"; interface CodeProps { @@ -79,7 +78,7 @@ const CodeBlock: React.FC = ({ code, language }) => { const html = highlighter.codeToHtml(code, { lang: shikiLang, - theme: SHIKI_THEME, + theme: getShikiTheme(), }); if (!cancelled) { diff --git a/src/browser/components/Messages/Mermaid.tsx b/src/browser/components/Messages/Mermaid.tsx index 5d9c2252ed..9059652d73 100644 --- a/src/browser/components/Messages/Mermaid.tsx +++ b/src/browser/components/Messages/Mermaid.tsx @@ -7,26 +7,36 @@ import { usePersistedState } from "@/browser/hooks/usePersistedState"; const MIN_HEIGHT = 300; const MAX_HEIGHT = 1200; -// Initialize mermaid -mermaid.initialize({ - startOnLoad: false, - theme: "dark", - layout: "elk", - securityLevel: "loose", - fontFamily: "var(--font-monospace)", - darkMode: true, - elk: { - nodePlacementStrategy: "LINEAR_SEGMENTS", - mergeEdges: true, - }, - wrap: true, - markdownAutoWrap: true, - flowchart: { - nodeSpacing: 60, - curve: "linear", - defaultRenderer: "elk", - }, -}); +/** + * Get mermaid theme configuration based on current theme + */ +function getMermaidConfig() { + const isDark = + typeof document === "undefined" || document.documentElement.dataset.theme !== "light"; + const theme: "dark" | "default" = isDark ? "dark" : "default"; + return { + startOnLoad: false, + theme, + layout: "elk", + securityLevel: "loose" as const, + fontFamily: "var(--font-monospace)", + darkMode: isDark, + elk: { + nodePlacementStrategy: "LINEAR_SEGMENTS" as const, + mergeEdges: true, + }, + wrap: true, + markdownAutoWrap: true, + flowchart: { + nodeSpacing: 60, + curve: "linear" as const, + defaultRenderer: "elk" as const, + }, + }; +} + +// Initialize mermaid with default dark theme +mermaid.initialize(getMermaidConfig()); // Common button styles const getButtonStyle = (disabled = false): CSSProperties => ({ @@ -141,6 +151,9 @@ export const Mermaid: React.FC<{ chart: string }> = ({ chart }) => { try { setError(null); + // Re-initialize mermaid with current theme + mermaid.initialize(getMermaidConfig()); + // Parse first to validate syntax without rendering await mermaid.parse(chart); diff --git a/src/browser/styles/globals.css b/src/browser/styles/globals.css index 30819dbbfa..feaff2bced 100644 --- a/src/browser/styles/globals.css +++ b/src/browser/styles/globals.css @@ -189,6 +189,182 @@ /* Error backgrounds */ --color-error-bg-dark: hsl(0 33% 13%); /* #3c1f1f - dark error bg */ + +[data-theme="light"] @theme { + /* Mode Colors - Keep hue, adjust lightness for light backgrounds */ + --color-plan-mode: hsl(210 80% 45%); + --color-plan-mode-hover: hsl(210 80% 38%); + --color-plan-mode-light: hsl(210 80% 55%); + + --color-exec-mode: hsl(268.56 70% 50%); + --color-exec-mode-hover: hsl(268.56 70% 43%); + --color-exec-mode-light: hsl(268.56 70% 60%); + + --color-edit-mode: hsl(120 45% 38%); + --color-edit-mode-hover: hsl(120 45% 32%); + --color-edit-mode-light: hsl(120 45% 48%); + + --color-read: hsl(210 80% 45%); + --color-editing-mode: hsl(30 100% 40%); + --color-pending: hsl(30 100% 50%); + + --color-debug-mode: hsl(214 100% 50%); + --color-debug-light: hsl(214 100% 60%); + --color-debug-text: hsl(214 100% 45%); + + --color-thinking-mode: hsl(271 76% 48%); + --color-thinking-mode-light: hsl(271 76% 58%); + --color-thinking-border: hsl(271 76% 48%); + + /* Background & Layout - Inverted */ + --color-background: hsl(0 0% 98%); + --color-background-secondary: hsl(0 0% 96%); + --color-border: hsl(240 3% 85%); + --color-foreground: hsl(0 0% 15%); + --color-muted-foreground: hsl(0 0% 40%); + --color-secondary: hsl(0 0% 55%); + + /* Code */ + --color-code-bg: hsl(0 0% 95%); + + /* Buttons */ + --color-button-bg: hsl(0 0% 90%); + --color-button-text: hsl(0 0% 20%); + --color-button-hover: hsl(0 0% 85%); + + /* Messages */ + --color-user-border: hsl(0 0% 70%); + --color-user-border-hover: hsl(0 0% 65%); + --color-assistant-border: hsl(207 45% 60%); + --color-assistant-border-hover: hsl(207 45% 55%); + --color-message-header: hsl(0 0% 20%); + + /* Tokens */ + --color-token-prompt: hsl(0 0% 55%); + --color-token-completion: hsl(207 100% 45%); + --color-token-variable: hsl(207 100% 45%); + --color-token-fixed: hsl(0 0% 55%); + --color-token-input: hsl(120 40% 38%); + --color-token-output: hsl(207 100% 45%); + --color-token-cached: hsl(0 0% 50%); + + /* Toggle */ + --color-toggle-bg: hsl(0 0% 92%); + --color-toggle-active: hsl(0 0% 88%); + --color-toggle-hover: hsl(0 0% 90%); + --color-toggle-text: hsl(0 0% 45%); + --color-toggle-text-active: hsl(0 0% 10%); + --color-toggle-text-hover: hsl(0 0% 30%); + + /* Status */ + --color-interrupted: hsl(38 92% 45%); + --color-review-accent: hsl(48 70% 45%); + --color-git-dirty: hsl(38 92% 45%); + --color-error: hsl(0 70% 50%); + --color-error-bg: hsl(0 50% 95%); + + /* Input */ + --color-input-bg: hsl(0 0% 100%); + --color-input-text: hsl(0 0% 15%); + --color-input-border: hsl(207 51% 65%); + --color-input-border-focus: hsl(193 91% 55%); + + /* Scrollbar */ + --color-scrollbar-track: hsl(0 0% 93%); + --color-scrollbar-thumb: hsl(0 0% 75%); + --color-scrollbar-thumb-hover: hsl(0 0% 65%); + + /* Additional Semantic Colors */ + --color-muted: hsl(0 0% 50%); + --color-muted-light: hsl(0 0% 55%); + --color-muted-dark: hsl(0 0% 45%); + --color-placeholder: hsl(0 0% 60%); + --color-subtle: hsl(0 0% 45%); + --color-dim: hsl(0 0% 55%); + --color-light: hsl(0 0% 25%); + --color-lighter: hsl(0 0% 20%); + --color-bright: hsl(0 0% 30%); + --color-subdued: hsl(0 0% 45%); + --color-label: hsl(0 0% 40%); + --color-gray: hsl(0 0% 52%); + --color-medium: hsl(0 0% 45%); + + --color-border-light: hsl(240 3% 88%); + --color-border-medium: hsl(0 0% 82%); + --color-border-darker: hsl(0 0% 78%); + --color-border-subtle: hsl(0 0% 75%); + --color-border-gray: hsl(240 1% 80%); + + --color-dark: hsl(0 0% 93%); + --color-darker: hsl(0 0% 95%); + --color-hover: hsl(0 0% 92%); + --color-bg-medium: hsl(0 0% 85%); + --color-bg-light: hsl(0 0% 88%); + --color-bg-subtle: hsl(240 3% 94%); + + --color-separator: hsl(0 0% 90%); + --color-separator-light: hsl(0 0% 85%); + --color-modal-bg: hsl(0 0% 97%); + + --color-accent: hsl(207 100% 45%); + --color-accent-hover: hsl(207 100% 40%); + --color-accent-dark: hsl(207 100% 38%); + --color-accent-darker: hsl(202 100% 30%); + --color-accent-light: hsl(198 100% 55%); + + --color-success: hsl(122 39% 45%); + --color-success-light: hsl(123 46% 55%); + + --color-danger: hsl(4 90% 52%); + --color-danger-light: hsl(0 91% 62%); + --color-danger-soft: hsl(6 93% 60%); + + --color-warning: hsl(45 100% 45%); + --color-warning-light: hsl(0 91% 65%); + + /* Code syntax highlighting */ + --color-code-type: hsl(197 71% 40%); + --color-code-keyword: hsl(210 59% 45%); + + /* Toast and notification backgrounds */ + --color-toast-success-bg: hsl(207 100% 45% / 0.1); + --color-toast-success-text: hsl(207 100% 40%); + --color-toast-error-bg: hsl(5 89% 50% / 0.1); + --color-toast-error-text: hsl(5 89% 45%); + --color-toast-error-border: hsl(5 89% 50%); + --color-toast-fatal-bg: hsl(0 50% 95%); + --color-toast-fatal-border: hsl(0 50% 85%); + + /* Semi-transparent overlays */ + --color-danger-overlay: hsl(4 90% 52% / 0.1); + --color-warning-overlay: hsl(45 100% 45% / 0.1); + --color-gray-overlay: hsl(0 0% 50% / 0.05); + --color-white-overlay-light: hsl(0 0% 0% / 0.03); + --color-white-overlay: hsl(0 0% 0% / 0.05); + --color-selection: hsl(204 100% 65% / 0.3); + --color-vim-status: hsl(0 0% 20% / 0.6); + --color-code-keyword-overlay-light: hsl(210 100% 50% / 0.05); + --color-code-keyword-overlay: hsl(210 100% 50% / 0.15); + + /* Info/status colors */ + --color-info-light: hsl(5 100% 60%); + --color-info-yellow: hsl(38 100% 55%); + + /* Review/diff backgrounds */ + --color-review-bg-blue: hsl(201 31% 90%); + --color-review-bg-info: hsl(202 33% 92%); + --color-review-bg-warning: hsl(40 100% 95%); + --color-review-warning: hsl(38 100% 40%); + --color-review-warning-medium: hsl(38 100% 45%); + --color-review-warning-light: hsl(40 100% 92%); + + /* Error backgrounds */ + --color-error-bg-dark: hsl(0 50% 93%); + + /* Radius */ + --radius: 0.5rem; +} + /* Radius */ --radius: 0.5rem; } diff --git a/src/browser/utils/commandIds.ts b/src/browser/utils/commandIds.ts index e30ae88544..01d0167c7b 100644 --- a/src/browser/utils/commandIds.ts +++ b/src/browser/utils/commandIds.ts @@ -33,6 +33,7 @@ export const CommandIds = { navNext: () => "nav:next" as const, navPrev: () => "nav:prev" as const, navToggleSidebar: () => "nav:toggleSidebar" as const, + navToggleTheme: () => "nav:toggleTheme" as const, // Chat commands chatClear: () => "chat:clear" as const, diff --git a/src/browser/utils/commands/sources.test.ts b/src/browser/utils/commands/sources.test.ts index 8b6d613d7e..73e501507f 100644 --- a/src/browser/utils/commands/sources.test.ts +++ b/src/browser/utils/commands/sources.test.ts @@ -44,6 +44,7 @@ const mk = (over: Partial[0]> = {}) => { onAddProject: () => undefined, onRemoveProject: () => undefined, onToggleSidebar: () => undefined, + onToggleTheme: () => undefined, onNavigateWorkspace: () => undefined, onOpenWorkspaceInTerminal: () => undefined, getBranchesForProject: () => diff --git a/src/browser/utils/commands/sources.ts b/src/browser/utils/commands/sources.ts index 2c3aa93483..740c377040 100644 --- a/src/browser/utils/commands/sources.ts +++ b/src/browser/utils/commands/sources.ts @@ -39,6 +39,7 @@ export interface BuildSourcesParams { onAddProject: () => void; onRemoveProject: (path: string) => void; onToggleSidebar: () => void; + onToggleTheme: () => void; onNavigateWorkspace: (dir: "next" | "prev") => void; onOpenWorkspaceInTerminal: (workspaceId: string) => void; } @@ -303,6 +304,13 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi shortcutHint: formatKeybind(KEYBINDS.TOGGLE_SIDEBAR), run: () => p.onToggleSidebar(), }, + { + id: CommandIds.navToggleTheme(), + title: "Toggle Theme", + section: section.navigation, + shortcutHint: formatKeybind(KEYBINDS.TOGGLE_THEME), + run: () => p.onToggleTheme(), + }, ]); // Chat utilities diff --git a/src/browser/utils/highlighting/highlightDiffChunk.ts b/src/browser/utils/highlighting/highlightDiffChunk.ts index ca41d361d6..39300d1c92 100644 --- a/src/browser/utils/highlighting/highlightDiffChunk.ts +++ b/src/browser/utils/highlighting/highlightDiffChunk.ts @@ -1,9 +1,9 @@ import { getShikiHighlighter, mapToShikiLang, - SHIKI_THEME, MAX_DIFF_SIZE_BYTES, } from "./shikiHighlighter"; +import { getShikiTheme } from "./shiki-shared"; import type { DiffChunk } from "./diffChunking"; /** @@ -83,7 +83,7 @@ export async function highlightDiffChunk( const html = highlighter.codeToHtml(code, { lang: shikiLang, - theme: SHIKI_THEME, + theme: getShikiTheme(), }); // Parse HTML to extract line contents diff --git a/src/browser/utils/highlighting/shiki-shared.ts b/src/browser/utils/highlighting/shiki-shared.ts index a07cd252be..0d833cef92 100644 --- a/src/browser/utils/highlighting/shiki-shared.ts +++ b/src/browser/utils/highlighting/shiki-shared.ts @@ -3,8 +3,14 @@ * Used by both the main app and documentation theme */ -// Shiki theme used throughout the application -export const SHIKI_THEME = "min-dark"; +/** + * Get the appropriate Shiki theme based on the current theme + */ +export function getShikiTheme(): "min-dark" | "min-light" { + if (typeof document === "undefined") return "min-dark"; + const theme = document.documentElement.dataset.theme; + return theme === "light" ? "min-light" : "min-dark"; +} /** * Map language names to Shiki-compatible language IDs diff --git a/src/browser/utils/highlighting/shikiHighlighter.ts b/src/browser/utils/highlighting/shikiHighlighter.ts index b98a54e600..f50ee810f2 100644 --- a/src/browser/utils/highlighting/shikiHighlighter.ts +++ b/src/browser/utils/highlighting/shikiHighlighter.ts @@ -1,8 +1,5 @@ import { createHighlighter, type Highlighter } from "shiki"; -// Shiki theme used throughout the application -export const SHIKI_THEME = "min-dark"; - // Maximum diff size to highlight (in bytes) // Diffs larger than this will fall back to plain text for performance export const MAX_DIFF_SIZE_BYTES = 32768; // 32kb @@ -21,7 +18,7 @@ export async function getShikiHighlighter(): Promise { // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing if (!highlighterPromise) { highlighterPromise = createHighlighter({ - themes: [SHIKI_THEME], + themes: ["min-dark", "min-light"], langs: [], // Load languages on-demand via highlightDiffChunk }); } diff --git a/src/browser/utils/ui/keybinds.ts b/src/browser/utils/ui/keybinds.ts index 1c884e0983..caf8974f30 100644 --- a/src/browser/utils/ui/keybinds.ts +++ b/src/browser/utils/ui/keybinds.ts @@ -249,6 +249,10 @@ export const KEYBINDS = { // macOS: Cmd+Shift+T, Win/Linux: Ctrl+Shift+T TOGGLE_THINKING: { key: "T", ctrl: true, shift: true }, + /** Toggle theme between light and dark */ + // macOS: Cmd+Option+T, Win/Linux: Ctrl+Alt+T + TOGGLE_THEME: { key: "T", ctrl: true, alt: true }, + /** Focus chat input from anywhere */ // Works even when focus is already in an input field // macOS: Cmd+I, Win/Linux: Ctrl+I diff --git a/src/common/constants/storage.ts b/src/common/constants/storage.ts index 5a2b2f1215..7cdd6a14e6 100644 --- a/src/common/constants/storage.ts +++ b/src/common/constants/storage.ts @@ -120,6 +120,13 @@ export const USE_1M_CONTEXT_KEY = "use1MContext"; */ export const PREFERRED_COMPACTION_MODEL_KEY = "preferredCompactionModel"; + +/** + * Get the localStorage key for theme preference (global) + * Format: "theme" + */ +export const THEME_KEY = "theme"; + /** * Get the localStorage key for vim mode preference (global) * Format: "vimEnabled" From 0a0d979ca00dbded8c5c98ebb23cff0a7a55c7b7 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Sun, 16 Nov 2025 09:49:15 -0500 Subject: [PATCH 2/8] Remove theme toggle keybind, add Storybook theme variants - Remove Cmd+Option+T / Ctrl+Alt+T keybind for theme toggle - Keep theme toggle as command palette action only - Add Storybook global theme switcher in toolbar - All stories now support both light and dark theme preview - Theme switcher appears as sun/moon icons in Storybook toolbar --- .storybook/preview.tsx | 34 +++++++++++++++++++++++---- src/browser/App.tsx | 8 ++----- src/browser/utils/commands/sources.ts | 1 - src/browser/utils/ui/keybinds.ts | 4 ---- 4 files changed, 31 insertions(+), 16 deletions(-) diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index e1ddd4ca8c..49ff36d0ff 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -1,13 +1,37 @@ import type { Preview } from "@storybook/react-vite"; +import { useEffect } from "react"; import "../src/browser/styles/globals.css"; const preview: Preview = { + globalTypes: { + theme: { + description: "Global theme for components", + defaultValue: "dark", + toolbar: { + title: "Theme", + icon: "circlehollow", + items: [ + { value: "light", title: "Light", icon: "sun" }, + { value: "dark", title: "Dark", icon: "moon" }, + ], + dynamicTitle: true, + }, + }, + }, decorators: [ - (Story) => ( - <> - - - ), + (Story, context) => { + const theme = context.globals.theme || "dark"; + + useEffect(() => { + document.documentElement.dataset.theme = theme; + }, [theme]); + + return ( + <> + + + ); + }, ], parameters: { controls: { diff --git a/src/browser/App.tsx b/src/browser/App.tsx index f44c25e0fa..f0146792f4 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -390,7 +390,7 @@ function AppInner() { setSidebarCollapsed((prev) => !prev); }, [setSidebarCollapsed]); - const toggleTheme = useCallback(() => { + const toggleThemeFromPalette = useCallback(() => { setTheme((prev) => (prev === "dark" ? "light" : "dark")); }, [setTheme]); @@ -415,7 +415,7 @@ function AppInner() { onAddProject: addProjectFromPalette, onRemoveProject: removeProjectFromPalette, onToggleSidebar: toggleSidebarFromPalette, - onToggleTheme: toggleTheme, + onToggleTheme: toggleThemeFromPalette, onNavigateWorkspace: navigateWorkspaceFromPalette, onOpenWorkspaceInTerminal: openWorkspaceInTerminal, }; @@ -463,9 +463,6 @@ function AppInner() { } else if (matchesKeybind(e, KEYBINDS.TOGGLE_SIDEBAR)) { e.preventDefault(); setSidebarCollapsed((prev) => !prev); - } else if (matchesKeybind(e, KEYBINDS.TOGGLE_THEME)) { - e.preventDefault(); - toggleTheme(); } }; @@ -474,7 +471,6 @@ function AppInner() { }, [ handleNavigateWorkspace, setSidebarCollapsed, - toggleTheme, isCommandPaletteOpen, closeCommandPalette, openCommandPalette, diff --git a/src/browser/utils/commands/sources.ts b/src/browser/utils/commands/sources.ts index 740c377040..1d24e7a3f4 100644 --- a/src/browser/utils/commands/sources.ts +++ b/src/browser/utils/commands/sources.ts @@ -308,7 +308,6 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi id: CommandIds.navToggleTheme(), title: "Toggle Theme", section: section.navigation, - shortcutHint: formatKeybind(KEYBINDS.TOGGLE_THEME), run: () => p.onToggleTheme(), }, ]); diff --git a/src/browser/utils/ui/keybinds.ts b/src/browser/utils/ui/keybinds.ts index caf8974f30..1c884e0983 100644 --- a/src/browser/utils/ui/keybinds.ts +++ b/src/browser/utils/ui/keybinds.ts @@ -249,10 +249,6 @@ export const KEYBINDS = { // macOS: Cmd+Shift+T, Win/Linux: Ctrl+Shift+T TOGGLE_THINKING: { key: "T", ctrl: true, shift: true }, - /** Toggle theme between light and dark */ - // macOS: Cmd+Option+T, Win/Linux: Ctrl+Alt+T - TOGGLE_THEME: { key: "T", ctrl: true, alt: true }, - /** Focus chat input from anywhere */ // Works even when focus is already in an input field // macOS: Cmd+I, Win/Linux: Ctrl+I From bbda494d22c00a37eabf8a0a6e13c29cc7cf079b Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Sun, 16 Nov 2025 09:51:17 -0500 Subject: [PATCH 3/8] Fix Tailwind v4 @theme syntax for light theme Tailwind CSS v4's @theme directive doesn't support nested selectors. Use standard CSS selector [data-theme='light'] { } instead. --- src/browser/styles/globals.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/browser/styles/globals.css b/src/browser/styles/globals.css index feaff2bced..3021c1178f 100644 --- a/src/browser/styles/globals.css +++ b/src/browser/styles/globals.css @@ -190,7 +190,8 @@ --color-error-bg-dark: hsl(0 33% 13%); /* #3c1f1f - dark error bg */ -[data-theme="light"] @theme { +/* Light theme overrides using standard CSS selector */ +[data-theme="light"] { /* Mode Colors - Keep hue, adjust lightness for light backgrounds */ --color-plan-mode: hsl(210 80% 45%); --color-plan-mode-hover: hsl(210 80% 38%); From 6d59312386053fcac9aa31746535ac2e25073c72 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Sun, 16 Nov 2025 09:53:39 -0500 Subject: [PATCH 4/8] Fix @theme block structure - add missing closing brace The original @theme block was missing its closing brace before the light theme block was added, causing the light theme to be nested inside @theme. --- src/browser/styles/globals.css | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/browser/styles/globals.css b/src/browser/styles/globals.css index 3021c1178f..0e94469056 100644 --- a/src/browser/styles/globals.css +++ b/src/browser/styles/globals.css @@ -189,6 +189,9 @@ /* Error backgrounds */ --color-error-bg-dark: hsl(0 33% 13%); /* #3c1f1f - dark error bg */ + /* Radius */ + --radius: 0.5rem; +} /* Light theme overrides using standard CSS selector */ [data-theme="light"] { @@ -366,10 +369,6 @@ --radius: 0.5rem; } - /* Radius */ - --radius: 0.5rem; -} - :root { /* Legacy RGB for special uses */ --plan-mode-rgb: 31, 107, 184; From 02c81371dd33b41d37a169c3d9080b1f4886fc94 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Sun, 16 Nov 2025 09:55:59 -0500 Subject: [PATCH 5/8] Fix Prettier formatting --- src/browser/App.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/browser/App.tsx b/src/browser/App.tsx index f0146792f4..a8902c29c6 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -60,15 +60,15 @@ function AppInner() { // Auto-collapse sidebar on mobile by default const isMobile = typeof window !== "undefined" && window.innerWidth <= 768; const [sidebarCollapsed, setSidebarCollapsed] = usePersistedState("sidebarCollapsed", isMobile); - + // Theme state (global, not workspace-specific) const [theme, setTheme] = usePersistedState<"light" | "dark">(THEME_KEY, "dark"); - + // Apply theme to document root useEffect(() => { document.documentElement.dataset.theme = theme; }, [theme]); - + const defaultProjectPath = getFirstProjectPath(projects); const creationChatInputRef = useRef(null); const creationProjectPath = !selectedWorkspace From a541e9f59a9616f5db951f8db72b353c296badd7 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Sun, 16 Nov 2025 09:56:10 -0500 Subject: [PATCH 6/8] Apply Prettier formatting to MarkdownComponents --- src/browser/components/Messages/MarkdownComponents.tsx | 5 +---- src/browser/utils/highlighting/highlightDiffChunk.ts | 6 +----- src/common/constants/storage.ts | 1 - 3 files changed, 2 insertions(+), 10 deletions(-) diff --git a/src/browser/components/Messages/MarkdownComponents.tsx b/src/browser/components/Messages/MarkdownComponents.tsx index 19327d4d6a..af43774246 100644 --- a/src/browser/components/Messages/MarkdownComponents.tsx +++ b/src/browser/components/Messages/MarkdownComponents.tsx @@ -1,10 +1,7 @@ import type { ReactNode } from "react"; import React, { useState, useEffect } from "react"; import { Mermaid } from "./Mermaid"; -import { - getShikiHighlighter, - mapToShikiLang, -} from "@/browser/utils/highlighting/shikiHighlighter"; +import { getShikiHighlighter, mapToShikiLang } from "@/browser/utils/highlighting/shikiHighlighter"; import { extractShikiLines, getShikiTheme } from "@/browser/utils/highlighting/shiki-shared"; import { CopyButton } from "@/browser/components/ui/CopyButton"; diff --git a/src/browser/utils/highlighting/highlightDiffChunk.ts b/src/browser/utils/highlighting/highlightDiffChunk.ts index 39300d1c92..228f30facd 100644 --- a/src/browser/utils/highlighting/highlightDiffChunk.ts +++ b/src/browser/utils/highlighting/highlightDiffChunk.ts @@ -1,8 +1,4 @@ -import { - getShikiHighlighter, - mapToShikiLang, - MAX_DIFF_SIZE_BYTES, -} from "./shikiHighlighter"; +import { getShikiHighlighter, mapToShikiLang, MAX_DIFF_SIZE_BYTES } from "./shikiHighlighter"; import { getShikiTheme } from "./shiki-shared"; import type { DiffChunk } from "./diffChunking"; diff --git a/src/common/constants/storage.ts b/src/common/constants/storage.ts index 7cdd6a14e6..e9d00eea0c 100644 --- a/src/common/constants/storage.ts +++ b/src/common/constants/storage.ts @@ -120,7 +120,6 @@ export const USE_1M_CONTEXT_KEY = "use1MContext"; */ export const PREFERRED_COMPACTION_MODEL_KEY = "preferredCompactionModel"; - /** * Get the localStorage key for theme preference (global) * Format: "theme" From 438b2d218473d04ed958a1ebb49ac7c74643b102 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Sun, 16 Nov 2025 10:08:19 -0500 Subject: [PATCH 7/8] Fix Storybook theme switching persistence - Apply theme to both document.documentElement and document.body - Wrap Story in div with data-theme attribute and background/foreground colors - Ensures theme persists and doesn't flash back to dark --- .storybook/preview.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 49ff36d0ff..c34018f897 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -23,13 +23,17 @@ const preview: Preview = { const theme = context.globals.theme || "dark"; useEffect(() => { + // Apply theme to document root document.documentElement.dataset.theme = theme; + + // Also apply to body to ensure it persists + document.body.dataset.theme = theme; }, [theme]); return ( - <> +
- +
); }, ], From 43f8300c1b64c6b722ad49b8ade35378903cb197 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Sun, 16 Nov 2025 10:12:27 -0500 Subject: [PATCH 8/8] Refactor theme management to use useTheme hook and ThemeContext - Create ThemeContext with useTheme hook for cleaner theme access - Remove duplicate theme logic from App.tsx - Simplify Storybook decorator to just apply data-theme attribute - Components can now use useTheme() instead of reading document.documentElement - Centralize all theme logic in one place --- .storybook/preview.tsx | 9 ----- src/browser/App.tsx | 29 +++++++------- src/browser/contexts/ThemeContext.tsx | 40 +++++++++++++++++++ .../utils/highlighting/shiki-shared.ts | 1 + 4 files changed, 55 insertions(+), 24 deletions(-) create mode 100644 src/browser/contexts/ThemeContext.tsx diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index c34018f897..066bd0278e 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -1,5 +1,4 @@ import type { Preview } from "@storybook/react-vite"; -import { useEffect } from "react"; import "../src/browser/styles/globals.css"; const preview: Preview = { @@ -22,14 +21,6 @@ const preview: Preview = { (Story, context) => { const theme = context.globals.theme || "dark"; - useEffect(() => { - // Apply theme to document root - document.documentElement.dataset.theme = theme; - - // Also apply to body to ensure it persists - document.body.dataset.theme = theme; - }, [theme]); - return (
diff --git a/src/browser/App.tsx b/src/browser/App.tsx index a8902c29c6..6ac79616e3 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -22,16 +22,18 @@ import { CommandRegistryProvider, useCommandRegistry } from "./contexts/CommandR import type { CommandAction } from "./contexts/CommandRegistryContext"; import { ModeProvider } from "./contexts/ModeContext"; import { ThinkingProvider } from "./contexts/ThinkingContext"; +import { ThemeProvider } from "./contexts/ThemeContext"; import { CommandPalette } from "./components/CommandPalette"; import { buildCoreSources, type BuildSourcesParams } from "./utils/commands/sources"; import type { ThinkingLevel } from "@/common/types/thinking"; import { CUSTOM_EVENTS } from "@/common/constants/events"; import { isWorkspaceForkSwitchEvent } from "./utils/workspaceFork"; -import { getThinkingLevelKey, THEME_KEY } from "@/common/constants/storage"; +import { getThinkingLevelKey } from "@/common/constants/storage"; import type { BranchListResult } from "@/common/types/ipc"; import { useTelemetry } from "./hooks/useTelemetry"; import { useStartWorkspaceCreation, getFirstProjectPath } from "./hooks/useStartWorkspaceCreation"; +import { useTheme } from "./contexts/ThemeContext"; const THINKING_LEVELS: ThinkingLevel[] = ["off", "low", "medium", "high"]; @@ -62,12 +64,7 @@ function AppInner() { const [sidebarCollapsed, setSidebarCollapsed] = usePersistedState("sidebarCollapsed", isMobile); // Theme state (global, not workspace-specific) - const [theme, setTheme] = usePersistedState<"light" | "dark">(THEME_KEY, "dark"); - - // Apply theme to document root - useEffect(() => { - document.documentElement.dataset.theme = theme; - }, [theme]); + const { toggleTheme } = useTheme(); const defaultProjectPath = getFirstProjectPath(projects); const creationChatInputRef = useRef(null); @@ -390,10 +387,6 @@ function AppInner() { setSidebarCollapsed((prev) => !prev); }, [setSidebarCollapsed]); - const toggleThemeFromPalette = useCallback(() => { - setTheme((prev) => (prev === "dark" ? "light" : "dark")); - }, [setTheme]); - const navigateWorkspaceFromPalette = useCallback( (dir: "next" | "prev") => { handleNavigateWorkspace(dir); @@ -415,7 +408,7 @@ function AppInner() { onAddProject: addProjectFromPalette, onRemoveProject: removeProjectFromPalette, onToggleSidebar: toggleSidebarFromPalette, - onToggleTheme: toggleThemeFromPalette, + onToggleTheme: toggleTheme, onNavigateWorkspace: navigateWorkspaceFromPalette, onOpenWorkspaceInTerminal: openWorkspaceInTerminal, }; @@ -632,9 +625,15 @@ function AppInner() { function App() { return ( - - - + + + + + + + + + ); } diff --git a/src/browser/contexts/ThemeContext.tsx b/src/browser/contexts/ThemeContext.tsx new file mode 100644 index 0000000000..f3ea4c4aab --- /dev/null +++ b/src/browser/contexts/ThemeContext.tsx @@ -0,0 +1,40 @@ +import { createContext, useContext, useEffect, type ReactNode } from "react"; +import { usePersistedState } from "../hooks/usePersistedState"; +import { THEME_KEY } from "@/common/constants/storage"; + +type Theme = "light" | "dark"; + +interface ThemeContextValue { + theme: Theme; + setTheme: (theme: Theme) => void; + toggleTheme: () => void; +} + +const ThemeContext = createContext(undefined); + +export function ThemeProvider(props: { children: ReactNode }) { + const [theme, setTheme] = usePersistedState(THEME_KEY, "dark"); + + // Apply theme to document root + useEffect(() => { + document.documentElement.dataset.theme = theme; + }, [theme]); + + const toggleTheme = () => { + setTheme((prev) => (prev === "dark" ? "light" : "dark")); + }; + + return ( + + {props.children} + + ); +} + +export function useTheme(): ThemeContextValue { + const context = useContext(ThemeContext); + if (!context) { + throw new Error("useTheme must be used within ThemeProvider"); + } + return context; +} diff --git a/src/browser/utils/highlighting/shiki-shared.ts b/src/browser/utils/highlighting/shiki-shared.ts index 0d833cef92..23c39d5430 100644 --- a/src/browser/utils/highlighting/shiki-shared.ts +++ b/src/browser/utils/highlighting/shiki-shared.ts @@ -5,6 +5,7 @@ /** * Get the appropriate Shiki theme based on the current theme + * Falls back to reading from document if called outside React context */ export function getShikiTheme(): "min-dark" | "min-light" { if (typeof document === "undefined") return "min-dark";