From f55662ad675d0f64e0fb0c413788cae1b9690b27 Mon Sep 17 00:00:00 2001 From: Alec Thomas Date: Thu, 25 Jun 2026 10:58:51 +1000 Subject: [PATCH] feat(desktop): add 'Follow system' theme mode Adds a checkbox in Appearance settings that auto-switches between the user's selected theme and its light/dark counterpart based on the OS color scheme (prefers-color-scheme media query). Implementation: - theme-loader.ts: adds THEME_PAIRS map and resolveSystemTheme() helper - ThemeProvider.tsx: adds followSystem state, media query listener, and resolves the effective theme based on system preference - SettingsPanels.tsx: adds Follow System checkbox with contextual hint showing which paired theme will be used When enabled with e.g. 'GitHub Light' selected, the app automatically switches to 'GitHub Dark' when the OS enters dark mode, and back when it returns to light mode. Themes without a known pair show a hint. Co-authored-by: Alec Thomas Signed-off-by: Alec Thomas Co-authored-by: Goose --- .../features/settings/ui/SettingsPanels.tsx | 74 ++++++++++++++-- desktop/src/shared/theme/ThemeProvider.tsx | 85 +++++++++++++++++-- desktop/src/shared/theme/theme-loader.ts | 70 +++++++++++++++ 3 files changed, 216 insertions(+), 13 deletions(-) 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[];