feat(desktop): add 'Follow system' theme mode (#1262)

Signed-off-by: Alec Thomas <aat@block.xyz>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Goose <opensource@block.xyz>
Co-authored-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Alec Thomas
2026-06-25 16:35:08 +00:00
committed by GitHub
co-authored by Goose Wes Pinky
parent 227518c234
commit c996be395e
4 changed files with 201 additions and 48 deletions
@@ -9,6 +9,7 @@ import {
Keyboard,
LayoutTemplate,
LockKeyhole,
Monitor,
MonitorCog,
Moon,
Search,
@@ -33,6 +34,7 @@ import {
useTheme,
} from "@/shared/theme/ThemeProvider";
import { SYNTAX_THEMES, isLightTheme } from "@/shared/theme/theme-loader";
import { Switch } from "@/shared/ui/switch";
import { ChannelTemplatesSettingsCard } from "./ChannelTemplatesSettingsCard";
import { DoctorSettingsPanel } from "./DoctorSettingsPanel";
import { ExperimentalFeaturesCard } from "./ExperimentalFeaturesCard";
@@ -188,8 +190,16 @@ function formatThemeLabel(name: string): string {
}
function ThemeSettingsCard() {
const { setTheme, themeName, isDark, accentColor, setAccentColor } =
useTheme();
const {
setTheme,
themeName,
selectedThemeName,
isDark,
accentColor,
setAccentColor,
followSystem,
setFollowSystem,
} = useTheme();
const [search, setSearch] = useState("");
const didScrollRef = useRef(false);
const activeRef = (node: HTMLButtonElement | null) => {
@@ -199,6 +209,8 @@ function ThemeSettingsCard() {
}
};
const selectedTheme = selectedThemeName;
const filtered = useMemo(() => {
const q = search.toLowerCase().trim();
if (!q) return SYNTAX_THEMES;
@@ -206,13 +218,16 @@ function ThemeSettingsCard() {
}, [search]);
return (
<section className="min-w-0" data-testid="settings-theme">
<section
className="flex min-h-0 flex-1 flex-col"
data-testid="settings-theme"
>
<SettingsSectionHeader
title="Appearance"
description="Choose a theme for Buzz. Light and dark mode is auto-detected."
description="Choose a theme for Buzz."
/>
<div className="relative mb-3">
<div className="relative mb-3 shrink-0">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input
autoCapitalize="none"
@@ -226,14 +241,15 @@ function ThemeSettingsCard() {
/>
</div>
<div className="max-h-72 overflow-y-auto rounded-lg border border-border/70 bg-background/70">
<div className="min-h-0 flex-1 overflow-y-auto rounded-lg border border-border/70 bg-background/70">
{filtered.length === 0 ? (
<p className="px-3 py-4 text-center text-sm text-muted-foreground">
No themes match your search.
</p>
) : (
filtered.map((name) => {
const isActive = themeName === name;
const isActive = selectedTheme === name;
const isEffective = themeName === name;
const light = isLightTheme(name);
return (
@@ -243,7 +259,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,44 +280,62 @@ function ThemeSettingsCard() {
{isActive && (
<Check className="h-4 w-4 shrink-0 text-primary" />
)}
{!isActive && isEffective && followSystem && (
<Monitor className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
)}
</button>
);
})
)}
</div>
<div className="mt-4">
<h3 className="mb-2 text-sm font-medium">Accent Color</h3>
<div className="flex gap-2">
{ACCENT_COLORS.map((color) => {
const isNeutral = color.value === NEUTRAL_ACCENT;
const swatchColor = isNeutral
? "hsl(var(--foreground))"
: color.value;
const checkClassName =
isNeutral && isDark ? "text-black" : "text-white";
<div className="mt-4 flex shrink-0 flex-col gap-3 pb-2 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<h3 className="mb-2 text-sm font-medium">Accent color</h3>
<div className="flex flex-wrap gap-2">
{ACCENT_COLORS.map((color) => {
const isNeutral = color.value === NEUTRAL_ACCENT;
const swatchColor = isNeutral
? "hsl(var(--foreground))"
: color.value;
const checkClassName =
isNeutral && isDark ? "text-black" : "text-white";
return (
<button
className={cn(
"flex h-7 w-7 items-center justify-center rounded-full border border-border/50 transition-transform hover:scale-110",
accentColor === color.value &&
"ring-2 ring-ring ring-offset-2 ring-offset-background",
)}
data-testid={`accent-color-${color.name.toLowerCase()}`}
key={color.value}
onClick={() => setAccentColor(color.value)}
style={{ backgroundColor: swatchColor }}
title={color.name}
type="button"
>
{accentColor === color.value && (
<Check className={cn("h-4 w-4", checkClassName)} />
)}
</button>
);
})}
return (
<button
className={cn(
"flex h-7 w-7 items-center justify-center rounded-full border border-border/50 transition-transform hover:scale-110",
accentColor === color.value &&
"ring-2 ring-ring ring-offset-2 ring-offset-background",
)}
data-testid={`accent-color-${color.name.toLowerCase()}`}
key={color.value}
onClick={() => setAccentColor(color.value)}
style={{ backgroundColor: swatchColor }}
title={color.name}
type="button"
>
{accentColor === color.value && (
<Check className={cn("h-4 w-4", checkClassName)} />
)}
</button>
);
})}
</div>
</div>
<label
className="flex cursor-pointer items-center gap-3 text-sm font-medium text-foreground"
htmlFor="follow-system-switch"
>
<span className="min-w-0 truncate">Use system setting</span>
<Switch
checked={followSystem}
data-testid="follow-system-toggle"
id="follow-system-switch"
onCheckedChange={setFollowSystem}
/>
</label>
</div>
</section>
);
@@ -278,7 +278,7 @@ export function SettingsView({
<div className="relative z-10 ml-px mt-px flex min-h-0 flex-1 flex-col overflow-hidden rounded-tl-xl bg-background pt-11 shadow-[-1px_-1px_0_0_hsl(var(--sidebar-border)/0.45)]">
<section className="min-h-0 flex-1 overflow-y-auto px-5 pb-5 pt-4 sm:px-6">
<div
className="mx-auto flex w-full max-w-4xl flex-col gap-4"
className="mx-auto flex h-full w-full max-w-4xl flex-col gap-4"
data-testid={`settings-panel-${section}`}
>
{renderSettingsSection(section, {
+56 -9
View File
@@ -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;
@@ -41,11 +44,15 @@ const DEFAULT_ACCENT = "#3b82f6";
type ThemeContextValue = {
themeName: string;
selectedThemeName: string;
isDark: boolean;
isLoading: boolean;
accentColor: string;
followSystem: boolean;
hasPair: boolean;
setTheme: (name: string) => void;
setAccentColor: (color: string) => void;
setFollowSystem: (enabled: boolean) => void;
};
type ThemeProviderProps = {
@@ -258,9 +265,9 @@ export function ThemeProvider({
defaultTheme = "houston",
}: ThemeProviderProps) {
// Apply cached vars synchronously before first render
const [themeName, setThemeName] = useState<string>(() => {
const cached = applyCachedVars();
return cached ?? readStoredTheme(defaultTheme);
const [selectedTheme, setSelectedTheme] = useState<string>(() => {
applyCachedVars();
return readStoredTheme(defaultTheme);
});
const [isDark, setIsDark] = useState<boolean>(() => {
return document.documentElement.classList.contains("dark");
@@ -270,16 +277,33 @@ export function ThemeProvider({
const [accentColor, setAccentColorState] = useState<string>(() => {
return window.localStorage.getItem(ACCENT_STORAGE_KEY) ?? DEFAULT_ACCENT;
});
const [followSystem, setFollowSystemState] = useState<boolean>(() => {
return window.localStorage.getItem(FOLLOW_SYSTEM_KEY) === "true";
});
const [systemIsDark, setSystemIsDark] = useState<boolean>(() => {
return window.matchMedia("(prefers-color-scheme: dark)").matches;
});
// Resolve the effective theme based on follow-system preference
const effectiveTheme = (() => {
if (!followSystem || !isValidThemeName(selectedTheme)) return selectedTheme;
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;
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);
@@ -290,7 +314,21 @@ 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 = (event: MediaQueryListEvent) => {
setSystemIsDark(event.matches);
};
setSystemIsDark(mq.matches);
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, [followSystem]);
useEffect(() => {
applyAccentColor(accentColor);
@@ -298,7 +336,7 @@ export function ThemeProvider({
const setTheme = useCallback((name: string) => {
if (!isValidThemeName(name)) return;
setThemeName(name);
setSelectedTheme(name);
window.localStorage.setItem(THEME_STORAGE_KEY, name);
}, []);
@@ -307,13 +345,22 @@ 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,
selectedThemeName: selectedTheme,
isDark,
isLoading,
accentColor,
followSystem,
hasPair,
setTheme,
setAccentColor,
setFollowSystem,
};
return (
+70
View File
@@ -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<SyntaxThemeName, SyntaxThemeName> =
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[];