diff --git a/desktop/src/features/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx index c3055c30f..70fd9e256 100644 --- a/desktop/src/features/settings/ui/SettingsPanels.tsx +++ b/desktop/src/features/settings/ui/SettingsPanels.tsx @@ -9,6 +9,7 @@ import { Keyboard, LayoutTemplate, LockKeyhole, + Monitor, MonitorCog, Moon, Search, @@ -30,9 +31,15 @@ import { cn } from "@/shared/lib/cn"; import { ACCENT_COLORS, NEUTRAL_ACCENT, + THEME_STORAGE_KEY, useTheme, } from "@/shared/theme/ThemeProvider"; -import { SYNTAX_THEMES, isLightTheme } from "@/shared/theme/theme-loader"; +import { + SYNTAX_THEMES, + type SyntaxThemeName, + getThemePair, + isLightTheme, +} from "@/shared/theme/theme-loader"; import { ChannelTemplatesSettingsCard } from "./ChannelTemplatesSettingsCard"; import { DoctorSettingsPanel } from "./DoctorSettingsPanel"; import { ExperimentalFeaturesCard } from "./ExperimentalFeaturesCard"; @@ -188,8 +195,16 @@ function formatThemeLabel(name: string): string { } function ThemeSettingsCard() { - const { setTheme, themeName, isDark, accentColor, setAccentColor } = - useTheme(); + const { + setTheme, + themeName, + isDark, + accentColor, + setAccentColor, + followSystem, + hasPair, + setFollowSystem, + } = useTheme(); const [search, setSearch] = useState(""); const didScrollRef = useRef(false); const activeRef = (node: HTMLButtonElement | null) => { @@ -199,19 +214,60 @@ function ThemeSettingsCard() { } }; + // Read the user's selected theme from localStorage (not the effective/resolved one) + const selectedTheme = useMemo(() => { + return window.localStorage.getItem(THEME_STORAGE_KEY) ?? themeName; + }, [themeName]); + const filtered = useMemo(() => { const q = search.toLowerCase().trim(); if (!q) return SYNTAX_THEMES; return SYNTAX_THEMES.filter((name) => name.includes(q)); }, [search]); + // Determine the paired theme name for the hint text + const pairName = useMemo(() => { + if (!hasPair) return null; + const pair = getThemePair(selectedTheme as SyntaxThemeName); + return pair ? formatThemeLabel(pair) : null; + }, [selectedTheme, hasPair]); + return (
+ {/* Follow System toggle */} + +
) : ( filtered.map((name) => { - const isActive = themeName === name; + const isActive = selectedTheme === name; + const isEffective = themeName === name; const light = isLightTheme(name); return ( @@ -243,7 +300,9 @@ function ThemeSettingsCard() { "flex w-full items-center gap-3 px-3 py-2 text-left text-sm transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring", isActive ? "bg-primary/10 text-foreground" - : "text-muted-foreground hover:bg-accent hover:text-accent-foreground", + : isEffective && followSystem + ? "bg-primary/5 text-foreground" + : "text-muted-foreground hover:bg-accent hover:text-accent-foreground", )} data-testid={`theme-option-${name}`} key={name} @@ -262,6 +321,9 @@ function ThemeSettingsCard() { {isActive && ( )} + {!isActive && isEffective && followSystem && ( + + )} ); }) diff --git a/desktop/src/shared/theme/ThemeProvider.tsx b/desktop/src/shared/theme/ThemeProvider.tsx index 4a06ab01a..1213327ea 100644 --- a/desktop/src/shared/theme/ThemeProvider.tsx +++ b/desktop/src/shared/theme/ThemeProvider.tsx @@ -12,13 +12,16 @@ import { SYNTAX_THEMES, type SyntaxThemeName, extractThemeInfo, + getThemePair, loadThemeData, + resolveSystemTheme, } from "./theme-loader"; export const THEME_STORAGE_KEY = "buzz-theme"; const CACHE_KEY = "buzz-theme-cache"; export const ACCENT_STORAGE_KEY = "buzz-accent-color"; export const NEUTRAL_ACCENT = "neutral"; +const FOLLOW_SYSTEM_KEY = "buzz-follow-system"; const VIDEO_REVIEW_NEUTRAL_ACCENT = "0 0% 98%"; const VIDEO_REVIEW_CHIP_SURFACE = "#161616"; const VIDEO_REVIEW_TEXT_CONTRAST = 4.5; @@ -44,8 +47,11 @@ type ThemeContextValue = { isDark: boolean; isLoading: boolean; accentColor: string; + followSystem: boolean; + hasPair: boolean; setTheme: (name: string) => void; setAccentColor: (color: string) => void; + setFollowSystem: (enabled: boolean) => void; }; type ThemeProviderProps = { @@ -259,7 +265,7 @@ export function ThemeProvider({ defaultTheme = "houston", }: ThemeProviderProps) { // Apply cached vars synchronously before first render - const [themeName, setThemeName] = useState(() => { + const [selectedTheme, setSelectedTheme] = useState(() => { const cached = applyCachedVars(); return cached ?? readStoredTheme(defaultTheme); }); @@ -271,17 +277,34 @@ export function ThemeProvider({ const [accentColor, setAccentColorState] = useState(() => { return window.localStorage.getItem(ACCENT_STORAGE_KEY) ?? DEFAULT_ACCENT; }); + const [followSystem, setFollowSystemState] = useState(() => { + return window.localStorage.getItem(FOLLOW_SYSTEM_KEY) === "true"; + }); + + // Resolve the effective theme based on follow-system preference + const effectiveTheme = (() => { + if (!followSystem || !isValidThemeName(selectedTheme)) return selectedTheme; + const systemIsDark = window.matchMedia( + "(prefers-color-scheme: dark)", + ).matches; + return resolveSystemTheme(selectedTheme as SyntaxThemeName, systemIsDark); + })(); + + // Check if the selected theme has a pair (for UI hint) + const hasPair = isValidThemeName(selectedTheme) + ? getThemePair(selectedTheme as SyntaxThemeName) !== null + : false; // Load and apply theme useEffect(() => { - if (!isValidThemeName(themeName)) return; + if (!isValidThemeName(effectiveTheme)) return; // Track which theme we're loading to avoid race conditions - const thisTheme = themeName; + const thisTheme = effectiveTheme; loadingRef.current = thisTheme; setIsLoading(true); - applyTheme(themeName).then(({ isDark: dark }) => { + applyTheme(effectiveTheme as SyntaxThemeName).then(({ isDark: dark }) => { // Only update if this is still the theme we want if (loadingRef.current === thisTheme) { setIsDark(dark); @@ -292,7 +315,47 @@ export function ThemeProvider({ ); } }); - }, [themeName]); + }, [effectiveTheme]); + + // Listen for system color scheme changes when followSystem is enabled + useEffect(() => { + if (!followSystem) return; + + const mq = window.matchMedia("(prefers-color-scheme: dark)"); + const handler = () => { + // Force a re-render by toggling a state update — effectiveTheme + // is derived and will recalculate on next render + setFollowSystemState((prev) => { + // No-op toggle to trigger re-render with new media query value + return prev; + }); + // Directly resolve and apply the theme for immediate response + if (isValidThemeName(selectedTheme)) { + const resolved = resolveSystemTheme( + selectedTheme as SyntaxThemeName, + mq.matches, + ); + if (isValidThemeName(resolved)) { + const thisTheme = resolved; + loadingRef.current = thisTheme; + setIsLoading(true); + applyTheme(resolved as SyntaxThemeName).then(({ isDark: dark }) => { + if (loadingRef.current === thisTheme) { + setIsDark(dark); + setIsLoading(false); + applyAccentColor( + window.localStorage.getItem(ACCENT_STORAGE_KEY) ?? + DEFAULT_ACCENT, + ); + } + }); + } + } + }; + + mq.addEventListener("change", handler); + return () => mq.removeEventListener("change", handler); + }, [followSystem, selectedTheme]); // Apply accent color changes useEffect(() => { @@ -301,7 +364,7 @@ export function ThemeProvider({ const setTheme = useCallback((name: string) => { if (!isValidThemeName(name)) return; - setThemeName(name); + setSelectedTheme(name); window.localStorage.setItem(THEME_STORAGE_KEY, name); }, []); @@ -310,13 +373,21 @@ export function ThemeProvider({ setAccentColorState(color); }, []); + const setFollowSystem = useCallback((enabled: boolean) => { + window.localStorage.setItem(FOLLOW_SYSTEM_KEY, enabled ? "true" : "false"); + setFollowSystemState(enabled); + }, []); + const value: ThemeContextValue = { - themeName, + themeName: effectiveTheme, isDark, isLoading, accentColor, + followSystem, + hasPair, setTheme, setAccentColor, + setFollowSystem, }; return ( diff --git a/desktop/src/shared/theme/theme-loader.ts b/desktop/src/shared/theme/theme-loader.ts index c954d2cc0..1d1cc117d 100644 --- a/desktop/src/shared/theme/theme-loader.ts +++ b/desktop/src/shared/theme/theme-loader.ts @@ -182,6 +182,76 @@ export function isLightTheme(name: string): boolean { return LIGHT_THEMES.has(name as SyntaxThemeName); } +/** + * Theme pairs: maps a light theme to its dark counterpart and vice versa. + * Used by the "Follow system" feature to auto-switch themes. + */ +export const THEME_PAIRS: ReadonlyMap = + new Map([ + // Light → Dark + ["catppuccin-latte", "catppuccin-mocha"], + ["everforest-light", "everforest-dark"], + ["github-light", "github-dark"], + ["github-light-default", "github-dark-default"], + ["github-light-high-contrast", "github-dark-high-contrast"], + ["gruvbox-light-hard", "gruvbox-dark-hard"], + ["gruvbox-light-medium", "gruvbox-dark-medium"], + ["gruvbox-light-soft", "gruvbox-dark-soft"], + ["kanagawa-lotus", "kanagawa-wave"], + ["light-plus", "dark-plus"], + ["material-theme-lighter", "material-theme"], + ["min-light", "min-dark"], + ["one-light", "one-dark-pro"], + ["rose-pine-dawn", "rose-pine"], + ["slack-ochin", "slack-dark"], + ["solarized-light", "solarized-dark"], + ["vitesse-light", "vitesse-dark"], + // Dark → Light (reverse mappings) + ["catppuccin-mocha", "catppuccin-latte"], + ["everforest-dark", "everforest-light"], + ["github-dark", "github-light"], + ["github-dark-default", "github-light-default"], + ["github-dark-high-contrast", "github-light-high-contrast"], + ["gruvbox-dark-hard", "gruvbox-light-hard"], + ["gruvbox-dark-medium", "gruvbox-light-medium"], + ["gruvbox-dark-soft", "gruvbox-light-soft"], + ["kanagawa-wave", "kanagawa-lotus"], + ["dark-plus", "light-plus"], + ["material-theme", "material-theme-lighter"], + ["min-dark", "min-light"], + ["one-dark-pro", "one-light"], + ["rose-pine", "rose-pine-dawn"], + ["slack-dark", "slack-ochin"], + ["solarized-dark", "solarized-light"], + ["vitesse-dark", "vitesse-light"], + ]); + +/** + * Get the counterpart theme for system theme switching. + * Returns the paired theme if one exists, or null if the theme has no pair. + */ +export function getThemePair(name: SyntaxThemeName): SyntaxThemeName | null { + return THEME_PAIRS.get(name) ?? null; +} + +/** + * Given a user-selected theme and the current system color scheme, + * returns the theme that should actually be applied. + */ +export function resolveSystemTheme( + selectedTheme: SyntaxThemeName, + systemIsDark: boolean, +): SyntaxThemeName { + const selectedIsLight = isLightTheme(selectedTheme); + const needsSwitch = + (systemIsDark && selectedIsLight) || (!systemIsDark && !selectedIsLight); + + if (!needsSwitch) return selectedTheme; + + const pair = getThemePair(selectedTheme); + return pair ?? selectedTheme; +} + // Theme settings type from Shiki interface ThemeSetting { scope?: string | string[];