From ebaff3831fa276702cd2a9b66c0c16d29b2e43f2 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Fri, 5 Jun 2026 08:42:08 +0100 Subject: [PATCH] feat(desktop): add system neutral theme --- .../features/settings/ui/SettingsPanels.tsx | 6 +- desktop/src/shared/theme/ThemeProvider.tsx | 132 ++++++++++----- desktop/src/shared/theme/theme-loader.ts | 151 +++++++++++++++++- desktop/src/shared/ui/markdown.tsx | 22 +-- 4 files changed, 255 insertions(+), 56 deletions(-) diff --git a/desktop/src/features/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx index e9f2301972..8350b028de 100644 --- a/desktop/src/features/settings/ui/SettingsPanels.tsx +++ b/desktop/src/features/settings/ui/SettingsPanels.tsx @@ -30,7 +30,7 @@ import { NEUTRAL_ACCENT, useTheme, } from "@/shared/theme/ThemeProvider"; -import { SYNTAX_THEMES, isLightTheme } from "@/shared/theme/theme-loader"; +import { APP_THEMES, isLightTheme } from "@/shared/theme/theme-loader"; import { ChannelTemplatesSettingsCard } from "./ChannelTemplatesSettingsCard"; import { DoctorSettingsPanel } from "./DoctorSettingsPanel"; import { KeyboardShortcutsCard } from "./KeyboardShortcutsCard"; @@ -161,8 +161,8 @@ function ThemeSettingsCard() { const filtered = useMemo(() => { const q = search.toLowerCase().trim(); - if (!q) return SYNTAX_THEMES; - return SYNTAX_THEMES.filter((name) => name.includes(q)); + if (!q) return APP_THEMES; + return APP_THEMES.filter((name) => name.includes(q)); }, [search]); return ( diff --git a/desktop/src/shared/theme/ThemeProvider.tsx b/desktop/src/shared/theme/ThemeProvider.tsx index 436bfd7deb..f1e1985842 100644 --- a/desktop/src/shared/theme/ThemeProvider.tsx +++ b/desktop/src/shared/theme/ThemeProvider.tsx @@ -9,10 +9,14 @@ import { } from "react"; import { createThemeVars, hexToHsl } from "./adaptive-theme"; import { - SYNTAX_THEMES, + type AppThemeName, type SyntaxThemeName, extractThemeInfo, + getLocalTheme, + isAppThemeName, loadThemeData, + prefersDarkMode, + resolveLocalThemeVariant, } from "./theme-loader"; const STORAGE_KEY = "sprout-theme"; @@ -46,25 +50,28 @@ type ThemeContextValue = { type ThemeProviderProps = { children: ReactNode; - defaultTheme?: SyntaxThemeName; + defaultTheme?: AppThemeName; }; const ThemeContext = createContext(undefined); -function isValidThemeName(name: string): name is SyntaxThemeName { - return (SYNTAX_THEMES as readonly string[]).includes(name); +function normalizeStoredTheme(stored: string | null): AppThemeName | null { + if (!stored) return null; + if (stored === "neutral-light" || stored === "neutral-dark") return "neutral"; + + // Migrate legacy values. + if (stored === "light") return "catppuccin-latte"; + if (stored === "dark") return "houston"; + if (stored === "system") return "houston"; + + return isAppThemeName(stored) ? stored : null; } /** 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; - - // Migrate legacy values - if (stored === "light") return "catppuccin-latte"; - if (stored === "dark" || stored === "system") return "houston"; - - return isValidThemeName(stored) ? stored : fallback; +function readStoredTheme(fallback: AppThemeName): AppThemeName { + return ( + normalizeStoredTheme(window.localStorage.getItem(STORAGE_KEY)) ?? fallback + ); } function getContrastColor(hex: string): string { @@ -99,12 +106,25 @@ function applyAccentColor(value: string) { root.style.setProperty("--sidebar-primary-foreground", fgHsl); } -/** Apply cached CSS vars synchronously to prevent FOUC. */ -function applyCachedVars(): string | null { +/** Apply initial CSS vars synchronously to prevent FOUC. */ +function applyCachedVars(themeName: AppThemeName, systemIsDark: boolean) { try { + const localVariant = resolveLocalThemeVariant(themeName, systemIsDark); + if (localVariant) { + const root = document.documentElement; + for (const [key, value] of Object.entries(localVariant.vars)) { + root.style.setProperty(key, value); + } + root.classList.remove("light", "dark"); + root.classList.add(systemIsDark ? "dark" : "light"); + return; + } + const cached = window.localStorage.getItem(CACHE_KEY); - if (!cached) return null; - const { themeName, vars, isDark } = JSON.parse(cached); + if (!cached) return; + const { themeName: cachedThemeName, vars, isDark } = JSON.parse(cached); + if (normalizeStoredTheme(cachedThemeName) !== themeName) return; + const root = document.documentElement; for (const [key, value] of Object.entries(vars)) { root.style.setProperty(key, value as string); @@ -112,25 +132,34 @@ function applyCachedVars(): string | null { 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; + // Ignore corrupt cache and let the async theme application correct it. } } /** 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, - }); +async function applyTheme( + name: AppThemeName, + systemIsDark: boolean, +): Promise<{ isDark: boolean }> { + let isDark: boolean; + let vars: Record; + + const localVariant = resolveLocalThemeVariant(name, systemIsDark); + if (localVariant) { + isDark = systemIsDark; + vars = localVariant.vars; + } else { + const themeData = await loadThemeData(name as SyntaxThemeName); + const info = extractThemeInfo(name, themeData); + ({ 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)) { @@ -157,12 +186,15 @@ export function ThemeProvider({ children, defaultTheme = "houston", }: ThemeProviderProps) { + const [systemIsDark, setSystemIsDark] = useState(prefersDarkMode); // Apply cached vars synchronously before first render const [themeName, setThemeName] = useState(() => { - const cached = applyCachedVars(); - return cached ?? readStoredTheme(defaultTheme); + const initialTheme = readStoredTheme(defaultTheme); + applyCachedVars(initialTheme, systemIsDark); + return initialTheme; }); const [isDark, setIsDark] = useState(() => { + if (resolveLocalThemeVariant(themeName, systemIsDark)) return systemIsDark; return document.documentElement.classList.contains("dark"); }); const [isLoading, setIsLoading] = useState(true); @@ -171,35 +203,51 @@ export function ThemeProvider({ return window.localStorage.getItem(ACCENT_KEY) ?? DEFAULT_ACCENT; }); + useEffect(() => { + if (typeof window.matchMedia !== "function") return; + const media = window.matchMedia("(prefers-color-scheme: dark)"); + const handleChange = (event: MediaQueryListEvent) => { + setSystemIsDark(event.matches); + }; + media.addEventListener("change", handleChange); + return () => { + media.removeEventListener("change", handleChange); + }; + }, []); + // Load and apply theme useEffect(() => { - if (!isValidThemeName(themeName)) return; + if (!isAppThemeName(themeName)) return; // Track which theme we're loading to avoid race conditions const thisTheme = themeName; - loadingRef.current = thisTheme; + const thisLoadKey = `${themeName}:${systemIsDark}`; + loadingRef.current = thisLoadKey; setIsLoading(true); - applyTheme(themeName).then(({ isDark: dark }) => { + applyTheme(themeName, systemIsDark).then(({ isDark: dark }) => { // Only update if this is still the theme we want - if (loadingRef.current === thisTheme) { + if (loadingRef.current === thisLoadKey) { setIsDark(dark); setIsLoading(false); - // Re-apply accent after theme load (theme vars don't include primary) - applyAccentColor( - window.localStorage.getItem(ACCENT_KEY) ?? DEFAULT_ACCENT, - ); + // Re-apply accent after syntax theme load (derived vars don't include primary). + if (!getLocalTheme(thisTheme)) { + applyAccentColor( + window.localStorage.getItem(ACCENT_KEY) ?? DEFAULT_ACCENT, + ); + } } }); - }, [themeName]); + }, [themeName, systemIsDark]); // Apply accent color changes useEffect(() => { + if (getLocalTheme(themeName)) return; applyAccentColor(accentColor); - }, [accentColor]); + }, [accentColor, themeName]); const setTheme = useCallback((name: string) => { - if (!isValidThemeName(name)) return; + if (!isAppThemeName(name)) return; setThemeName(name); window.localStorage.setItem(STORAGE_KEY, name); }, []); diff --git a/desktop/src/shared/theme/theme-loader.ts b/desktop/src/shared/theme/theme-loader.ts index 31e7567c6e..070a36bda6 100644 --- a/desktop/src/shared/theme/theme-loader.ts +++ b/desktop/src/shared/theme/theme-loader.ts @@ -7,6 +7,19 @@ import type { ThemeRegistrationRaw } from "shiki"; +export const LOCAL_THEME_NAMES = ["neutral"] as const; +export type LocalThemeName = (typeof LOCAL_THEME_NAMES)[number]; + +type LocalThemeVariant = { + vars: Record; + syntaxTheme: SyntaxThemeName; +}; + +type LocalThemeDefinition = { + light: LocalThemeVariant; + dark: LocalThemeVariant; +}; + // Available syntax themes (all Shiki bundled themes, alphabetically sorted) export const SYNTAX_THEMES = [ "andromeeda", @@ -72,6 +85,9 @@ export const SYNTAX_THEMES = [ ] as const; export type SyntaxThemeName = (typeof SYNTAX_THEMES)[number]; +export type AppThemeName = SyntaxThemeName | LocalThemeName; + +export const APP_THEMES = [...LOCAL_THEME_NAMES, ...SYNTAX_THEMES] as const; // Known light themes — used by the theme picker to show sun/moon icons // for themes that haven't been loaded yet. @@ -96,6 +112,99 @@ export const LIGHT_THEMES: ReadonlySet = new Set([ "vitesse-light", ]); +const localThemes: Record = { + neutral: { + light: { + syntaxTheme: "github-light-default", + vars: { + "--radius": "0.625rem", + "--background": "0 0% 100%", + "--foreground": "0 0% 3.9%", + "--card": "0 0% 100%", + "--card-foreground": "0 0% 3.9%", + "--popover": "0 0% 100%", + "--popover-foreground": "0 0% 3.9%", + "--primary": "0 0% 9%", + "--primary-foreground": "0 0% 98%", + "--secondary": "0 0% 96.1%", + "--secondary-foreground": "0 0% 9%", + "--muted": "0 0% 96.1%", + "--muted-foreground": "0 0% 45.1%", + "--accent": "0 0% 96.1%", + "--accent-foreground": "0 0% 9%", + "--destructive": "0 84.2% 60.2%", + "--destructive-foreground": "0 0% 98%", + "--border": "0 0% 89.8%", + "--input": "0 0% 89.8%", + "--ring": "0 0% 63.9%", + "--chart-1": "12 76% 61%", + "--chart-2": "173 58% 39%", + "--chart-3": "197 37% 24%", + "--chart-4": "43 74% 66%", + "--chart-5": "27 87% 67%", + "--sidebar": "0 0% 98%", + "--sidebar-background": "0 0% 98%", + "--sidebar-foreground": "0 0% 3.9%", + "--sidebar-primary": "0 0% 9%", + "--sidebar-primary-foreground": "0 0% 98%", + "--sidebar-accent": "0 0% 96.1%", + "--sidebar-accent-foreground": "0 0% 9%", + "--sidebar-border": "0 0% 89.8%", + "--sidebar-ring": "0 0% 63.9%", + "--status-added": "#16a34a", + "--status-deleted": "#dc2626", + "--status-modified": "#ca8a04", + "--ui-warning": "#ca8a04", + "--ui-warning-bg": "rgba(202, 138, 4, 0.08)", + }, + }, + dark: { + syntaxTheme: "github-dark-default", + vars: { + "--radius": "0.625rem", + "--background": "0 0% 3.9%", + "--foreground": "0 0% 98%", + "--card": "0 0% 9%", + "--card-foreground": "0 0% 98%", + "--popover": "0 0% 9%", + "--popover-foreground": "0 0% 98%", + "--primary": "0 0% 89.8%", + "--primary-foreground": "0 0% 9%", + "--secondary": "0 0% 14.9%", + "--secondary-foreground": "0 0% 98%", + "--muted": "0 0% 14.9%", + "--muted-foreground": "0 0% 63.9%", + "--accent": "0 0% 14.9%", + "--accent-foreground": "0 0% 98%", + "--destructive": "0 62.8% 30.6%", + "--destructive-foreground": "0 0% 98%", + "--border": "0 0% 14.9%", + "--input": "0 0% 14.9%", + "--ring": "0 0% 45.1%", + "--chart-1": "220 70% 50%", + "--chart-2": "160 60% 45%", + "--chart-3": "30 80% 55%", + "--chart-4": "280 65% 60%", + "--chart-5": "340 75% 55%", + "--sidebar": "0 0% 9%", + "--sidebar-background": "0 0% 9%", + "--sidebar-foreground": "0 0% 98%", + "--sidebar-primary": "0 0% 89.8%", + "--sidebar-primary-foreground": "0 0% 9%", + "--sidebar-accent": "0 0% 14.9%", + "--sidebar-accent-foreground": "0 0% 98%", + "--sidebar-border": "0 0% 14.9%", + "--sidebar-ring": "0 0% 45.1%", + "--status-added": "#3fb950", + "--status-deleted": "#f85149", + "--status-modified": "#d29922", + "--ui-warning": "#d29922", + "--ui-warning-bg": "rgba(210, 153, 34, 0.1)", + }, + }, + }, +}; + // Static theme imports (Vite needs static strings for tree-shaking) const themeImports: Record< SyntaxThemeName, @@ -169,7 +278,47 @@ const themeImports: Record< }; export function isLightTheme(name: string): boolean { - return LIGHT_THEMES.has(name as SyntaxThemeName); + const localTheme = getLocalTheme(name); + return localTheme + ? !prefersDarkMode() + : LIGHT_THEMES.has(name as SyntaxThemeName); +} + +export function isAppThemeName(name: string): name is AppThemeName { + return (APP_THEMES as readonly string[]).includes(name); +} + +export function getLocalTheme(name: string): LocalThemeDefinition | null { + return localThemes[name as LocalThemeName] ?? null; +} + +export function resolveLocalThemeVariant( + name: string, + isDark: boolean, +): LocalThemeVariant | null { + const localTheme = getLocalTheme(name); + if (!localTheme) return null; + return isDark ? localTheme.dark : localTheme.light; +} + +export function resolveSyntaxThemeName( + name: string, + isDark = prefersDarkMode(), +): SyntaxThemeName { + return ( + resolveLocalThemeVariant(name, isDark)?.syntaxTheme ?? + (name as SyntaxThemeName) + ); +} + +export function prefersDarkMode(): boolean { + if ( + typeof window === "undefined" || + typeof window.matchMedia !== "function" + ) { + return false; + } + return window.matchMedia("(prefers-color-scheme: dark)").matches; } // Theme settings type from Shiki diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index 1c6070dae9..9634039ee1 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -18,6 +18,7 @@ import { } from "shiki"; import { useTheme } from "@/shared/theme/ThemeProvider"; +import { resolveSyntaxThemeName } from "@/shared/theme/theme-loader"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { isMessageLink, @@ -440,7 +441,8 @@ function SyntaxHighlightedCode({ code: string; language: string; } & React.ComponentProps<"code">) { - const { themeName } = useTheme(); + const { themeName, isDark } = useTheme(); + const syntaxThemeName = resolveSyntaxThemeName(themeName, isDark); const [loadedKey, setLoadedKey] = React.useState(0); React.useEffect(() => { @@ -460,10 +462,10 @@ function SyntaxHighlightedCode({ return; } } - if (!loadedThemes.has(themeName as string)) { + if (!loadedThemes.has(syntaxThemeName)) { try { - await shikiHighlighter.loadTheme(themeName as BundledTheme); - loadedThemes.add(themeName as string); + await shikiHighlighter.loadTheme(syntaxThemeName as BundledTheme); + loadedThemes.add(syntaxThemeName); loaded = true; } catch { return; @@ -474,30 +476,30 @@ function SyntaxHighlightedCode({ /* ignore */ } } - if (!loadedLangs.has(language) || !loadedThemes.has(themeName as string)) { + if (!loadedLangs.has(language) || !loadedThemes.has(syntaxThemeName)) { loadAssets(); } return () => { cancelled = true; }; - }, [language, themeName]); + }, [language, syntaxThemeName]); // biome-ignore lint/correctness/useExhaustiveDependencies: loadedKey intentionally triggers re-memoization after async asset loading const tokens = React.useMemo(() => { if ( !shikiHighlighter || !loadedLangs.has(language) || - !loadedThemes.has(themeName as string) + !loadedThemes.has(syntaxThemeName) ) return null; if ((code.match(/\n/g) || []).length > MAX_HIGHLIGHT_LINES) return null; - const cacheKey = `${language}:${themeName}:${code}`; + const cacheKey = `${language}:${syntaxThemeName}:${code}`; const cached = tokenCache.get(cacheKey); if (cached) return cached; try { const result = shikiHighlighter.codeToTokens(code, { lang: language as BundledLanguage, - theme: themeName as BundledTheme, + theme: syntaxThemeName as BundledTheme, }); if (tokenCache.size >= MAX_CACHE_ENTRIES) { const firstKey = tokenCache.keys().next().value; @@ -508,7 +510,7 @@ function SyntaxHighlightedCode({ } catch { return null; } - }, [code, language, themeName, loadedKey]); + }, [code, language, syntaxThemeName, loadedKey]); const codeClassName = CODE_BLOCK_CLASS;