From 0b17b5226d7f241b8ff206b6c65c913ad6ab774c Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Sun, 16 Nov 2025 15:27:58 -0500 Subject: [PATCH 1/4] feat: add light theme with storybook toggle --- .storybook/preview.tsx | 53 ++++- bun.lock | 5 +- index.html | 27 ++- package.json | 1 + src/browser/App.tsx | 21 +- .../components/Messages/MessageWindow.tsx | 6 +- src/browser/components/Modal.tsx | 2 +- src/browser/components/ModelSelector.tsx | 4 +- src/browser/components/ProjectCreateModal.tsx | 4 +- src/browser/components/ThemeToggleButton.tsx | 24 +++ src/browser/components/TitleBar.tsx | 12 +- src/browser/contexts/ThemeContext.tsx | 85 ++++++++ src/browser/styles/globals.css | 186 ++++++++++++++++++ src/browser/utils/commandIds.ts | 4 + src/browser/utils/commands/sources.test.ts | 3 + src/browser/utils/commands/sources.ts | 38 ++++ src/common/constants/storage.ts | 6 + 17 files changed, 458 insertions(+), 23 deletions(-) create mode 100644 src/browser/components/ThemeToggleButton.tsx create mode 100644 src/browser/contexts/ThemeContext.tsx diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index e1ddd4ca8..c2541ba7b 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -1,13 +1,50 @@ +import React, { useEffect } from "react"; import type { Preview } from "@storybook/react-vite"; +import { + ThemeProvider, + useTheme, + type ThemeMode, +} from "../src/browser/contexts/ThemeContext"; import "../src/browser/styles/globals.css"; +const ThemeStorySync: React.FC<{ mode: ThemeMode }> = ({ mode }) => { + const { theme, setTheme } = useTheme(); + + useEffect(() => { + if (theme !== mode) { + setTheme(mode); + } + }, [mode, setTheme, theme]); + + return null; +}; + const preview: Preview = { + globalTypes: { + theme: { + name: "Theme", + description: "Choose between light and dark UI themes", + defaultValue: "dark", + toolbar: { + icon: "mirror", + items: [ + { value: "dark", title: "Dark" }, + { value: "light", title: "Light" }, + ], + dynamicTitle: true, + }, + }, + }, decorators: [ - (Story) => ( - <> - - - ), + (Story, context) => { + const mode = (context.globals.theme ?? "dark") as ThemeMode; + return ( + + + + + ); + }, ], parameters: { controls: { @@ -16,6 +53,12 @@ const preview: Preview = { date: /Date$/i, }, }, + chromatic: { + modes: { + dark: { globals: { theme: "dark" } }, + light: { globals: { theme: "light" } }, + }, + }, }, }; diff --git a/bun.lock b/bun.lock index 2d4ab805b..959a6bef4 100644 --- a/bun.lock +++ b/bun.lock @@ -34,6 +34,7 @@ "ghostty-web": "next", "jsonc-parser": "^3.3.1", "lru-cache": "^11.2.2", + "lucide-react": "^0.553.0", "markdown-it": "^14.1.0", "minimist": "^1.2.8", "motion": "^12.23.24", @@ -2226,7 +2227,7 @@ "lru-cache": ["lru-cache@11.2.2", "", {}, "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg=="], - "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=="], + "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=="], "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], @@ -3514,6 +3515,8 @@ "stack-utils/escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="], + "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=="], + "string-length/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], "string_decoder/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], diff --git a/index.html b/index.html index a96f986c4..464c26382 100644 --- a/index.html +++ b/index.html @@ -16,13 +16,38 @@ margin: 0; padding: 0; overflow: hidden; - background: #1e1e1e; + background: var(--color-background, #1e1e1e); } #root { height: 100vh; overflow: hidden; } +
diff --git a/package.json b/package.json index 0c74a3ab8..3d846310e 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,7 @@ "ghostty-web": "next", "jsonc-parser": "^3.3.1", "lru-cache": "^11.2.2", + "lucide-react": "^0.553.0", "markdown-it": "^14.1.0", "minimist": "^1.2.8", "motion": "^12.23.24", diff --git a/src/browser/App.tsx b/src/browser/App.tsx index 585a75550..e861d8dae 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -21,6 +21,7 @@ import { CommandRegistryProvider, useCommandRegistry } from "./contexts/CommandR import type { CommandAction } from "./contexts/CommandRegistryContext"; import { ModeProvider } from "./contexts/ModeContext"; import { ProviderOptionsProvider } from "./contexts/ProviderOptionsContext"; +import { ThemeProvider, useTheme, type ThemeMode } from "./contexts/ThemeContext"; import { ThinkingProvider } from "./contexts/ThinkingContext"; import { CommandPalette } from "./components/CommandPalette"; import { buildCoreSources, type BuildSourcesParams } from "./utils/commands/sources"; @@ -48,6 +49,13 @@ function AppInner() { beginWorkspaceCreation, clearPendingWorkspaceCreation, } = useWorkspaceContext(); + const { theme, setTheme, toggleTheme } = useTheme(); + const setThemePreference = useCallback( + (nextTheme: ThemeMode) => { + setTheme(nextTheme); + }, + [setTheme] + ); const { projects, removeProject, @@ -389,6 +397,7 @@ function AppInner() { projects, workspaceMetadata, selectedWorkspace, + theme, getThinkingLevel: getThinkingLevelForWorkspace, onSetThinkingLevel: setThinkingLevelFromPalette, onStartWorkspaceCreation: openNewWorkspaceFromPalette, @@ -401,6 +410,8 @@ function AppInner() { onToggleSidebar: toggleSidebarFromPalette, onNavigateWorkspace: navigateWorkspaceFromPalette, onOpenWorkspaceInTerminal: openWorkspaceInTerminal, + onToggleTheme: toggleTheme, + onSetTheme: setThemePreference, }; useEffect(() => { @@ -587,7 +598,7 @@ function AppInner() { })() ) : (
- - + + + + + ); } diff --git a/src/browser/components/Messages/MessageWindow.tsx b/src/browser/components/Messages/MessageWindow.tsx index cb95a5e5c..4bf0bc9a6 100644 --- a/src/browser/components/Messages/MessageWindow.tsx +++ b/src/browser/components/Messages/MessageWindow.tsx @@ -66,7 +66,7 @@ export const MessageWindow: React.FC = ({ className={cn( "mt-4 mb-1 flex w-full flex-col relative isolate w-fit", variant === "user" && "ml-auto", - variant === "assistant" && "text-white", + variant === "assistant" && "text-foreground", isLastPartOfMessage && "mb-4" )} data-message-block @@ -74,7 +74,7 @@ export const MessageWindow: React.FC = ({
@@ -95,7 +95,7 @@ export const MessageWindow: React.FC = ({
diff --git a/src/browser/components/Modal.tsx b/src/browser/components/Modal.tsx index edd311a19..690922955 100644 --- a/src/browser/components/Modal.tsx +++ b/src/browser/components/Modal.tsx @@ -29,7 +29,7 @@ export const ModalContent: React.FC<
( "text-[11px] font-monospace py-1.5 px-2.5 cursor-pointer transition-colors duration-100", "first:rounded-t last:rounded-b", index === highlightedIndex - ? "text-white bg-hover" - : "text-light bg-transparent hover:bg-hover hover:text-white" + ? "text-foreground bg-hover" + : "text-light bg-transparent hover:bg-hover hover:text-foreground" )} onClick={() => handleSelectModel(model)} > diff --git a/src/browser/components/ProjectCreateModal.tsx b/src/browser/components/ProjectCreateModal.tsx index 6a7f51bec..90e700821 100644 --- a/src/browser/components/ProjectCreateModal.tsx +++ b/src/browser/components/ProjectCreateModal.tsx @@ -142,14 +142,14 @@ export const ProjectCreateModal: React.FC = ({ placeholder="/home/user/projects/my-project" autoFocus disabled={isCreating} - className="bg-modal-bg border-border-medium focus:border-accent placeholder:text-muted w-full flex-1 rounded border px-3 py-2 font-mono text-sm text-white focus:outline-none disabled:opacity-50" + className="bg-modal-bg border-border-medium focus:border-accent placeholder:text-muted w-full flex-1 rounded border px-3 py-2 font-mono text-sm text-foreground focus:outline-none disabled:opacity-50" /> {(isDesktop || hasWebFsPicker) && ( diff --git a/src/browser/components/ThemeToggleButton.tsx b/src/browser/components/ThemeToggleButton.tsx new file mode 100644 index 000000000..d6f79bb1f --- /dev/null +++ b/src/browser/components/ThemeToggleButton.tsx @@ -0,0 +1,24 @@ +import { MoonStar, SunMedium } from "lucide-react"; +import { useTheme } from "@/browser/contexts/ThemeContext"; +import { TooltipWrapper, Tooltip } from "./Tooltip"; + +export function ThemeToggleButton() { + const { theme, toggleTheme } = useTheme(); + const label = theme === "light" ? "Switch to dark theme" : "Switch to light theme"; + const Icon = theme === "light" ? MoonStar : SunMedium; + + return ( + + + {label} + + ); +} diff --git a/src/browser/components/TitleBar.tsx b/src/browser/components/TitleBar.tsx index 42a68798b..85bc84215 100644 --- a/src/browser/components/TitleBar.tsx +++ b/src/browser/components/TitleBar.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect, useRef } from "react"; import { cn } from "@/common/lib/utils"; import { VERSION } from "@/version"; +import { ThemeToggleButton } from "./ThemeToggleButton"; import { TooltipWrapper, Tooltip } from "./Tooltip"; import type { UpdateStatus } from "@/common/types/ipc"; import { isTelemetryEnabled } from "@/common/telemetry"; @@ -251,10 +252,13 @@ export function TitleBar() { mux {gitDescribe ?? "(dev)"}
- -
{buildDate}
- Built at {extendedTimestamp} -
+
+ + +
{buildDate}
+ Built at {extendedTimestamp} +
+
); } diff --git a/src/browser/contexts/ThemeContext.tsx b/src/browser/contexts/ThemeContext.tsx new file mode 100644 index 000000000..1ead055a5 --- /dev/null +++ b/src/browser/contexts/ThemeContext.tsx @@ -0,0 +1,85 @@ +import React, { + createContext, + useCallback, + useContext, + useLayoutEffect, + useMemo, + type ReactNode, +} from "react"; +import { usePersistedState } from "@/browser/hooks/usePersistedState"; +import { UI_THEME_KEY } from "@/common/constants/storage"; + +export type ThemeMode = "light" | "dark"; + +interface ThemeContextValue { + theme: ThemeMode; + setTheme: React.Dispatch>; + toggleTheme: () => void; +} + +const ThemeContext = createContext(null); + +const DARK_THEME_COLOR = "#1e1e1e"; +const LIGHT_THEME_COLOR = "#f5f6f8"; + +function resolveSystemTheme(): ThemeMode { + if (typeof window === "undefined" || !window.matchMedia) { + return "dark"; + } + + return window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark"; +} + +function applyThemeToDocument(theme: ThemeMode) { + if (typeof document === "undefined") { + return; + } + + const root = document.documentElement; + root.dataset.theme = theme; + root.style.colorScheme = theme; + + const themeColor = theme === "light" ? LIGHT_THEME_COLOR : DARK_THEME_COLOR; + const meta = document.querySelector('meta[name="theme-color"]'); + if (meta) { + meta.setAttribute("content", themeColor); + } + + const body = document.body; + if (body) { + body.style.backgroundColor = "var(--color-background)"; + } +} + +export function ThemeProvider(props: { children: ReactNode }) { + const [theme, setTheme] = usePersistedState(UI_THEME_KEY, resolveSystemTheme(), { + listener: true, + }); + + useLayoutEffect(() => { + applyThemeToDocument(theme); + }, [theme]); + + const toggleTheme = useCallback(() => { + setTheme((current) => (current === "dark" ? "light" : "dark")); + }, [setTheme]); + + const value = useMemo( + () => ({ + theme, + setTheme, + toggleTheme, + }), + [setTheme, theme, toggleTheme] + ); + + return {props.children}; +} + +export function useTheme(): ThemeContextValue { + const context = useContext(ThemeContext); + if (!context) { + throw new Error("useTheme must be used within a ThemeProvider"); + } + return context; +} diff --git a/src/browser/styles/globals.css b/src/browser/styles/globals.css index c2473a18b..583516e1a 100644 --- a/src/browser/styles/globals.css +++ b/src/browser/styles/globals.css @@ -83,6 +83,7 @@ /* Messages */ --color-user-border: hsl(0 0% 45%); + --color-user-surface: hsla(0 0% 100% / 0.06); --color-user-border-hover: hsl(0 0% 44%); --color-assistant-border: hsl(207 45% 40%); --color-assistant-border-hover: hsl(207 45% 50%); @@ -249,6 +250,191 @@ "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; } +:root[data-theme="light"] { + color-scheme: light; + + --color-plan-mode: hsl(210 70% 40%); + --color-plan-mode-hover: hsl(210 70% 52%); + --color-plan-mode-light: hsl(210 70% 68%); + --color-plan-mode-alpha: hsla(210 70% 40% / 0.08); + + --color-exec-mode: hsl(268.56 94.04% 55.19%); + --color-exec-mode-hover: hsl(268.56 94.04% 67%); + --color-exec-mode-light: hsl(268.56 94.04% 78%); + + --color-edit-mode: hsl(120 50% 35%); + --color-edit-mode-hover: hsl(120 50% 45%); + --color-edit-mode-light: hsl(120 50% 60%); + + --color-read: hsl(210 70% 40%); + --color-editing-mode: hsl(30 100% 50%); + --color-editing-mode-alpha: hsla(30 100% 50% / 0.08); + --color-pending: hsl(30 100% 64%); + + --color-debug-mode: hsl(214 100% 56%); + --color-debug-light: hsl(214 100% 68%); + --color-debug-text: hsl(214 100% 32%); + + --color-thinking-mode: hsl(271 70% 46%); + --color-thinking-mode-light: hsl(271 70% 62%); + --color-thinking-border: hsl(271 70% 46%); + + --color-background: hsl(210 33% 98%); + --color-background-secondary: hsl(210 36% 95%); + --color-border: hsl(210 24% 82%); + --color-foreground: hsl(210 18% 16%); + --color-text: hsl(210 18% 16%); + --color-text-light: hsl(210 15% 28%); + --color-text-secondary: hsl(210 12% 42%); + --color-muted-foreground: hsl(210 14% 48%); + --color-secondary: hsl(210 18% 60%); + + --color-code-bg: hsl(210 40% 96%); + + --color-button-bg: hsl(210 35% 94%); + --color-button-text: hsl(210 20% 20%); + --color-button-hover: hsl(210 32% 90%); + + --color-user-surface: hsl(0 0% 91%); + --color-user-border: hsl(210 20% 78%); + --color-user-border-hover: hsl(210 20% 68%); + --color-assistant-border: hsl(207 65% 50%); + --color-assistant-border-hover: hsl(207 65% 40%); + --color-message-header: hsl(210 18% 20%); + + --color-token-prompt: hsl(210 18% 32%); + --color-token-completion: hsl(207 90% 40%); + --color-token-variable: hsl(207 90% 40%); + --color-token-fixed: hsl(210 18% 32%); + --color-token-input: hsl(125 45% 36%); + --color-token-output: hsl(207 90% 40%); + --color-token-cached: hsl(210 16% 50%); + + --surface-plan-gradient: linear-gradient(135deg, color-mix(in srgb, var(--color-plan-mode), transparent 94%) 0%, color-mix(in srgb, var(--color-plan-mode), transparent 97%) 100%); + --surface-plan-border: color-mix(in srgb, var(--color-plan-mode), transparent 78%); + --surface-plan-border-subtle: color-mix(in srgb, var(--color-plan-mode), transparent 85%); + --surface-plan-border-strong: color-mix(in srgb, var(--color-plan-mode), transparent 70%); + --surface-plan-divider: color-mix(in srgb, var(--color-plan-mode), transparent 86%); + --surface-plan-chip-bg: color-mix(in srgb, var(--color-plan-mode), transparent 94%); + --surface-plan-chip-hover: color-mix(in srgb, var(--color-plan-mode), transparent 90%); + --surface-plan-chip-active: color-mix(in srgb, var(--color-plan-mode), transparent 72%); + --surface-plan-chip-border: color-mix(in srgb, var(--color-plan-mode), transparent 78%); + --surface-plan-chip-border-strong: color-mix(in srgb, var(--color-plan-mode), transparent 68%); + --surface-plan-neutral-border: hsl(210 16% 72% / 0.5); + + --surface-assistant-chip-bg: hsl(from var(--color-assistant-border) h s l / 0.15); + --surface-assistant-chip-hover: hsl(from var(--color-assistant-border) h s l / 0.45); + --surface-assistant-chip-border: hsl(from var(--color-assistant-border) h s l / 0.45); + --surface-assistant-chip-border-strong: hsl(from var(--color-assistant-border) h s l / 0.65); + + --border-warning-dashed: color-mix(in srgb, var(--color-warning), transparent 55%); + + --color-toggle-bg: hsl(210 34% 95%); + --color-toggle-active: hsl(210 48% 88%); + --color-toggle-hover: hsl(210 38% 92%); + --color-toggle-text: hsl(210 16% 32%); + --color-toggle-text-active: hsl(210 18% 18%); + --color-toggle-text-hover: hsl(210 20% 26%); + + --color-interrupted: hsl(38 92% 50%); + --color-review-accent: hsl(48 70% 52%); + --color-git-dirty: hsl(38 92% 50%); + --color-error: hsl(0 68% 46%); + --color-error-bg: hsl(0 82% 94%); + + --color-input-bg: hsl(0 0% 100%); + --color-input-text: hsl(210 18% 16%); + --color-input-border: hsl(207 75% 52%); + --color-input-border-focus: hsl(193 85% 56%); + + --color-scrollbar-track: hsl(210 38% 95%); + --color-scrollbar-thumb: hsl(210 18% 78%); + --color-scrollbar-thumb-hover: hsl(210 18% 70%); + + --color-muted: hsl(210 14% 52%); + --color-muted-light: hsl(210 20% 60%); + --color-muted-dark: hsl(210 12% 42%); + --color-placeholder: hsl(210 16% 52%); + --color-subtle: hsl(210 20% 64%); + --color-dim: hsl(210 16% 50%); + --color-light: hsl(210 22% 72%); + --color-lighter: hsl(210 32% 86%); + --color-bright: hsl(210 60% 48%); + --color-subdued: hsl(210 18% 60%); + --color-label: hsl(210 18% 46%); + --color-gray: hsl(210 14% 54%); + --color-medium: hsl(210 18% 58%); + + --color-border-light: hsl(210 24% 84%); + --color-border-medium: hsl(210 22% 78%); + --color-border-darker: hsl(210 20% 70%); + --color-border-subtle: hsl(210 18% 64%); + --color-border-gray: hsl(210 22% 76%); + + --color-dark: hsl(210 33% 98%); + --color-darker: hsl(210 30% 94%); + --color-hover: hsl(210 28% 92%); + --color-bg-medium: hsl(210 26% 88%); + --color-bg-light: hsl(210 24% 84%); + --color-bg-subtle: hsl(210 32% 92%); + + --color-separator: hsl(0 0% 91%); + --color-separator-light: hsl(0 0% 96%); + --color-modal-bg: hsl(210 35% 96%); + + --color-accent: hsl(207 90% 42%); + --color-accent-hover: hsl(207 90% 46%); + --color-accent-dark: hsl(207 90% 38%); + --color-accent-darker: hsl(204 92% 30%); + --color-accent-light: hsl(198 100% 70%); + + --color-success: hsl(122 39% 45%); + --color-success-light: hsl(123 46% 60%); + --color-on-success: hsl(0 0% 100%); + + --color-danger: hsl(4 78% 52%); + --color-danger-light: hsl(4 78% 70%); + --color-danger-soft: hsl(6 80% 76%); + --color-on-danger: hsl(0 0% 100%); + + --color-warning: hsl(45 100% 50%); + --color-warning-light: hsl(38 100% 70%); + + --color-code-type: hsl(210 70% 34%); + --color-code-keyword: hsl(210 78% 32%); + + --color-toast-success-bg: hsl(207 90% 46% / 0.18); + --color-toast-success-text: hsl(207 90% 34%); + --color-toast-error-bg: hsl(5 80% 55% / 0.18); + --color-toast-error-text: hsl(5 78% 46%); + --color-toast-error-border: hsl(5 78% 46%); + --color-toast-fatal-bg: hsl(0 72% 94%); + --color-toast-fatal-border: hsl(0 74% 82%); + + --color-danger-overlay: hsl(4 80% 52% / 0.12); + --color-warning-overlay: hsl(45 100% 50% / 0.12); + --color-gray-overlay: hsl(210 16% 30% / 0.08); + --color-white-overlay-light: hsl(210 14% 12% / 0.04); + --color-white-overlay: hsl(210 14% 12% / 0.08); + --color-selection: hsl(204 100% 45% / 0.3); + --color-vim-status: hsl(210 18% 32% / 0.6); + --color-code-keyword-overlay-light: hsl(210 80% 45% / 0.12); + --color-code-keyword-overlay: hsl(210 80% 45% / 0.24); + + --color-info-light: hsl(5 90% 70%); + --color-info-yellow: hsl(38 100% 60%); + + --color-review-bg-blue: hsl(212 57% 82%); + --color-review-bg-info: hsl(200 60% 80%); + --color-review-bg-warning: hsl(38 88% 82%); + --color-review-warning: hsl(34 86% 45%); + --color-review-warning-medium: hsl(34 86% 52%); + --color-review-warning-light: hsl(38 100% 88%); + + --color-error-bg-dark: hsl(0 65% 86%); + + --radius: 0.5rem; +} :root[data-theme="dark"] { color-scheme: dark; /* Theme override hook: redeclare tokens inside this block to swap palettes */ diff --git a/src/browser/utils/commandIds.ts b/src/browser/utils/commandIds.ts index e30ae8854..cce0e5ad2 100644 --- a/src/browser/utils/commandIds.ts +++ b/src/browser/utils/commandIds.ts @@ -50,6 +50,10 @@ export const CommandIds = { projectRemove: (projectPath: string) => `${COMMAND_ID_PREFIXES.PROJECT_REMOVE}${projectPath}` as const, + // Appearance commands + themeToggle: () => "appearance:theme:toggle" as const, + themeSet: (theme: "light" | "dark") => `appearance:theme:set:${theme}` as const, + // Help commands helpKeybinds: () => "help:keybinds" as const, } as const; diff --git a/src/browser/utils/commands/sources.test.ts b/src/browser/utils/commands/sources.test.ts index 8b6d613d7..c322ea63a 100644 --- a/src/browser/utils/commands/sources.test.ts +++ b/src/browser/utils/commands/sources.test.ts @@ -27,6 +27,7 @@ const mk = (over: Partial[0]> = {}) => { }); const params: Parameters[0] = { projects, + theme: "dark", workspaceMetadata, selectedWorkspace: { projectPath: "/repo/a", @@ -46,6 +47,8 @@ const mk = (over: Partial[0]> = {}) => { onToggleSidebar: () => undefined, onNavigateWorkspace: () => undefined, onOpenWorkspaceInTerminal: () => undefined, + onToggleTheme: () => undefined, + onSetTheme: () => undefined, getBranchesForProject: () => Promise.resolve({ branches: ["main"], diff --git a/src/browser/utils/commands/sources.ts b/src/browser/utils/commands/sources.ts index 2c3aa9348..bf64d38b9 100644 --- a/src/browser/utils/commands/sources.ts +++ b/src/browser/utils/commands/sources.ts @@ -1,3 +1,4 @@ +import type { ThemeMode } from "@/browser/contexts/ThemeContext"; import type { CommandAction } from "@/browser/contexts/CommandRegistryContext"; import { formatKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds"; import type { ThinkingLevel } from "@/common/types/thinking"; @@ -12,6 +13,7 @@ export interface BuildSourcesParams { projects: Map; /** Map of workspace ID to workspace metadata (keyed by metadata.id, not path) */ workspaceMetadata: Map; + theme: ThemeMode; selectedWorkspace: { projectPath: string; projectName: string; @@ -41,6 +43,8 @@ export interface BuildSourcesParams { onToggleSidebar: () => void; onNavigateWorkspace: (dir: "next" | "prev") => void; onOpenWorkspaceInTerminal: (workspaceId: string) => void; + onToggleTheme: () => void; + onSetTheme: (theme: ThemeMode) => void; } const THINKING_LEVELS: ThinkingLevel[] = ["off", "low", "medium", "high"]; @@ -56,12 +60,14 @@ export const COMMAND_SECTIONS = { MODE: "Modes & Model", HELP: "Help", PROJECTS: "Projects", + APPEARANCE: "Appearance", } as const; const section = { workspaces: COMMAND_SECTIONS.WORKSPACES, navigation: COMMAND_SECTIONS.NAVIGATION, chat: COMMAND_SECTIONS.CHAT, + appearance: COMMAND_SECTIONS.APPEARANCE, mode: COMMAND_SECTIONS.MODE, help: COMMAND_SECTIONS.HELP, projects: COMMAND_SECTIONS.PROJECTS, @@ -305,6 +311,38 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi }, ]); + // Appearance + actions.push(() => { + const list: CommandAction[] = [ + { + id: CommandIds.themeToggle(), + title: `Switch to ${p.theme === "dark" ? "Light" : "Dark"} Theme`, + section: section.appearance, + run: () => p.onToggleTheme(), + }, + ]; + + if (p.theme !== "dark") { + list.push({ + id: CommandIds.themeSet("dark"), + title: "Use Dark Theme", + section: section.appearance, + run: () => p.onSetTheme("dark"), + }); + } + + if (p.theme !== "light") { + list.push({ + id: CommandIds.themeSet("light"), + title: "Use Light Theme", + section: section.appearance, + run: () => p.onSetTheme("light"), + }); + } + + return list; + }); + // Chat utilities actions.push(() => { const list: CommandAction[] = []; diff --git a/src/common/constants/storage.ts b/src/common/constants/storage.ts index e6e9e485e..bfaa80a16 100644 --- a/src/common/constants/storage.ts +++ b/src/common/constants/storage.ts @@ -30,6 +30,12 @@ export function getPendingScopeId(projectPath: string): string { */ export const GLOBAL_SCOPE_ID = "__global__"; +/** + * Get the localStorage key for the UI theme preference (global) + * Format: "uiTheme" + */ +export const UI_THEME_KEY = "uiTheme"; + /** * Helper to create a thinking level storage key for a workspace * Format: "thinkingLevel:{workspaceId}" From 0cd592215bc196173db409accd23274f49664e35 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Sun, 16 Nov 2025 18:01:15 -0500 Subject: [PATCH 2/4] fix: replace hardcoded white/slate text colors with semantic tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - User message text: text-slate-100 → text-foreground - JSON debug view: text-white/80 → text-muted-foreground - History hidden message: bg-white/[0.03] → bg-muted/30 - Image borders: border-white/10 → border-separator - Fix Tailwind classnames order warnings --- src/browser/App.tsx | 2 +- .../Messages/HistoryHiddenMessage.tsx | 4 +- .../components/Messages/MessageWindow.tsx | 2 +- .../components/Messages/UserMessage.tsx | 4 +- src/browser/components/ProjectCreateModal.tsx | 111 +++++------------- src/browser/components/ThemeToggleButton.tsx | 2 +- 6 files changed, 36 insertions(+), 89 deletions(-) diff --git a/src/browser/App.tsx b/src/browser/App.tsx index e861d8dae..eea416ab8 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -598,7 +598,7 @@ function AppInner() { })() ) : (
= ({ return (
diff --git a/src/browser/components/Messages/MessageWindow.tsx b/src/browser/components/Messages/MessageWindow.tsx index 4bf0bc9a6..29cafabd7 100644 --- a/src/browser/components/Messages/MessageWindow.tsx +++ b/src/browser/components/Messages/MessageWindow.tsx @@ -82,7 +82,7 @@ export const MessageWindow: React.FC = ({
{showJson ? ( -
+              
                 {JSON.stringify(message, null, 2)}
               
) : ( diff --git a/src/browser/components/Messages/UserMessage.tsx b/src/browser/components/Messages/UserMessage.tsx index 41b9c8add..642045722 100644 --- a/src/browser/components/Messages/UserMessage.tsx +++ b/src/browser/components/Messages/UserMessage.tsx @@ -99,7 +99,7 @@ export const UserMessage: React.FC = ({ variant="user" > {content && ( -
+        
           {content}
         
)} @@ -110,7 +110,7 @@ export const UserMessage: React.FC = ({ key={idx} src={img.url} alt={`Attachment ${idx + 1}`} - className="max-h-[300px] max-w-72 rounded-xl border border-white/10 object-cover" + className="border-separator max-h-[300px] max-w-72 rounded-xl border object-cover" /> ))}
diff --git a/src/browser/components/ProjectCreateModal.tsx b/src/browser/components/ProjectCreateModal.tsx index 90e700821..a339d4d97 100644 --- a/src/browser/components/ProjectCreateModal.tsx +++ b/src/browser/components/ProjectCreateModal.tsx @@ -1,7 +1,5 @@ import React, { useState, useCallback } from "react"; import { Modal, ModalActions, CancelButton, PrimaryButton } from "./Modal"; -import type { IPCApi } from "@/common/types/ipc"; -import { DirectoryPickerModal } from "./DirectoryPickerModal"; import type { ProjectConfig } from "@/node/config"; interface ProjectCreateModalProps { @@ -23,13 +21,7 @@ export const ProjectCreateModal: React.FC = ({ }) => { const [path, setPath] = useState(""); const [error, setError] = useState(""); - // Detect desktop environment where native directory picker is available - const isDesktop = - window.api.platform !== "browser" && typeof window.api.projects.pickDirectory === "function"; - const api = window.api as unknown as IPCApi; - const hasWebFsPicker = window.api.platform === "browser" && !!api.fs?.listDirectory; const [isCreating, setIsCreating] = useState(false); - const [isDirPickerOpen, setIsDirPickerOpen] = useState(false); const handleCancel = useCallback(() => { setPath(""); @@ -37,23 +29,6 @@ export const ProjectCreateModal: React.FC = ({ onClose(); }, [onClose]); - const handleWebPickerPathSelected = useCallback((selected: string) => { - setPath(selected); - setError(""); - }, []); - - const handleBrowse = useCallback(async () => { - try { - const selectedPath = await window.api.projects.pickDirectory(); - if (selectedPath) { - setPath(selectedPath); - setError(""); - } - } catch (err) { - console.error("Failed to pick directory:", err); - } - }, []); - const handleSelect = useCallback(async () => { const trimmedPath = path.trim(); if (!trimmedPath) { @@ -103,14 +78,6 @@ export const ProjectCreateModal: React.FC = ({ } }, [path, onSuccess, onClose]); - const handleBrowseClick = useCallback(() => { - if (isDesktop) { - void handleBrowse(); - } else if (hasWebFsPicker) { - setIsDirPickerOpen(true); - } - }, [handleBrowse, hasWebFsPicker, isDesktop]); - const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === "Enter") { @@ -122,55 +89,35 @@ export const ProjectCreateModal: React.FC = ({ ); return ( - <> - -
- { - setPath(e.target.value); - setError(""); - }} - onKeyDown={handleKeyDown} - placeholder="/home/user/projects/my-project" - autoFocus - disabled={isCreating} - className="bg-modal-bg border-border-medium focus:border-accent placeholder:text-muted w-full flex-1 rounded border px-3 py-2 font-mono text-sm text-foreground focus:outline-none disabled:opacity-50" - /> - {(isDesktop || hasWebFsPicker) && ( - - )} -
- {error &&
{error}
} - - - Cancel - - void handleSelect()} disabled={isCreating}> - {isCreating ? "Adding..." : "Add Project"} - - -
- setIsDirPickerOpen(false)} - onSelectPath={handleWebPickerPathSelected} + + { + setPath(e.target.value); + setError(""); + }} + onKeyDown={handleKeyDown} + placeholder="/home/user/projects/my-project" + autoFocus + disabled={isCreating} + className="bg-modal-bg border-border-medium focus:border-accent placeholder:text-muted text-foreground mb-5 w-full rounded border px-3 py-2 font-mono text-sm focus:outline-none disabled:opacity-50" /> - + {error &&
{error}
} + + + Cancel + + void handleSelect()} disabled={isCreating}> + {isCreating ? "Adding..." : "Add Project"} + + +
); }; diff --git a/src/browser/components/ThemeToggleButton.tsx b/src/browser/components/ThemeToggleButton.tsx index d6f79bb1f..9adca9b4c 100644 --- a/src/browser/components/ThemeToggleButton.tsx +++ b/src/browser/components/ThemeToggleButton.tsx @@ -12,7 +12,7 @@ export function ThemeToggleButton() { )} -