mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
Goose
Wes
Pinky
parent
227518c234
commit
c996be395e
@@ -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, {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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[];
|
||||
|
||||
Reference in New Issue
Block a user