Skip to content

Commit 270b91f

Browse files
committed
feat: add light theme with storybook toggle
1 parent cb9bdaf commit 270b91f

File tree

13 files changed

+450
-17
lines changed

13 files changed

+450
-17
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

bun.lock

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"ghostty-web": "^0.1.1",
3131
"jsonc-parser": "^3.3.1",
3232
"lru-cache": "^11.2.2",
33+
"lucide-react": "^0.553.0",
3334
"markdown-it": "^14.1.0",
3435
"minimist": "^1.2.8",
3536
"motion": "^12.23.24",
@@ -2203,7 +2204,7 @@
22032204

22042205
"lru-cache": ["lru-cache@11.2.2", "", {}, "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg=="],
22052206

2206-
"lucide-react": ["lucide-react@0.542.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-w3hD8/SQB7+lzU2r4VdFyzzOzKnUjTZIF/MQJGSSvni7Llewni4vuViRppfRAa2guOsY5k4jZyxw/i9DQHv+dw=="],
2207+
"lucide-react": ["lucide-react@0.553.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-BRgX5zrWmNy/lkVAe0dXBgd7XQdZ3HTf+Hwe3c9WK6dqgnj9h+hxV+MDncM88xDWlCq27+TKvHGE70ViODNILw=="],
22072208

22082209
"lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
22092210

@@ -3551,6 +3552,8 @@
35513552

35523553
"stack-utils/escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="],
35533554

3555+
"streamdown/lucide-react": ["lucide-react@0.542.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-w3hD8/SQB7+lzU2r4VdFyzzOzKnUjTZIF/MQJGSSvni7Llewni4vuViRppfRAa2guOsY5k4jZyxw/i9DQHv+dw=="],
3556+
35543557
"string-length/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
35553558

