diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index e1ddd4ca8c..066bd0278e 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -2,12 +2,31 @@ import type { Preview } from "@storybook/react-vite"; 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"; + + return ( +
+ +
+ ); + }, ], parameters: { controls: { diff --git a/src/browser/App.tsx b/src/browser/App.tsx index 6afb546e1a..6ac79616e3 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -22,6 +22,7 @@ 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"; @@ -32,6 +33,7 @@ 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"]; @@ -60,6 +62,10 @@ 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 { toggleTheme } = useTheme(); + const defaultProjectPath = getFirstProjectPath(projects); const creationChatInputRef = useRef(null); const creationProjectPath = !selectedWorkspace @@ -402,6 +408,7 @@ function AppInner() { onAddProject: addProjectFromPalette, onRemoveProject: removeProjectFromPalette, onToggleSidebar: toggleSidebarFromPalette, + onToggleTheme: toggleTheme, onNavigateWorkspace: navigateWorkspaceFromPalette, onOpenWorkspaceInTerminal: openWorkspaceInTerminal, }; @@ -618,9 +625,15 @@ function AppInner() { function App() { return ( - - - + + + + + + + + + ); } diff --git a/src/browser/components/Messages/MarkdownComponents.tsx b/src/browser/components/Messages/MarkdownComponents.tsx index be98648e85..af43774246 100644 --- a/src/browser/components/Messages/MarkdownComponents.tsx +++ b/src/browser/components/Messages/MarkdownComponents.tsx @@ -1,12 +1,8 @@ import type { ReactNode } from "react"; import React, { useState, useEffect } from "react"; import { Mermaid } from "./Mermaid"; -import { - getShikiHighlighter, - mapToShikiLang, - SHIKI_THEME, -} from "@/browser/utils/highlighting/shikiHighlighter"; -import { extractShikiLines } from "@/browser/utils/highlighting/shiki-shared"; +import { getShikiHighlighter, mapToShikiLang } from "@/browser/utils/highlighting/shikiHighlighter"; +import { extractShikiLines, getShikiTheme } from "@/browser/utils/highlighting/shiki-shared"; import { CopyButton } from "@/browser/components/ui/CopyButton"; interface CodeProps { @@ -79,7 +75,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/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/styles/globals.css b/src/browser/styles/globals.css index 30819dbbfa..0e94469056 100644 --- a/src/browser/styles/globals.css +++ b/src/browser/styles/globals.css @@ -193,6 +193,182 @@ --radius: 0.5rem; } +/* 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%); + --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; +} + :root { /* Legacy RGB for special uses */ --plan-mode-rgb: 31, 107, 184; 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..1d24e7a3f4 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,12 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi shortcutHint: formatKeybind(KEYBINDS.TOGGLE_SIDEBAR), run: () => p.onToggleSidebar(), }, + { + id: CommandIds.navToggleTheme(), + title: "Toggle Theme", + section: section.navigation, + run: () => p.onToggleTheme(), + }, ]); // Chat utilities diff --git a/src/browser/utils/highlighting/highlightDiffChunk.ts b/src/browser/utils/highlighting/highlightDiffChunk.ts index ca41d361d6..228f30facd 100644 --- a/src/browser/utils/highlighting/highlightDiffChunk.ts +++ b/src/browser/utils/highlighting/highlightDiffChunk.ts @@ -1,9 +1,5 @@ -import { - getShikiHighlighter, - mapToShikiLang, - SHIKI_THEME, - 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"; /** @@ -83,7 +79,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..23c39d5430 100644 --- a/src/browser/utils/highlighting/shiki-shared.ts +++ b/src/browser/utils/highlighting/shiki-shared.ts @@ -3,8 +3,15 @@ * 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 + * 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"; + 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/common/constants/storage.ts b/src/common/constants/storage.ts index 5a2b2f1215..e9d00eea0c 100644 --- a/src/common/constants/storage.ts +++ b/src/common/constants/storage.ts @@ -120,6 +120,12 @@ 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"