Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 68 additions & 6 deletions desktop/src/features/settings/ui/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
Keyboard,
LayoutTemplate,
LockKeyhole,
Monitor,
MonitorCog,
Moon,
Search,
Expand All @@ -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";
Expand Down Expand Up @@ -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) => {
Expand All @@ -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 (
<section className="min-w-0" 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."
/>

{/* Follow System toggle */}
<label
className="mb-3 flex cursor-pointer items-center gap-3 rounded-lg border border-border/70 bg-background/70 px-3 py-2.5"
data-testid="follow-system-toggle"
>
<Monitor className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="flex-1 min-w-0">
<span className="text-sm font-medium">Follow system</span>
{followSystem && pairName && (
<p className="text-xs text-muted-foreground truncate">
Switches to {pairName} in{" "}
{isLightTheme(selectedTheme) ? "dark" : "light"} mode
</p>
)}
{followSystem && !hasPair && (
<p className="text-xs text-muted-foreground">
No paired theme available — select a theme with a light/dark
counterpart
</p>
)}
</div>
<input
checked={followSystem}
className="h-4 w-4 rounded border-border accent-primary"
onChange={(e) => setFollowSystem(e.target.checked)}
type="checkbox"
/>
</label>

<div className="relative mb-3">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input
Expand All @@ -233,7 +289,8 @@ function ThemeSettingsCard() {
</p>
) : (
filtered.map((name) => {
const isActive = themeName === name;
const isActive = selectedTheme === name;
const isEffective = themeName === name;
const light = isLightTheme(name);

return (
Expand All @@ -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}
Expand All @@ -262,6 +321,9 @@ 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>
);
})
Expand Down
85 changes: 78 additions & 7 deletions desktop/src/shared/theme/ThemeProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 = {
Expand Down Expand Up @@ -259,7 +265,7 @@ export function ThemeProvider({
defaultTheme = "houston",
}: ThemeProviderProps) {
// Apply cached vars synchronously before first render
const [themeName, setThemeName] = useState<string>(() => {
const [selectedTheme, setSelectedTheme] = useState<string>(() => {
const cached = applyCachedVars();
return cached ?? readStoredTheme(defaultTheme);
});
Expand All @@ -271,17 +277,34 @@ 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";
});

// 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);
Expand All @@ -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(() => {
Expand All @@ -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);
}, []);

Expand All @@ -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 (
Expand Down
70 changes: 70 additions & 0 deletions desktop/src/shared/theme/theme-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down
Loading