mirror of
https://github.com/block/buzz.git
synced 2026-11-04 00:12:02 +00:00
feat(desktop): add system neutral theme
This commit is contained in:
@@ -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 (
|
||||
|
||||
@@ -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);
|
||||
}, []);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user