Skip to content

Commit 8fea0e1

Browse files
committed
feat: add light theme with storybook toggle
1 parent cb9bdaf commit 8fea0e1

File tree

12 files changed

+464
-13
lines changed

12 files changed

+464
-13
lines changed

.storybook/preview.tsx

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,50 @@
1+
import React, { useEffect } from "react";
12
import type { Preview } from "@storybook/react-vite";
3+
import {
4+
ThemeProvider,
5+
useTheme,
6+
type ThemeMode,
7+
} from "../src/browser/contexts/ThemeContext";
28
import "../src/browser/styles/globals.css";
39

10+
const ThemeStorySync: React.FC<{ mode: ThemeMode }> = ({ mode }) => {
11+
const { theme, setTheme } = useTheme();
12+
13+
useEffect(() => {
14+
if (theme !== mode) {
15+
setTheme(mode);
16+
}
17+
}, [mode, setTheme, theme]);
18+
19+
return null;
20+
};
21+
422
const preview: Preview = {
23+
globalTypes: {
24+
theme: {
25+
name: "Theme",
26+
description: "Choose between light and dark UI themes",
27+
defaultValue: "dark",
28+
toolbar: {
29+
icon: "mirror",
30+
items: [
31+
{ value: "dark", title: "Dark" },
32+
{ value: "light", title: "Light" },
33+
],
34+
dynamicTitle: true,
35+
},
36+
},
37+
},
538
decorators: [
6-
(Story) => (
7-
<>
8-
<Story />
9-
</>
10-
),
39+
(Story, context) => {
40+
const mode = (context.globals.theme ?? "dark") as ThemeMode;
41+
return (
42+
<ThemeProvider>
43+
<ThemeStorySync mode={mode} />
44+
<Story />
45+
</ThemeProvider>
46+
);
47+
},
1148
],
1249
parameters: {
1350
controls: {
@@ -16,6 +53,12 @@ const preview: Preview = {
1653
date: /Date$/i,
1754
},
1855
},
56+
chromatic: {
57+
modes: {
58+
dark: { globals: { theme: "dark" } },
59+
light: { globals: { theme: "light" } },
60+
},
61+
},
1962
},
2063
};
2164

index.html

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,38 @@
1616
margin: 0;
1717
padding: 0;
1818
overflow: hidden;
19-
background: #1e1e1e;
19+
background: var(--color-background, #1e1e1e);
2020
}
2121
#root {
2222
height: 100vh;
2323
overflow: hidden;
2424
}
2525
</style>
26+
<script>
27+
(function () {
28+
const THEME_KEY = "uiTheme";
29+
try {
30+
const stored = window.localStorage.getItem(THEME_KEY);
31+
const parsed = stored ? JSON.parse(stored) : null;
32+
const prefersLight = window.matchMedia
33+
? window.matchMedia("(prefers-color-scheme: light)").matches
34+
: false;
35+
const theme = parsed === "light" || parsed === "dark" ? parsed : prefersLight ? "light" : "dark";
36+
37+
document.documentElement.dataset.theme = theme;
38+
document.documentElement.style.colorScheme = theme;
39+
40+
const metaThemeColor = document.querySelector('meta[name="theme-color"]');
41+
if (metaThemeColor) {
42+
metaThemeColor.setAttribute("content", theme === "light" ? "#f5f6f8" : "#1e1e1e");
43+
}
44+
} catch (error) {
45+
console.warn("Failed to apply preferred theme early", error);
46+
document.documentElement.dataset.theme = "dark";
47+
document.documentElement.style.colorScheme = "dark";
48+
}
49+
})();
50+
</script>
2651
</head>
2752
<body>
2853
<div id="root"></div>

