feat(desktop): add system neutral theme

This commit is contained in:
klopez4212
2026-06-05 08:42:08 +01:00
parent baf1f7705f
commit ebaff3831f
4 changed files with 255 additions and 56 deletions

View File

@@ -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 (

View File

@@ -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<ThemeContextValue | undefined>(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<string, string>;
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<string>(() => {
const cached = applyCachedVars();
return cached ?? readStoredTheme(defaultTheme);
const initialTheme = readStoredTheme(defaultTheme);
applyCachedVars(initialTheme, systemIsDark);
return initialTheme;
});
const [isDark, setIsDark] = useState<boolean>(() => {
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);
}, []);

View File

@@ -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<string, string>;
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<SyntaxThemeName> = new Set([
"vitesse-light",
]);
const localThemes: Record<LocalThemeName, LocalThemeDefinition> = {
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

View File

@@ -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;