Skip to content

Commit 853885e

Browse files
committed
🤖 feat: add light theme support with toggle keybind
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`_
1 parent a55417a commit 853885e

File tree

12 files changed

+262
-32
lines changed

12 files changed

+262
-32
lines changed

src/browser/App.tsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import { buildCoreSources, type BuildSourcesParams } from "./utils/commands/sour
2828
import type { ThinkingLevel } from "@/common/types/thinking";
2929
import { CUSTOM_EVENTS } from "@/common/constants/events";
3030
import { isWorkspaceForkSwitchEvent } from "./utils/workspaceFork";
31-
import { getThinkingLevelKey } from "@/common/constants/storage";
31+
import { getThinkingLevelKey, THEME_KEY } from "@/common/constants/storage";
3232
import type { BranchListResult } from "@/common/types/ipc";
3333
import { useTelemetry } from "./hooks/useTelemetry";
3434
import { useStartWorkspaceCreation, getFirstProjectPath } from "./hooks/useStartWorkspaceCreation";
@@ -60,6 +60,15 @@ function AppInner() {
6060
// Auto-collapse sidebar on mobile by default
6161
const isMobile = typeof window !== "undefined" && window.innerWidth <= 768;
6262
const [sidebarCollapsed, setSidebarCollapsed] = usePersistedState("sidebarCollapsed", isMobile);
63+
64+
// Theme state (global, not workspace-specific)
65+
const [theme, setTheme] = usePersistedState<"light" | "dark">(THEME_KEY, "dark");
66+
67+
// Apply theme to document root
68+
useEffect(() => {
69+
document.documentElement.dataset.theme = theme;
70+
}, [theme]);
71+
6372
const defaultProjectPath = getFirstProjectPath(projects);
6473
const creationChatInputRef = useRef<ChatInputAPI | null>(null);
6574
const creationProjectPath = !selectedWorkspace
@@ -381,6 +390,10 @@ function AppInner() {
381390
setSidebarCollapsed((prev) => !prev);
382391
}, [setSidebarCollapsed]);
383392

393+
const toggleTheme = useCallback(() => {
394+
setTheme((prev) => (prev === "dark" ? "light" : "dark"));
395+
}, [setTheme]);
396+
384397
const navigateWorkspaceFromPalette = useCallback(
385398
(dir: "next" | "prev") => {
386399
handleNavigateWorkspace(dir);
@@ -402,6 +415,7 @@ function AppInner() {
402415
onAddProject: addProjectFromPalette,
403416
onRemoveProject: removeProjectFromPalette,
404417
onToggleSidebar: toggleSidebarFromPalette,
418+
onToggleTheme: toggleTheme,
405419
onNavigateWorkspace: navigateWorkspaceFromPalette,
406420
onOpenWorkspaceInTerminal: openWorkspaceInTerminal,
407421
};
@@ -449,6 +463,9 @@ function AppInner() {
449463
} else if (matchesKeybind(e, KEYBINDS.TOGGLE_SIDEBAR)) {
450464
e.preventDefault();
451465
setSidebarCollapsed((prev) => !prev);
466+
} else if (matchesKeybind(e, KEYBINDS.TOGGLE_THEME)) {
467+
e.preventDefault();
468+
toggleTheme();
452469
}
453470
};
454471

@@ -457,6 +474,7 @@ function AppInner() {
457474
}, [
458475
handleNavigateWorkspace,
459476
setSidebarCollapsed,
477+
toggleTheme,
460478
isCommandPaletteOpen,
461479
closeCommandPalette,
462480
openCommandPalette,

src/browser/components/Messages/MarkdownComponents.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,8 @@ import { Mermaid } from "./Mermaid";
44
import {
55
getShikiHighlighter,
66
mapToShikiLang,
7-
SHIKI_THEME,
87
} from "@/browser/utils/highlighting/shikiHighlighter";
9-
import { extractShikiLines } from "@/browser/utils/highlighting/shiki-shared";
8+
import { extractShikiLines, getShikiTheme } from "@/browser/utils/highlighting/shiki-shared";
109
import { CopyButton } from "@/browser/components/ui/CopyButton";
1110

1211
interface CodeProps {
@@ -79,7 +78,7 @@ const CodeBlock: React.FC<CodeBlockProps> = ({ code, language }) => {
7978

8079
const html = highlighter.codeToHtml(code, {
8180
lang: shikiLang,
82-
theme: SHIKI_THEME,
81+
theme: getShikiTheme(),
8382
});
8483

8584
if (!cancelled) {

src/browser/components/Messages/Mermaid.tsx

Lines changed: 33 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -7,26 +7,36 @@ import { usePersistedState } from "@/browser/hooks/usePersistedState";
77
const MIN_HEIGHT = 300;
88
const MAX_HEIGHT = 1200;
99

10-
// Initialize mermaid
11-
mermaid.initialize({
12-
startOnLoad: false,
13-
theme: "dark",
14-
layout: "elk",
15-
securityLevel: "loose",
16-
fontFamily: "var(--font-monospace)",
17-
darkMode: true,
18-
elk: {
19-
nodePlacementStrategy: "LINEAR_SEGMENTS",
20-
mergeEdges: true,
21-
},
22-
wrap: true,
23-
markdownAutoWrap: true,
24-
flowchart: {
25-
nodeSpacing: 60,
26-
curve: "linear",
27-
defaultRenderer: "elk",
28-
},
29-
});
10+
/**
11+
* Get mermaid theme configuration based on current theme
12+
*/
13+
function getMermaidConfig() {
14+
const isDark =
15+
typeof document === "undefined" || document.documentElement.dataset.theme !== "light";
16+
const theme: "dark" | "default" = isDark ? "dark" : "default";
17+
return {
18+
startOnLoad: false,
19+
theme,
20+
layout: "elk",
21+
securityLevel: "loose" as const,
22+
fontFamily: "var(--font-monospace)",
23+
darkMode: isDark,
24+
elk: {
25+
nodePlacementStrategy: "LINEAR_SEGMENTS" as const,
26+
mergeEdges: true,
27+
},
28+
wrap: true,
29+
markdownAutoWrap: true,
30+
flowchart: {
31+
nodeSpacing: 60,
32+
curve: "linear" as const,
33+
defaultRenderer: "elk" as const,
34+
},
35+
};
36+
}
37+
38+
// Initialize mermaid with default dark theme
39+
mermaid.initialize(getMermaidConfig());
3040

3141
// Common button styles
3242
const getButtonStyle = (disabled = false): CSSProperties => ({
@@ -141,6 +151,9 @@ export const Mermaid: React.FC<{ chart: string }> = ({ chart }) => {
141151
try {
142152
setError(null);
143153

154+
// Re-initialize mermaid with current theme
155+
mermaid.initialize(getMermaidConfig());
156+
144157
// Parse first to validate syntax without rendering
145158
await mermaid.parse(chart);
146159

src/browser/styles/globals.css

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,182 @@
189189
/* Error backgrounds */
190190
--color-error-bg-dark: hsl(0 33% 13%); /* #3c1f1f - dark error bg */
191191

192+
193+
[data-theme="light"] @theme {
194+
/* Mode Colors - Keep hue, adjust lightness for light backgrounds */
195+
--color-plan-mode: hsl(210 80% 45%);
196+
--color-plan-mode-hover: hsl(210 80% 38%);
197+
--color-plan-mode-light: hsl(210 80% 55%);
198+
199+
--color-exec-mode: hsl(268.56 70% 50%);
200+
--color-exec-mode-hover: hsl(268.56 70% 43%);
201+
--color-exec-mode-light: hsl(268.56 70% 60%);
202+
203+
--color-edit-mode: hsl(120 45% 38%);
204+
--color-edit-mode-hover: hsl(120 45% 32%);
205+
--color-edit-mode-light: hsl(120 45% 48%);
206+
207+
--color-read: hsl(210 80% 45%);
208+
--color-editing-mode: hsl(30 100% 40%);
209+
--color-pending: hsl(30 100% 50%);
210+
211+
--color-debug-mode: hsl(214 100% 50%);
212+
--color-debug-light: hsl(214 100% 60%);
213+
--color-debug-text: hsl(214 100% 45%);
214+
215+
--color-thinking-mode: hsl(271 76% 48%);
216+
--color-thinking-mode-light: hsl(271 76% 58%);
217+
--color-thinking-border: hsl(271 76% 48%);
218+
219+
/* Background & Layout - Inverted */
220+
--color-background: hsl(0 0% 98%);
221+
--color-background-secondary: hsl(0 0% 96%);
222+
--color-border: hsl(240 3% 85%);
223+
--color-foreground: hsl(0 0% 15%);
224+
--color-muted-foreground: hsl(0 0% 40%);
225+
--color-secondary: hsl(0 0% 55%);
226+
227+
/* Code */
228+
--color-code-bg: hsl(0 0% 95%);
229+
230+
/* Buttons */
231+
--color-button-bg: hsl(0 0% 90%);
232+
--color-button-text: hsl(0 0% 20%);
233+
--color-button-hover: hsl(0 0% 85%);
234+
235+
/* Messages */
236+
--color-user-border: hsl(0 0% 70%);
237+
--color-user-border-hover: hsl(0 0% 65%);
238+
--color-assistant-border: hsl(207 45% 60%);
239+
--color-assistant-border-hover: hsl(207 45% 55%);
240+
--color-message-header: hsl(0 0% 20%);
241+
242+
/* Tokens */
243+
--color-token-prompt: hsl(0 0% 55%);
244+
--color-token-completion: hsl(207 100% 45%);
245+
--color-token-variable: hsl(207 100% 45%);
246+
--color-token-fixed: hsl(0 0% 55%);
247+
--color-token-input: hsl(120 40% 38%);
248+
--color-token-output: hsl(207 100% 45%);
249+
--color-token-cached: hsl(0 0% 50%);
250+
251+
/* Toggle */
252+
--color-toggle-bg: hsl(0 0% 92%);
253+
--color-toggle-active: hsl(0 0% 88%);
254+
--color-toggle-hover: hsl(0 0% 90%);
255+
--color-toggle-text: hsl(0 0% 45%);
256+
--color-toggle-text-active: hsl(0 0% 10%);
257+
--color-toggle-text-hover: hsl(0 0% 30%);
258+
259+
/* Status */
260+
--color-interrupted: hsl(38 92% 45%);
261+
--color-review-accent: hsl(48 70% 45%);
262+
--color-git-dirty: hsl(38 92% 45%);
263+
--color-error: hsl(0 70% 50%);
264+
--color-error-bg: hsl(0 50% 95%);
265+
266+
/* Input */
267+
--color-input-bg: hsl(0 0% 100%);
268+
--color-input-text: hsl(0 0% 15%);
269+
--color-input-border: hsl(207 51% 65%);
270+
--color-input-border-focus: hsl(193 91% 55%);
271+
272+
/* Scrollbar */
273+
--color-scrollbar-track: hsl(0 0% 93%);
274+
--color-scrollbar-thumb: hsl(0 0% 75%);
275+
--color-scrollbar-thumb-hover: hsl(0 0% 65%);
276+
277+
/* Additional Semantic Colors */
278+
--color-muted: hsl(0 0% 50%);
279+
--color-muted-light: hsl(0 0% 55%);
280+
--color-muted-dark: hsl(0 0% 45%);
281+
--color-placeholder: hsl(0 0% 60%);
282+
--color-subtle: hsl(0 0% 45%);
283+
--color-dim: hsl(0 0% 55%);
284+
--color-light: hsl(0 0% 25%);
285+
--color-lighter: hsl(0 0% 20%);
286+
--color-bright: hsl(0 0% 30%);
287+
--color-subdued: hsl(0 0% 45%);
288+
--color-label: hsl(0 0% 40%);
289+
--color-gray: hsl(0 0% 52%);
290+
--color-medium: hsl(0 0% 45%);
291+
292+
--color-border-light: hsl(240 3% 88%);
293+
--color-border-medium: hsl(0 0% 82%);
294+
--color-border-darker: hsl(0 0% 78%);
295+
--color-border-subtle: hsl(0 0% 75%);
296+
--color-border-gray: hsl(240 1% 80%);
297+
298+
--color-dark: hsl(0 0% 93%);
299+
--color-darker: hsl(0 0% 95%);
300+
--color-hover: hsl(0 0% 92%);
301+
--color-bg-medium: hsl(0 0% 85%);
302+
--color-bg-light: hsl(0 0% 88%);
303+
--color-bg-subtle: hsl(240 3% 94%);
304+
305+
--color-separator: hsl(0 0% 90%);
306+
--color-separator-light: hsl(0 0% 85%);
307+
--color-modal-bg: hsl(0 0% 97%);
308+
309+
--color-accent: hsl(207 100% 45%);
310+
--color-accent-hover: hsl(207 100% 40%);
311+
--color-accent-dark: hsl(207 100% 38%);
312+
--color-accent-darker: hsl(202 100% 30%);
313+
--color-accent-light: hsl(198 100% 55%);
314+
315+
--color-success: hsl(122 39% 45%);
316+
--color-success-light: hsl(123 46% 55%);
317+
318+
--color-danger: hsl(4 90% 52%);
319+
--color-danger-light: hsl(0 91% 62%);
320+
--color-danger-soft: hsl(6 93% 60%);
321+
322+
--color-warning: hsl(45 100% 45%);
323+
--color-warning-light: hsl(0 91% 65%);
324+
325+
/* Code syntax highlighting */
326+
--color-code-type: hsl(197 71% 40%);
327+
--color-code-keyword: hsl(210 59% 45%);
328+
329+
/* Toast and notification backgrounds */
330+
--color-toast-success-bg: hsl(207 100% 45% / 0.1);
331+
--color-toast-success-text: hsl(207 100% 40%);
332+
--color-toast-error-bg: hsl(5 89% 50% / 0.1);
333+
--color-toast-error-text: hsl(5 89% 45%);
334+
--color-toast-error-border: hsl(5 89% 50%);
335+
--color-toast-fatal-bg: hsl(0 50% 95%);
336+
--color-toast-fatal-border: hsl(0 50% 85%);
337+
338+
/* Semi-transparent overlays */
339+
--color-danger-overlay: hsl(4 90% 52% / 0.1);
340+
--color-warning-overlay: hsl(45 100% 45% / 0.1);
341+
--color-gray-overlay: hsl(0 0% 50% / 0.05);
342+
--color-white-overlay-light: hsl(0 0% 0% / 0.03);
343+
--color-white-overlay: hsl(0 0% 0% / 0.05);
344+
--color-selection: hsl(204 100% 65% / 0.3);
345+
--color-vim-status: hsl(0 0% 20% / 0.6);
346+
--color-code-keyword-overlay-light: hsl(210 100% 50% / 0.05);
347+
--color-code-keyword-overlay: hsl(210 100% 50% / 0.15);
348+
349+
/* Info/status colors */
350+
--color-info-light: hsl(5 100% 60%);
351+
--color-info-yellow: hsl(38 100% 55%);
352+
353+
/* Review/diff backgrounds */
354+
--color-review-bg-blue: hsl(201 31% 90%);
355+
--color-review-bg-info: hsl(202 33% 92%);
356+
--color-review-bg-warning: hsl(40 100% 95%);
357+
--color-review-warning: hsl(38 100% 40%);
358+
--color-review-warning-medium: hsl(38 100% 45%);
359+
--color-review-warning-light: hsl(40 100% 92%);
360+
361+
/* Error backgrounds */
362+
--color-error-bg-dark: hsl(0 50% 93%);
363+
364+
/* Radius */
365+
--radius: 0.5rem;
366+
}
367+
192368
/* Radius */
193369
--radius: 0.5rem;
194370
}

src/browser/utils/commandIds.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export const CommandIds = {
3333
navNext: () => "nav:next" as const,
3434
navPrev: () => "nav:prev" as const,
3535
navToggleSidebar: () => "nav:toggleSidebar" as const,
36+
navToggleTheme: () => "nav:toggleTheme" as const,
3637

3738
// Chat commands
3839
chatClear: () => "chat:clear" as const,

src/browser/utils/commands/sources.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ const mk = (over: Partial<Parameters<typeof buildCoreSources>[0]> = {}) => {
4444
onAddProject: () => undefined,
4545
onRemoveProject: () => undefined,
4646
onToggleSidebar: () => undefined,
47+
onToggleTheme: () => undefined,
4748
onNavigateWorkspace: () => undefined,
4849
onOpenWorkspaceInTerminal: () => undefined,
4950
getBranchesForProject: () =>

src/browser/utils/commands/sources.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export interface BuildSourcesParams {
3939
onAddProject: () => void;
4040
onRemoveProject: (path: string) => void;
4141
onToggleSidebar: () => void;
42+
onToggleTheme: () => void;
4243
onNavigateWorkspace: (dir: "next" | "prev") => void;
4344
onOpenWorkspaceInTerminal: (workspaceId: string) => void;
4445
}
@@ -303,6 +304,13 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi
303304
shortcutHint: formatKeybind(KEYBINDS.TOGGLE_SIDEBAR),
304305
run: () => p.onToggleSidebar(),
305306
},
307+
{
308+
id: CommandIds.navToggleTheme(),
309+
title: "Toggle Theme",
310+
section: section.navigation,
311+
shortcutHint: formatKeybind(KEYBINDS.TOGGLE_THEME),
312+
run: () => p.onToggleTheme(),
313+
},
306314
]);
307315

308316
// Chat utilities

src/browser/utils/highlighting/highlightDiffChunk.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import {
22
getShikiHighlighter,
33
mapToShikiLang,
4-
SHIKI_THEME,
54
MAX_DIFF_SIZE_BYTES,
65
} from "./shikiHighlighter";
6+
import { getShikiTheme } from "./shiki-shared";
77
import type { DiffChunk } from "./diffChunking";
88

99
/**
@@ -83,7 +83,7 @@ export async function highlightDiffChunk(
8383

8484
const html = highlighter.codeToHtml(code, {
8585
lang: shikiLang,
86-
theme: SHIKI_THEME,
86+
theme: getShikiTheme(),
8787
});
8888

8989
// Parse HTML to extract line contents

0 commit comments

Comments
 (0)