35563559
"tar-fs/chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="],

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>

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,8 @@
4646
"dependencies": {
4747
"@ai-sdk/anthropic": "^2.0.44",
4848
"@ai-sdk/openai": "^2.0.66",
49+
"@homebridge/node-pty-prebuilt-multiarch": "^0.11.14",
4950
"@openrouter/ai-sdk-provider": "^1.2.2",
50-
"ghostty-web": "^0.1.1",
5151
"@radix-ui/react-dialog": "^1.1.15",
5252
"@radix-ui/react-dropdown-menu": "^2.1.16",
5353
"@radix-ui/react-scroll-area": "^1.2.10",
@@ -66,13 +66,14 @@
6666
"disposablestack": "^1.1.7",
6767
"electron-updater": "^6.6.2",
6868
"express": "^5.1.0",
69+
"ghostty-web": "^0.1.1",
6970
"jsonc-parser": "^3.3.1",
7071
"lru-cache": "^11.2.2",
72+
"lucide-react": "^0.553.0",
7173
"markdown-it": "^14.1.0",
7274
"minimist": "^1.2.8",
7375
"motion": "^12.23.24",
7476
"ollama-ai-provider-v2": "^1.5.4",
75-
"@homebridge/node-pty-prebuilt-multiarch": "^0.11.14",
7677
"rehype-harden": "^1.1.5",
7778
"shescape": "^2.1.6",
7879
"source-map-support": "^0.5.21",

src/browser/App.tsx

Lines changed: 17 additions & 4 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(() => {
@@ -586,7 +597,7 @@ function AppInner() {
586597
})()
587598
) : (
588599
<div
589-
className="[&_p]:text-muted mx-auto w-full max-w-3xl text-center [&_h2]:mb-4 [&_h2]:font-bold [&_h2]:tracking-tight [&_h2]:text-white [&_p]:leading-[1.6]"
600+
className="[&_p]:text-muted mx-auto w-full max-w-3xl text-center [&_h2]:mb-4 [&_h2]:font-bold [&_h2]:tracking-tight [&_h2]:text-foreground [&_p]:leading-[1.6]"
590601
style={{
591602
padding: "clamp(40px, 10vh, 100px) 20px",
592603
fontSize: "clamp(14px, 2vw, 16px)",
@@ -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: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { MoonStar, SunMedium } from "lucide-react";
2+
import { useTheme } from "@/browser/contexts/ThemeContext";
3+
import { TooltipWrapper, Tooltip } from "./Tooltip";
4+
5+
export function ThemeToggleButton() {
6+
const { theme, toggleTheme } = useTheme();
7+
const label = theme === "light" ? "Switch to dark theme" : "Switch to light theme";
8+
const Icon = theme === "light" ? MoonStar : SunMedium;
9+
10+
return (
11+
<TooltipWrapper>
12+
<button
13+
type="button"
14+
onClick={toggleTheme}
15+
className="border-border-light text-muted-foreground hover:border-border-medium/80 hover:bg-toggle-bg/70 focus-visible:ring-1 focus-visible:ring-border-medium flex h-7 w-7 items-center justify-center rounded-md border bg-transparent transition-colors duration-150"
16+
aria-label={label}
17+
data-testid="theme-toggle"
18+
>
19+
<Icon className="h-4 w-4" aria-hidden />
20+
</button>
21+
<Tooltip align="right">{label}</Tooltip>
22+
</TooltipWrapper>
23+
);
24+
}

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: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import React, {
2+
createContext,
3+
useCallback,
4+
useContext,
5+
useLayoutEffect,
6+
useMemo,
7+
type ReactNode,
8+
} from "react";
9+
import { usePersistedState } from "@/browser/hooks/usePersistedState";
10+
import { UI_THEME_KEY } from "@/common/constants/storage";
11+
12+
export type ThemeMode = "light" | "dark";
13+
14+
interface ThemeContextValue {
15+
theme: ThemeMode;
16+
setTheme: React.Dispatch<React.SetStateAction<ThemeMode>>;
17+
toggleTheme: () => void;
18+
}
19+
20+
const ThemeContext = createContext<ThemeContextValue | null>(null);
21+
22+
const DARK_THEME_COLOR = "#1e1e1e";
23+
const LIGHT_THEME_COLOR = "#f5f6f8";
24+
25+
function resolveSystemTheme(): ThemeMode {
26+
if (typeof window === "undefined" || !window.matchMedia) {
27+
return "dark";
28+
}
29+
30+
return window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark";
31+
}
32+
33+
function applyThemeToDocument(theme: ThemeMode) {
34+
if (typeof document === "undefined") {
35+
return;
36+
}
37+
38+
const root = document.documentElement;
39+
root.dataset.theme = theme;
40+
root.style.colorScheme = theme;
41+
42+
const themeColor = theme === "light" ? LIGHT_THEME_COLOR : DARK_THEME_COLOR;
43+
const meta = document.querySelector<HTMLMetaElement>('meta[name="theme-color"]');
44+
if (meta) {
45+
meta.setAttribute("content", themeColor);
46+
}
47+
48+
const body = document.body;
49+
if (body) {
50+
body.style.backgroundColor = "var(--color-background)";
51+
}
52+
}
53+
54+
export function ThemeProvider(props: { children: ReactNode }) {
55+
const [theme, setTheme] = usePersistedState<ThemeMode>(UI_THEME_KEY, resolveSystemTheme(), {
56+
listener: true,
57+
});
58+
59+
useLayoutEffect(() => {
60+
applyThemeToDocument(theme);
61+
}, [theme]);
62+
63+
const toggleTheme = useCallback(() => {
64+
setTheme((current) => (current === "dark" ? "light" : "dark"));
65+
}, [setTheme]);
66+
67+
const value = useMemo<ThemeContextValue>(
68+
() => ({
69+
theme,
70+
setTheme,
71+
toggleTheme,
72+
}),
73+
[setTheme, theme, toggleTheme]
74+
);
75+
76+
return <ThemeContext.Provider value={value}>{props.children}</ThemeContext.Provider>;
77+
}
78+
79+
export function useTheme(): ThemeContextValue {
80+
const context = useContext(ThemeContext);
81+
if (!context) {
82+
throw new Error("useTheme must be used within a ThemeProvider");
83+
}
84+
return context;
85+
}

0 commit comments

Comments
 (0)