+
+
This provider at{" "}
{selectedBackendProvider.binaryPath}
@@ -558,13 +558,11 @@ export function AddChannelBotDialog({
{providerWarnings.length > 0
? providerWarnings.map((warning) => (
-
-
- {warning}
-
+
+
{warning}
))
: null}
diff --git a/desktop/src/features/messages/ui/DiffMessage.tsx b/desktop/src/features/messages/ui/DiffMessage.tsx
index d6f830d7f..ed3e743ac 100644
--- a/desktop/src/features/messages/ui/DiffMessage.tsx
+++ b/desktop/src/features/messages/ui/DiffMessage.tsx
@@ -116,7 +116,7 @@ export default function DiffMessage({
{/* Truncation warning */}
{truncated && (
-
+
{hasMissingSproutTools ? (
-
+
Build the workspace binaries with{" "}
cargo build --release --workspace
@@ -345,7 +343,7 @@ export function DoctorSettingsPanel() {
/>
))
) : (
-
+
No known ACP runtime was detected on your PATH yet. You can
still use a custom command in Create agent.
diff --git a/desktop/src/features/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx
index a3c437f19..17f6594ca 100644
--- a/desktop/src/features/settings/ui/SettingsPanels.tsx
+++ b/desktop/src/features/settings/ui/SettingsPanels.tsx
@@ -1,8 +1,11 @@
+import { useState, useMemo, useRef } from "react";
import {
BellRing,
+ Check,
KeyRound,
MonitorCog,
Moon,
+ Search,
Stethoscope,
Sun,
UserRound,
@@ -14,7 +17,8 @@ import type {
} from "@/features/notifications/hooks";
import { TokenSettingsCard } from "@/features/tokens/ui/TokenSettingsCard";
import { cn } from "@/shared/lib/cn";
-import { useTheme } from "@/shared/theme/ThemeProvider";
+import { ACCENT_COLORS, useTheme } from "@/shared/theme/ThemeProvider";
+import { SYNTAX_THEMES, isLightTheme } from "@/shared/theme/theme-loader";
import { DoctorSettingsPanel } from "./DoctorSettingsPanel";
import { NotificationSettingsCard } from "./NotificationSettingsCard";
import { ProfileSettingsCard } from "./ProfileSettingsCard";
@@ -47,12 +51,6 @@ export type SettingsPanelProps = {
onSetNeedsActionNotificationsEnabled: (enabled: boolean) => void;
};
-type ThemeOption = {
- value: "light" | "dark" | "system";
- label: string;
- icon: LucideIcon;
-};
-
export const settingsSections: SettingsSectionDescriptor[] = [
{
value: "profile",
@@ -81,65 +79,117 @@ export const settingsSections: SettingsSectionDescriptor[] = [
},
];
-const themeOptions: ThemeOption[] = [
- {
- value: "light",
- label: "Light",
- icon: Sun,
- },
- {
- value: "dark",
- label: "Dark",
- icon: Moon,
- },
- {
- value: "system",
- label: "System",
- icon: MonitorCog,
- },
-];
+function formatThemeLabel(name: string): string {
+ return name
+ .split("-")
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
+ .join(" ");
+}
function ThemeSettingsCard() {
- const { setTheme, theme } = useTheme();
+ const { setTheme, themeName, accentColor, setAccentColor } = useTheme();
+ const [search, setSearch] = useState("");
+ const didScrollRef = useRef(false);
+ const activeRef = (node: HTMLButtonElement | null) => {
+ if (node && !didScrollRef.current) {
+ didScrollRef.current = true;
+ node.scrollIntoView({ block: "center" });
+ }
+ };
+
+ const filtered = useMemo(() => {
+ const q = search.toLowerCase().trim();
+ if (!q) return SYNTAX_THEMES;
+ return SYNTAX_THEMES.filter((name) => name.includes(q));
+ }, [search]);
return (
-
-
-
Appearance
-
- Choose how Sprout looks on this device.
-
-
+
+
Appearance
+
+ Choose a theme for Sprout. Light and dark mode is auto-detected.
+
+
-
- {themeOptions.map(({ value, label, icon: Icon }) => {
- const isActive = theme === value;
+
+
+ setSearch(e.target.value)}
+ placeholder="Search themes..."
+ type="text"
+ value={search}
+ />
+
+
+
+ {filtered.length === 0 ? (
+
+ No themes match your search.
+
+ ) : (
+ filtered.map((name) => {
+ const isActive = themeName === name;
+ const light = isLightTheme(name);
return (
);
- })}
+ })
+ )}
+
+
+
+
Accent Color
+
+ {ACCENT_COLORS.map((color) => (
+
+ ))}
diff --git a/desktop/src/features/tokens/ui/TokenSettingsCard.tsx b/desktop/src/features/tokens/ui/TokenSettingsCard.tsx
index a20aa5643..e1e6ad354 100644
--- a/desktop/src/features/tokens/ui/TokenSettingsCard.tsx
+++ b/desktop/src/features/tokens/ui/TokenSettingsCard.tsx
@@ -52,11 +52,9 @@ function StatusBadge({ status }: { status: "active" | "revoked" | "expired" }) {
{status}
@@ -303,7 +301,7 @@ function CreateTokenDialog({
)}
-
+
This is the only time this token will be shown. Store it
@@ -532,7 +530,7 @@ function CreateTokenDialog({
{activeTokenCount >= MAX_ACTIVE_TOKENS ? (
-
+
You already have {MAX_ACTIVE_TOKENS} active tokens. Revoke one
before creating another.
@@ -713,7 +711,7 @@ export function TokenSettingsCard({
{hasReachedTokenLimit ? (
-
+
You've reached the active token limit. Revoke an existing token to
mint another.
diff --git a/desktop/src/main.tsx b/desktop/src/main.tsx
index 1ede3334c..41fc67cb4 100644
--- a/desktop/src/main.tsx
+++ b/desktop/src/main.tsx
@@ -25,7 +25,7 @@ const queryClient = new QueryClient({
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
-
+
diff --git a/desktop/src/shared/theme/ThemeProvider.tsx b/desktop/src/shared/theme/ThemeProvider.tsx
index 85c2c3a36..371fea8cd 100644
--- a/desktop/src/shared/theme/ThemeProvider.tsx
+++ b/desktop/src/shared/theme/ThemeProvider.tsx
@@ -1,85 +1,206 @@
import {
type ReactNode,
createContext,
+ useCallback,
useContext,
useEffect,
+ useRef,
useState,
} from "react";
+import { createThemeVars, hexToHsl } from "./adaptive-theme";
+import {
+ SYNTAX_THEMES,
+ type SyntaxThemeName,
+ extractThemeInfo,
+ loadThemeData,
+} from "./theme-loader";
-type Theme = "light" | "dark" | "system";
-type ResolvedTheme = "light" | "dark";
+const STORAGE_KEY = "sprout-theme";
+const CACHE_KEY = "sprout-theme-cache";
+const ACCENT_KEY = "sprout-accent-color";
+
+export const ACCENT_COLORS = [
+ { name: "Blue", value: "#3b82f6" },
+ { name: "Cyan", value: "#06b6d4" },
+ { name: "Green", value: "#22c55e" },
+ { name: "Orange", value: "#f97316" },
+ { name: "Red", value: "#ef4444" },
+ { name: "Pink", value: "#ec4899" },
+ { name: "Purple", value: "#a855f7" },
+ { name: "Indigo", value: "#6366f1" },
+] as const;
+
+const DEFAULT_ACCENT = "#3b82f6";
type ThemeContextValue = {
- theme: Theme;
- resolvedTheme: ResolvedTheme;
- setTheme: (theme: Theme) => void;
+ themeName: string;
+ isDark: boolean;
+ isLoading: boolean;
+ accentColor: string;
+ setTheme: (name: string) => void;
+ setAccentColor: (color: string) => void;
};
type ThemeProviderProps = {
children: ReactNode;
- defaultTheme?: Theme;
- storageKey?: string;
+ defaultTheme?: SyntaxThemeName;
};
const ThemeContext = createContext(undefined);
-function readStoredTheme(storageKey: string, fallback: Theme): Theme {
- if (typeof window === "undefined") {
- return fallback;
- }
-
- const value = window.localStorage.getItem(storageKey);
- return value === "light" || value === "dark" || value === "system"
- ? value
- : fallback;
+function isValidThemeName(name: string): name is SyntaxThemeName {
+ return (SYNTAX_THEMES as readonly string[]).includes(name);
}
-function detectSystemTheme(): ResolvedTheme {
- if (typeof window === "undefined") {
- return "light";
- }
+/** Read stored theme, migrating legacy "light"/"dark"/"system" values. */
+function readStoredTheme(fallback: SyntaxThemeName): SyntaxThemeName {
+ const stored = window.localStorage.getItem(STORAGE_KEY);
+ if (!stored) return fallback;
- return window.matchMedia("(prefers-color-scheme: dark)").matches
- ? "dark"
- : "light";
+ // Migrate legacy values
+ if (stored === "light") return "catppuccin-latte";
+ if (stored === "dark" || stored === "system") return "houston";
+
+ return isValidThemeName(stored) ? stored : fallback;
}
-function applyThemeToRoot(theme: ResolvedTheme) {
- document.documentElement.classList.remove("light", "dark");
- document.documentElement.classList.add(theme);
+function getContrastColor(hex: string): string {
+ const m = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})/i.exec(hex);
+ if (!m) return "#ffffff";
+ const r = parseInt(m[1], 16);
+ const g = parseInt(m[2], 16);
+ const b = parseInt(m[3], 16);
+ const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
+ return lum > 0.5 ? "#000000" : "#ffffff";
+}
+
+function applyAccentColor(hex: string) {
+ const root = document.documentElement;
+ const accentHsl = hexToHsl(hex);
+ const fgHsl = hexToHsl(getContrastColor(hex));
+ root.style.setProperty("--primary", accentHsl);
+ root.style.setProperty("--primary-foreground", fgHsl);
+ root.style.setProperty("--sidebar-primary", accentHsl);
+ root.style.setProperty("--sidebar-primary-foreground", fgHsl);
+}
+
+/** Apply cached CSS vars synchronously to prevent FOUC. */
+function applyCachedVars(): string | null {
+ try {
+ const cached = window.localStorage.getItem(CACHE_KEY);
+ if (!cached) return null;
+ const { themeName, vars, isDark } = JSON.parse(cached);
+ const root = document.documentElement;
+ for (const [key, value] of Object.entries(vars)) {
+ root.style.setProperty(key, value as string);
+ }
+ root.classList.remove("light", "dark");
+ root.classList.add(isDark ? "dark" : "light");
+
+ // Also apply cached accent
+ const accent = window.localStorage.getItem(ACCENT_KEY) ?? DEFAULT_ACCENT;
+ applyAccentColor(accent);
+
+ return themeName;
+ } catch {
+ return null;
+ }
+}
+
+/** Apply a theme: load data, derive CSS vars, set them on :root. */
+async function applyTheme(name: SyntaxThemeName): Promise<{ isDark: boolean }> {
+ const themeData = await loadThemeData(name);
+ const info = extractThemeInfo(name, themeData);
+ const { isDark, vars } = createThemeVars(info.bg, info.fg, info.comment, {
+ added: info.added,
+ deleted: info.deleted,
+ modified: info.modified,
+ });
+
+ const root = document.documentElement;
+ for (const [key, value] of Object.entries(vars)) {
+ root.style.setProperty(key, value);
+ }
+
+ root.classList.remove("light", "dark");
+ root.classList.add(isDark ? "dark" : "light");
+
+ // Cache for FOUC prevention
+ try {
+ window.localStorage.setItem(
+ CACHE_KEY,
+ JSON.stringify({ themeName: name, vars, isDark }),
+ );
+ } catch {
+ // Storage full — non-critical
+ }
+
+ return { isDark };
}
export function ThemeProvider({
children,
- defaultTheme = "system",
- storageKey = "sprout-theme",
+ defaultTheme = "houston",
}: ThemeProviderProps) {
- const [theme, setThemeState] = useState(() =>
- readStoredTheme(storageKey, defaultTheme),
- );
- const [systemTheme, setSystemTheme] =
- useState(detectSystemTheme);
+ // Apply cached vars synchronously before first render
+ const [themeName, setThemeName] = useState(() => {
+ const cached = applyCachedVars();
+ return cached ?? readStoredTheme(defaultTheme);
+ });
+ const [isDark, setIsDark] = useState(() => {
+ return document.documentElement.classList.contains("dark");
+ });
+ const [isLoading, setIsLoading] = useState(true);
+ const loadingRef = useRef(null);
+ const [accentColor, setAccentColorState] = useState(() => {
+ return window.localStorage.getItem(ACCENT_KEY) ?? DEFAULT_ACCENT;
+ });
+ // Load and apply theme
useEffect(() => {
- const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
- const onChange = (event: MediaQueryListEvent) =>
- setSystemTheme(event.matches ? "dark" : "light");
+ if (!isValidThemeName(themeName)) return;
- mediaQuery.addEventListener("change", onChange);
- return () => mediaQuery.removeEventListener("change", onChange);
+ // Track which theme we're loading to avoid race conditions
+ const thisTheme = themeName;
+ loadingRef.current = thisTheme;
+ setIsLoading(true);
+
+ applyTheme(themeName).then(({ isDark: dark }) => {
+ // Only update if this is still the theme we want
+ if (loadingRef.current === thisTheme) {
+ setIsDark(dark);
+ setIsLoading(false);
+ // Re-apply accent after theme load (theme vars don't include primary)
+ applyAccentColor(
+ window.localStorage.getItem(ACCENT_KEY) ?? DEFAULT_ACCENT,
+ );
+ }
+ });
+ }, [themeName]);
+
+ // Apply accent color changes
+ useEffect(() => {
+ applyAccentColor(accentColor);
+ }, [accentColor]);
+
+ const setTheme = useCallback((name: string) => {
+ if (!isValidThemeName(name)) return;
+ setThemeName(name);
+ window.localStorage.setItem(STORAGE_KEY, name);
}, []);
- const resolvedTheme = theme === "system" ? systemTheme : theme;
-
- useEffect(() => {
- applyThemeToRoot(resolvedTheme);
- window.localStorage.setItem(storageKey, theme);
- }, [resolvedTheme, storageKey, theme]);
+ const setAccentColor = useCallback((color: string) => {
+ window.localStorage.setItem(ACCENT_KEY, color);
+ setAccentColorState(color);
+ }, []);
const value: ThemeContextValue = {
- theme,
- resolvedTheme,
- setTheme: setThemeState,
+ themeName,
+ isDark,
+ isLoading,
+ accentColor,
+ setTheme,
+ setAccentColor,
};
return (
diff --git a/desktop/src/shared/theme/adaptive-theme.ts b/desktop/src/shared/theme/adaptive-theme.ts
new file mode 100644
index 000000000..43596c2e3
--- /dev/null
+++ b/desktop/src/shared/theme/adaptive-theme.ts
@@ -0,0 +1,266 @@
+/**
+ * Adaptive Theme Engine
+ *
+ * Derives shadcn CSS variables from a syntax theme's key colors (bg, fg, comment, git).
+ * Detects light vs dark from background luminance and adjusts accordingly.
+ *
+ * Ported from builderbot/apps/staged/src/lib/theme.ts, flattened to emit
+ * shadcn CSS vars directly (no intermediate Theme object).
+ */
+
+// =============================================================================
+// Color Utilities
+// =============================================================================
+
+interface RGB {
+ r: number;
+ g: number;
+ b: number;
+}
+
+function hexToRgb(hex: string): RGB {
+ const long = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})?$/i.exec(
+ hex,
+ );
+ if (long) {
+ return {
+ r: parseInt(long[1], 16),
+ g: parseInt(long[2], 16),
+ b: parseInt(long[3], 16),
+ };
+ }
+
+ const short = /^#?([a-f\d])([a-f\d])([a-f\d])([a-f\d])?$/i.exec(hex);
+ if (short) {
+ return {
+ r: parseInt(short[1] + short[1], 16),
+ g: parseInt(short[2] + short[2], 16),
+ b: parseInt(short[3] + short[3], 16),
+ };
+ }
+
+ return { r: 128, g: 128, b: 128 };
+}
+
+function rgbToHex({ r, g, b }: RGB): string {
+ const clamp = (n: number) => Math.max(0, Math.min(255, Math.round(n)));
+ return `#${[r, g, b].map((c) => clamp(c).toString(16).padStart(2, "0")).join("")}`;
+}
+
+export function luminance(hex: string): number {
+ const { r, g, b } = hexToRgb(hex);
+ const [rs, gs, bs] = [r, g, b].map((c) => {
+ const s = c / 255;
+ return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
+ });
+ return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
+}
+
+function mix(hex1: string, hex2: string, factor: number): string {
+ const c1 = hexToRgb(hex1);
+ const c2 = hexToRgb(hex2);
+ return rgbToHex({
+ r: c1.r + (c2.r - c1.r) * factor,
+ g: c1.g + (c2.g - c1.g) * factor,
+ b: c1.b + (c2.b - c1.b) * factor,
+ });
+}
+
+function adjust(hex: string, amount: number): string {
+ const target = amount > 0 ? "#ffffff" : "#000000";
+ return mix(hex, target, Math.abs(amount));
+}
+
+function overlay(hex: string, alpha: number): string {
+ const { r, g, b } = hexToRgb(hex);
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
+}
+
+// =============================================================================
+// Chrome Color Calculation
+// =============================================================================
+
+const CONTRAST_VALUE = 0.035;
+const CONTRAST_OFFSET = 0.0135;
+
+function calculateLumDiff(bgLum: number): number {
+ return CONTRAST_VALUE * Math.log(1 + (bgLum + CONTRAST_OFFSET) * 10);
+}
+
+function findColorWithLuminance(baseColor: string, targetLum: number): string {
+ const baseLum = luminance(baseColor);
+ if (Math.abs(baseLum - targetLum) < 0.001) return baseColor;
+
+ const target = targetLum < baseLum ? "#000000" : "#ffffff";
+ let lo = 0;
+ let hi = 1;
+
+ for (let i = 0; i < 20; i++) {
+ const mid = (lo + hi) / 2;
+ const testLum = luminance(mix(baseColor, target, mid));
+ const diff = testLum - targetLum;
+
+ if (Math.abs(diff) < 0.001) break;
+
+ if (target === "#000000") {
+ if (testLum > targetLum) lo = mid;
+ else hi = mid;
+ } else {
+ if (testLum < targetLum) lo = mid;
+ else hi = mid;
+ }
+ }
+ return mix(baseColor, target, (lo + hi) / 2);
+}
+
+function calculateChromeColors(syntaxBg: string): {
+ chrome: string;
+ primary: string;
+} {
+ const bgLum = luminance(syntaxBg);
+ const lumDiff = calculateLumDiff(bgLum);
+ const targetChromeLum = bgLum - lumDiff;
+
+ if (targetChromeLum >= 0) {
+ return {
+ chrome: findColorWithLuminance(syntaxBg, targetChromeLum),
+ primary: syntaxBg,
+ };
+ }
+
+ return {
+ chrome: findColorWithLuminance(syntaxBg, 0),
+ primary: findColorWithLuminance(syntaxBg, lumDiff),
+ };
+}
+
+// =============================================================================
+// Hex → HSL component format ("H S% L%") for Tailwind's hexToHsl() wrappers
+// =============================================================================
+
+export function hexToHsl(hex: string): string {
+ const { r, g, b } = hexToRgb(hex);
+ const rn = r / 255;
+ const gn = g / 255;
+ const bn = b / 255;
+
+ const max = Math.max(rn, gn, bn);
+ const min = Math.min(rn, gn, bn);
+ const l = (max + min) / 2;
+
+ if (max === min) {
+ return `0 0% ${(l * 100).toFixed(1)}%`;
+ }
+
+ const d = max - min;
+ const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
+
+ let h: number;
+ if (max === rn) {
+ h = ((gn - bn) / d + (gn < bn ? 6 : 0)) / 6;
+ } else if (max === gn) {
+ h = ((bn - rn) / d + 2) / 6;
+ } else {
+ h = ((rn - gn) / d + 4) / 6;
+ }
+
+ return `${(h * 360).toFixed(1)} ${(s * 100).toFixed(2)}% ${(l * 100).toFixed(1)}%`;
+}
+
+// =============================================================================
+// Adaptive Theme Generator — emits shadcn CSS vars directly
+// =============================================================================
+
+export interface ThemeGitColors {
+ added: string | null;
+ deleted: string | null;
+ modified: string | null;
+}
+
+export interface ThemeResult {
+ isDark: boolean;
+ vars: Record;
+}
+
+/**
+ * Derive a full set of shadcn CSS variables from syntax theme colors.
+ *
+ * Takes bg, fg, comment hex colors (+ optional git decoration colors) and
+ * returns the var map ready to apply via style.setProperty().
+ */
+export function createThemeVars(
+ syntaxBg: string,
+ syntaxFg: string,
+ syntaxComment: string,
+ gitColors?: ThemeGitColors,
+): ThemeResult {
+ const isDark = luminance(syntaxBg) < 0.5;
+
+ const { chrome: chromeColor, primary: primaryBg } =
+ calculateChromeColors(syntaxBg);
+
+ const dir = isDark ? 1 : -1;
+ const elevate = (amount: number) => adjust(primaryBg, dir * amount);
+
+ // Git/accent colors with fallbacks
+ const fallbackGreen = isDark ? "#3fb950" : "#1a7f37";
+ const fallbackRed = isDark ? "#f85149" : "#cf222e";
+ const fallbackOrange = isDark ? "#d29922" : "#9a6700";
+
+ const accentGreen = gitColors?.added ?? fallbackGreen;
+ const accentRed = gitColors?.deleted ?? fallbackRed;
+ const accentOrange = fallbackOrange;
+
+ // Derived colors
+ const borderColor = mix(primaryBg, syntaxFg, isDark ? 0.15 : 0.12);
+ const hoverBg = elevate(0.06);
+ const primaryFg = hexToHsl(primaryBg);
+ const textFg = hexToHsl(syntaxFg);
+
+ return {
+ isDark,
+ vars: {
+ // Backgrounds
+ "--background": hexToHsl(primaryBg),
+ "--card": hexToHsl(primaryBg),
+ "--popover": hexToHsl(elevate(0.08)),
+ "--muted": hexToHsl(hoverBg),
+ "--accent": hexToHsl(hoverBg),
+ "--secondary": hexToHsl(hoverBg),
+
+ // Foregrounds
+ "--foreground": textFg,
+ "--card-foreground": textFg,
+ "--popover-foreground": textFg,
+ "--muted-foreground": hexToHsl(syntaxComment),
+ "--accent-foreground": textFg,
+ "--secondary-foreground": textFg,
+
+ // Destructive
+ "--destructive": hexToHsl(accentRed),
+ "--destructive-foreground": primaryFg,
+
+ // Borders
+ "--border": hexToHsl(borderColor),
+ "--input": hexToHsl(borderColor),
+ "--ring": textFg,
+
+ // Sidebar
+ "--sidebar-background": hexToHsl(chromeColor),
+ "--sidebar-foreground": textFg,
+ "--sidebar-accent": hexToHsl(primaryBg),
+ "--sidebar-accent-foreground": textFg,
+ "--sidebar-border": hexToHsl(borderColor),
+ "--sidebar-ring": hexToHsl(borderColor),
+
+ // Status colors (hex — used directly via var())
+ "--status-added": accentGreen,
+ "--status-deleted": accentRed,
+ "--status-modified": accentOrange,
+
+ // Warning
+ "--ui-warning": accentOrange,
+ "--ui-warning-bg": overlay(accentOrange, isDark ? 0.1 : 0.08),
+ },
+ };
+}
diff --git a/desktop/src/shared/theme/theme-loader.ts b/desktop/src/shared/theme/theme-loader.ts
new file mode 100644
index 000000000..31e7567c6
--- /dev/null
+++ b/desktop/src/shared/theme/theme-loader.ts
@@ -0,0 +1,285 @@
+/**
+ * Theme Loader
+ *
+ * Loads Shiki theme JSON files and extracts key colors (bg, fg, comment, git).
+ * Only imports the theme JSON — the Shiki highlighter engine is not used here.
+ */
+
+import type { ThemeRegistrationRaw } from "shiki";
+
+// Available syntax themes (all Shiki bundled themes, alphabetically sorted)
+export const SYNTAX_THEMES = [
+ "andromeeda",
+ "aurora-x",
+ "ayu-dark",
+ "catppuccin-frappe",
+ "catppuccin-latte",
+ "catppuccin-macchiato",
+ "catppuccin-mocha",
+ "dark-plus",
+ "dracula",
+ "dracula-soft",
+ "everforest-dark",
+ "everforest-light",
+ "github-dark",
+ "github-dark-default",
+ "github-dark-dimmed",
+ "github-dark-high-contrast",
+ "github-light",
+ "github-light-default",
+ "github-light-high-contrast",
+ "gruvbox-dark-hard",
+ "gruvbox-dark-medium",
+ "gruvbox-dark-soft",
+ "gruvbox-light-hard",
+ "gruvbox-light-medium",
+ "gruvbox-light-soft",
+ "houston",
+ "kanagawa-dragon",
+ "kanagawa-lotus",
+ "kanagawa-wave",
+ "laserwave",
+ "light-plus",
+ "material-theme",
+ "material-theme-darker",
+ "material-theme-lighter",
+ "material-theme-ocean",
+ "material-theme-palenight",
+ "min-dark",
+ "min-light",
+ "monokai",
+ "night-owl",
+ "nord",
+ "one-dark-pro",
+ "one-light",
+ "plastic",
+ "poimandres",
+ "red",
+ "rose-pine",
+ "rose-pine-dawn",
+ "rose-pine-moon",
+ "slack-dark",
+ "slack-ochin",
+ "snazzy-light",
+ "solarized-dark",
+ "solarized-light",
+ "synthwave-84",
+ "tokyo-night",
+ "vesper",
+ "vitesse-black",
+ "vitesse-dark",
+ "vitesse-light",
+] as const;
+
+export type SyntaxThemeName = (typeof SYNTAX_THEMES)[number];
+
+// Known light themes — used by the theme picker to show sun/moon icons
+// for themes that haven't been loaded yet.
+export const LIGHT_THEMES: ReadonlySet = new Set([
+ "catppuccin-latte",
+ "everforest-light",
+ "github-light",
+ "github-light-default",
+ "github-light-high-contrast",
+ "gruvbox-light-hard",
+ "gruvbox-light-medium",
+ "gruvbox-light-soft",
+ "kanagawa-lotus",
+ "light-plus",
+ "material-theme-lighter",
+ "min-light",
+ "one-light",
+ "rose-pine-dawn",
+ "slack-ochin",
+ "snazzy-light",
+ "solarized-light",
+ "vitesse-light",
+]);
+
+// Static theme imports (Vite needs static strings for tree-shaking)
+const themeImports: Record<
+ SyntaxThemeName,
+ () => Promise<{ default: ThemeRegistrationRaw }>
+> = {
+ andromeeda: () => import("shiki/themes/andromeeda.mjs"),
+ "aurora-x": () => import("shiki/themes/aurora-x.mjs"),
+ "ayu-dark": () => import("shiki/themes/ayu-dark.mjs"),
+ "catppuccin-frappe": () => import("shiki/themes/catppuccin-frappe.mjs"),
+ "catppuccin-latte": () => import("shiki/themes/catppuccin-latte.mjs"),
+ "catppuccin-macchiato": () => import("shiki/themes/catppuccin-macchiato.mjs"),
+ "catppuccin-mocha": () => import("shiki/themes/catppuccin-mocha.mjs"),
+ "dark-plus": () => import("shiki/themes/dark-plus.mjs"),
+ dracula: () => import("shiki/themes/dracula.mjs"),
+ "dracula-soft": () => import("shiki/themes/dracula-soft.mjs"),
+ "everforest-dark": () => import("shiki/themes/everforest-dark.mjs"),
+ "everforest-light": () => import("shiki/themes/everforest-light.mjs"),
+ "github-dark": () => import("shiki/themes/github-dark.mjs"),
+ "github-dark-default": () => import("shiki/themes/github-dark-default.mjs"),
+ "github-dark-dimmed": () => import("shiki/themes/github-dark-dimmed.mjs"),
+ "github-dark-high-contrast": () =>
+ import("shiki/themes/github-dark-high-contrast.mjs"),
+ "github-light": () => import("shiki/themes/github-light.mjs"),
+ "github-light-default": () => import("shiki/themes/github-light-default.mjs"),
+ "github-light-high-contrast": () =>
+ import("shiki/themes/github-light-high-contrast.mjs"),
+ "gruvbox-dark-hard": () => import("shiki/themes/gruvbox-dark-hard.mjs"),
+ "gruvbox-dark-medium": () => import("shiki/themes/gruvbox-dark-medium.mjs"),
+ "gruvbox-dark-soft": () => import("shiki/themes/gruvbox-dark-soft.mjs"),
+ "gruvbox-light-hard": () => import("shiki/themes/gruvbox-light-hard.mjs"),
+ "gruvbox-light-medium": () => import("shiki/themes/gruvbox-light-medium.mjs"),
+ "gruvbox-light-soft": () => import("shiki/themes/gruvbox-light-soft.mjs"),
+ houston: () => import("shiki/themes/houston.mjs"),
+ "kanagawa-dragon": () => import("shiki/themes/kanagawa-dragon.mjs"),
+ "kanagawa-lotus": () => import("shiki/themes/kanagawa-lotus.mjs"),
+ "kanagawa-wave": () => import("shiki/themes/kanagawa-wave.mjs"),
+ laserwave: () => import("shiki/themes/laserwave.mjs"),
+ "light-plus": () => import("shiki/themes/light-plus.mjs"),
+ "material-theme": () => import("shiki/themes/material-theme.mjs"),
+ "material-theme-darker": () =>
+ import("shiki/themes/material-theme-darker.mjs"),
+ "material-theme-lighter": () =>
+ import("shiki/themes/material-theme-lighter.mjs"),
+ "material-theme-ocean": () => import("shiki/themes/material-theme-ocean.mjs"),
+ "material-theme-palenight": () =>
+ import("shiki/themes/material-theme-palenight.mjs"),
+ "min-dark": () => import("shiki/themes/min-dark.mjs"),
+ "min-light": () => import("shiki/themes/min-light.mjs"),
+ monokai: () => import("shiki/themes/monokai.mjs"),
+ "night-owl": () => import("shiki/themes/night-owl.mjs"),
+ nord: () => import("shiki/themes/nord.mjs"),
+ "one-dark-pro": () => import("shiki/themes/one-dark-pro.mjs"),
+ "one-light": () => import("shiki/themes/one-light.mjs"),
+ plastic: () => import("shiki/themes/plastic.mjs"),
+ poimandres: () => import("shiki/themes/poimandres.mjs"),
+ red: () => import("shiki/themes/red.mjs"),
+ "rose-pine": () => import("shiki/themes/rose-pine.mjs"),
+ "rose-pine-dawn": () => import("shiki/themes/rose-pine-dawn.mjs"),
+ "rose-pine-moon": () => import("shiki/themes/rose-pine-moon.mjs"),
+ "slack-dark": () => import("shiki/themes/slack-dark.mjs"),
+ "slack-ochin": () => import("shiki/themes/slack-ochin.mjs"),
+ "snazzy-light": () => import("shiki/themes/snazzy-light.mjs"),
+ "solarized-dark": () => import("shiki/themes/solarized-dark.mjs"),
+ "solarized-light": () => import("shiki/themes/solarized-light.mjs"),
+ "synthwave-84": () => import("shiki/themes/synthwave-84.mjs"),
+ "tokyo-night": () => import("shiki/themes/tokyo-night.mjs"),
+ vesper: () => import("shiki/themes/vesper.mjs"),
+ "vitesse-black": () => import("shiki/themes/vitesse-black.mjs"),
+ "vitesse-dark": () => import("shiki/themes/vitesse-dark.mjs"),
+ "vitesse-light": () => import("shiki/themes/vitesse-light.mjs"),
+};
+
+export function isLightTheme(name: string): boolean {
+ return LIGHT_THEMES.has(name as SyntaxThemeName);
+}
+
+// Theme settings type from Shiki
+interface ThemeSetting {
+ scope?: string | string[];
+ settings?: { foreground?: string };
+}
+
+function extractCommentColor(
+ settings: ReadonlyArray | undefined,
+ fallback: string,
+): string {
+ if (!settings) return fallback;
+
+ for (const setting of settings) {
+ if (!setting.scope || !setting.settings?.foreground) continue;
+ const scopes = Array.isArray(setting.scope)
+ ? setting.scope
+ : [setting.scope];
+ if (scopes.includes("comment")) {
+ return setting.settings.foreground;
+ }
+ }
+
+ return fallback;
+}
+
+function stripAlpha(color: string): string {
+ if (color.length === 9 && color.startsWith("#")) {
+ return color.slice(0, 7);
+ }
+ return color;
+}
+
+function extractGitColors(colors: Record | undefined): {
+ added: string | null;
+ deleted: string | null;
+ modified: string | null;
+} {
+ if (!colors) {
+ return { added: null, deleted: null, modified: null };
+ }
+
+ const addedKeys = [
+ "gitDecoration.addedResourceForeground",
+ "editorGutter.addedBackground",
+ "diffEditor.insertedTextBackground",
+ ];
+ const deletedKeys = [
+ "gitDecoration.deletedResourceForeground",
+ "editorGutter.deletedBackground",
+ "diffEditor.removedTextBackground",
+ ];
+ const modifiedKeys = [
+ "gitDecoration.modifiedResourceForeground",
+ "editorGutter.modifiedBackground",
+ ];
+
+ const findColor = (keys: string[]): string | null => {
+ for (const key of keys) {
+ const value = colors[key];
+ if (value) return stripAlpha(value);
+ }
+ return null;
+ };
+
+ return {
+ added: findColor(addedKeys),
+ deleted: findColor(deletedKeys),
+ modified: findColor(modifiedKeys),
+ };
+}
+
+export interface ThemeInfo {
+ name: string;
+ bg: string;
+ fg: string;
+ comment: string;
+ added: string | null;
+ deleted: string | null;
+ modified: string | null;
+}
+
+export function extractThemeInfo(
+ themeName: string,
+ theme: ThemeRegistrationRaw,
+): ThemeInfo {
+ const bg =
+ (theme.colors?.["editor.background"] as string | undefined) || "#1e1e1e";
+ const fg =
+ (theme.colors?.["editor.foreground"] as string | undefined) || "#d4d4d4";
+ const gitColors = extractGitColors(
+ theme.colors as Record | undefined,
+ );
+ return {
+ name: themeName,
+ bg,
+ fg,
+ comment: extractCommentColor(
+ theme.settings as ReadonlyArray | undefined,
+ fg,
+ ),
+ ...gitColors,
+ };
+}
+
+export async function loadThemeData(
+ name: SyntaxThemeName,
+): Promise {
+ const loader = themeImports[name];
+ const { default: theme } = await loader();
+ return theme;
+}
diff --git a/desktop/tailwind.config.js b/desktop/tailwind.config.js
index 0807c75f6..1ce6562a1 100644
--- a/desktop/tailwind.config.js
+++ b/desktop/tailwind.config.js
@@ -55,6 +55,15 @@ export default {
border: "hsl(var(--sidebar-border))",
ring: "hsl(var(--sidebar-ring))",
},
+ status: {
+ added: "var(--status-added)",
+ deleted: "var(--status-deleted)",
+ modified: "var(--status-modified)",
+ },
+ warning: {
+ DEFAULT: "var(--ui-warning)",
+ bg: "var(--ui-warning-bg)",
+ },
},
},
},
diff --git a/desktop/tests/e2e/profile.spec.ts b/desktop/tests/e2e/profile.spec.ts
index be8069d07..49cd85518 100644
--- a/desktop/tests/e2e/profile.spec.ts
+++ b/desktop/tests/e2e/profile.spec.ts
@@ -183,7 +183,45 @@ test("opens settings with the keyboard shortcut and updates theme", async ({
await expect(page.getByTestId("settings-view")).toBeVisible();
await page.getByTestId("settings-nav-appearance").click();
- await page.getByTestId("theme-option-dark").click();
+
+ // Default theme is catppuccin-macchiato (dark)
+ await expect
+ .poll(() =>
+ page.evaluate(() => document.documentElement.classList.contains("dark")),
+ )
+ .toBe(true);
+
+ // Switch to a light theme — verifies dark→light transition
+ await page.getByTestId("theme-option-github-light").click();
+
+ await expect
+ .poll(() =>
+ page.evaluate(() => document.documentElement.classList.contains("light")),
+ )
+ .toBe(true);
+
+ await expect
+ .poll(() =>
+ page.evaluate(() => document.documentElement.classList.contains("dark")),
+ )
+ .toBe(false);
+
+ // CSS variables are set on the root element (the real theming mechanism)
+ await expect
+ .poll(() =>
+ page.evaluate(() =>
+ document.documentElement.style.getPropertyValue("--background").trim(),
+ ),
+ )
+ .toBeTruthy();
+
+ // Theme name persists in localStorage
+ await expect
+ .poll(() => page.evaluate(() => localStorage.getItem("sprout-theme")))
+ .toBe("github-light");
+
+ // Switch back to a dark theme — verifies light→dark transition
+ await page.getByTestId("theme-option-dracula").click();
await expect
.poll(() =>
@@ -191,6 +229,11 @@ test("opens settings with the keyboard shortcut and updates theme", async ({
)
.toBe(true);
+ await expect
+ .poll(() => page.evaluate(() => localStorage.getItem("sprout-theme")))
+ .toBe("dracula");
+
+ // Close settings with keyboard shortcut
await page.keyboard.press(
process.platform === "darwin" ? "Meta+," : "Control+,",
);