src/browser/App.tsx

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { useStableReference, compareMaps } from "./hooks/useStableReference";
2121
import { CommandRegistryProvider, useCommandRegistry } from "./contexts/CommandRegistryContext";
2222
import type { CommandAction } from "./contexts/CommandRegistryContext";
2323
import { ModeProvider } from "./contexts/ModeContext";
24+
import { ThemeProvider, useTheme, type ThemeMode } from "./contexts/ThemeContext";
2425
import { ThinkingProvider } from "./contexts/ThinkingContext";
2526
import { CommandPalette } from "./components/CommandPalette";
2627
import { buildCoreSources, type BuildSourcesParams } from "./utils/commands/sources";
@@ -48,6 +49,13 @@ function AppInner() {
4849
beginWorkspaceCreation,
4950
clearPendingWorkspaceCreation,
5051
} = useWorkspaceContext();
52+
const { theme, setTheme, toggleTheme } = useTheme();
53+
const setThemePreference = useCallback(
54+
(nextTheme: ThemeMode) => {
55+
setTheme(nextTheme);
56+
},
57+
[setTheme]
58+
);
5159
const {
5260
projects,
5361
removeProject,
@@ -392,6 +400,7 @@ function AppInner() {
392400
projects,
393401
workspaceMetadata,
394402
selectedWorkspace,
403+
theme,
395404
getThinkingLevel: getThinkingLevelForWorkspace,
396405
onSetThinkingLevel: setThinkingLevelFromPalette,
397406
onStartWorkspaceCreation: openNewWorkspaceFromPalette,
@@ -404,6 +413,8 @@ function AppInner() {
404413
onToggleSidebar: toggleSidebarFromPalette,
405414
onNavigateWorkspace: navigateWorkspaceFromPalette,
406415
onOpenWorkspaceInTerminal: openWorkspaceInTerminal,
416+
onToggleTheme: toggleTheme,
417+
onSetTheme: setThemePreference,
407418
};
408419

409420
useEffect(() => {
@@ -618,9 +629,11 @@ function AppInner() {
618629

619630
function App() {
620631
return (
621-
<CommandRegistryProvider>
622-
<AppInner />
623-
</CommandRegistryProvider>
632+
<ThemeProvider>
633+
<CommandRegistryProvider>
634+
<AppInner />
635+
</CommandRegistryProvider>
636+
</ThemeProvider>
624637
);
625638
}
626639

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import React from "react";
2+
import { useTheme } from "@/browser/contexts/ThemeContext";
3+
import { formatKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds";
4+
import { TooltipWrapper, Tooltip } from "./Tooltip";
5+
6+
export function ThemeToggleButton() {
7+
const { theme, toggleTheme } = useTheme();
8+
const keyHint = formatKeybind(KEYBINDS.TOGGLE_THEME);
9+
const label = theme === "light" ? "Switch to dark theme" : "Switch to light theme";
10+
const icon = theme === "light" ? "🌙" : "☀️";
11+
12+
return (
13+
<TooltipWrapper>
14+
<button
15+
type="button"
16+
onClick={toggleTheme}
17+
className="border-border-light bg-toggle-bg text-foreground hover:border-border-medium hover:bg-toggle-hover flex h-7 w-7 items-center justify-center rounded-md border text-[13px] transition-colors duration-150"
18+
aria-label={`${label} (${keyHint})`}
19+
data-testid="theme-toggle"
20+
>
21+
<span aria-hidden>{icon}</span>
22+
</button>
23+
<Tooltip align="right">
24+
{label} · {keyHint}
25+
</Tooltip>
26+
</TooltipWrapper>
27+
);
28+
}

src/browser/components/TitleBar.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import React, { useState, useEffect, useRef } from "react";
22
import { cn } from "@/common/lib/utils";
33
import { VERSION } from "@/version";
4+
import { ThemeToggleButton } from "./ThemeToggleButton";
45
import { TooltipWrapper, Tooltip } from "./Tooltip";
56
import type { UpdateStatus } from "@/common/types/ipc";
67
import { isTelemetryEnabled } from "@/common/telemetry";
@@ -251,10 +252,13 @@ export function TitleBar() {
251252
mux {gitDescribe ?? "(dev)"}
252253
</div>
253254
</div>
254-
<TooltipWrapper>
255-
<div className="cursor-default text-[11px] opacity-70">{buildDate}</div>
256-
<Tooltip align="right">Built at {extendedTimestamp}</Tooltip>
257-
</TooltipWrapper>
255+
<div className="flex items-center gap-3">
256+
<ThemeToggleButton />
257+
<TooltipWrapper>
258+
<div className="cursor-default text-[11px] opacity-70">{buildDate}</div>
259+
<Tooltip align="right">Built at {extendedTimestamp}</Tooltip>
260+
</TooltipWrapper>
261+
</div>
258262
</div>
259263
);
260264
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import React, {
2+
createContext,
3+
useCallback,
4+
useContext,
5+
useEffect,
6+
useLayoutEffect,
7+
useMemo,
8+
type ReactNode,
9+
} from "react";
10+
import { usePersistedState } from "@/browser/hooks/usePersistedState";
11+
import { KEYBINDS, matchesKeybind } from "@/browser/utils/ui/keybinds";
12+
import { UI_THEME_KEY } from "@/common/constants/storage";
13+
14+
export type ThemeMode = "light" | "dark";
15+
16+
interface ThemeContextValue {
17+
theme: ThemeMode;
18+
setTheme: React.Dispatch<React.SetStateAction<ThemeMode>>;
19+
toggleTheme: () => void;
20+
}
21+
22+
const ThemeContext = createContext<ThemeContextValue | null>(null);
23+
24+
const DARK_THEME_COLOR = "#1e1e1e";
25+
const LIGHT_THEME_COLOR = "#f5f6f8";
26+
27+
function resolveSystemTheme(): ThemeMode {
28+
if (typeof window === "undefined" || !window.matchMedia) {
29+
return "dark";
30+
}
31+
32+
return window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark";
33+
}
34+
35+
function applyThemeToDocument(theme: ThemeMode) {
36+
if (typeof document === "undefined") {
37+
return;
38+
}
39+
40+
const root = document.documentElement;
41+
root.dataset.theme = theme;
42+
root.style.colorScheme = theme;
43+
44+
const themeColor = theme === "light" ? LIGHT_THEME_COLOR : DARK_THEME_COLOR;
45+
const meta = document.querySelector<HTMLMetaElement>('meta[name="theme-color"]');
46+
if (meta) {
47+
meta.setAttribute("content", themeColor);
48+
}
49+
50+
const body = document.body;
51+
if (body) {
52+
body.style.backgroundColor = "var(--color-background)";
53+
}
54+
}
55+
56+
export function ThemeProvider(props: { children: ReactNode }) {
57+
const [theme, setTheme] = usePersistedState<ThemeMode>(UI_THEME_KEY, resolveSystemTheme(), {
58+
listener: true,
59+
});
60+
61+
useLayoutEffect(() => {
62+
applyThemeToDocument(theme);
63+
}, [theme]);
64+
65+
useEffect(() => {
66+
const handleKeyDown = (event: KeyboardEvent) => {
67+
if (matchesKeybind(event, KEYBINDS.TOGGLE_THEME)) {
68+
event.preventDefault();
69+
setTheme((current) => (current === "dark" ? "light" : "dark"));
70+
}
71+
};
72+
73+
window.addEventListener("keydown", handleKeyDown);
74+
return () => window.removeEventListener("keydown", handleKeyDown);
75+
}, [setTheme]);
76+
77+
const toggleTheme = useCallback(() => {
78+
setTheme((current) => (current === "dark" ? "light" : "dark"));
79+
}, [setTheme]);
80+
81+
const value = useMemo<ThemeContextValue>(
82+
() => ({
83+
theme,
84+
setTheme,
85+
toggleTheme,
86+
}),
87+
[setTheme, theme, toggleTheme]
88+
);
89+
90+
return <ThemeContext.Provider value={value}>{props.children}</ThemeContext.Provider>;
91+
}
92+
93+
export function useTheme(): ThemeContextValue {
94+
const context = useContext(ThemeContext);
95+
if (!context) {
96+
throw new Error("useTheme must be used within a ThemeProvider");
97+
}
98+
return context;
99+
}

0 commit comments

Comments
 (0)