Files
buzz/desktop/src/shared/theme/ThemeProvider.tsx
klopez4212 0571f5455b Polish glass Huddle tray behavior (#5590)
## Summary

- inset the in-app Huddle tray with four rounded corners and even 8px
spacing when Glass background is enabled
- keep the popped-out Huddle dock full-width
- hide and suppress Glass background on Linux

## Why

The in-app tray reused the opaque backing needed by non-glass windows,
which covered the native vibrancy around it. Linux does not support this
window treatment.

## Testing

- `pnpm -C desktop build:e2e`
- focused Appearance and Huddle Playwright smoke tests
- pre-push desktop checks, typecheck, and 4,666 unit tests

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
Co-authored-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
2026-08-13 15:40:50 -07:00

748 lines
25 KiB
TypeScript

import {
type ReactNode,
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { isTauri } from "@tauri-apps/api/core";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { invokeTauri } from "@/shared/api/tauri";
import { isMacPlatform } from "@/shared/lib/platform";
import { getStorageItem } from "@/shared/lib/safeStorage";
import { createThemeVars, hexToHsl } from "./adaptive-theme";
import {
SYNTAX_THEMES,
type SyntaxThemeName,
type ThemeInfo,
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 GLASS_BACKGROUND_STORAGE_KEY = "buzz-glass-background";
export const GLASS_OPACITY_STORAGE_KEY = "buzz-glass-opacity";
export const PROMINENT_ACTIVE_TAB_STORAGE_KEY = "buzz-prominent-active-tab";
export const GLASS_OPACITY_MIN = 30;
export const GLASS_OPACITY_MAX = 90;
export const DEFAULT_GLASS_OPACITY = 65;
export const DEFAULT_PROMINENT_ACTIVE_TAB = false;
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;
const VIDEO_REVIEW_CHIP_BACKGROUND_ALPHAS = [0.15, 0.3] as const;
const GLASS_VIBRANCY_MATERIAL = "sidebar";
export const ACCENT_COLORS = [
{ name: "Neutral", value: NEUTRAL_ACCENT },
{ name: "Blue", value: "#3b82f6" },
{ name: "Cyan", value: "#06b6d4" },
{ name: "Green", value: "#22c55e" },
{ name: "Orange", value: "#f97316" },
{ name: "Red", value: "#ef4444" },
{ name: "Pink", value: "#ec4899" },
{ name: "Lilac", value: "#c0a2f1" },
{ name: "Purple", value: "#a855f7" },
{ name: "Indigo", value: "#6366f1" },
] as const;
const DEFAULT_ACCENT = "#3b82f6";
type ThemeContextValue = {
themeName: string;
selectedThemeName: string;
isDark: boolean;
isLoading: boolean;
accentColor: string;
followSystem: boolean;
glassBackground: boolean;
glassOpacity: number;
glassBackgroundSupported: boolean;
prominentActiveTab: boolean;
hasPair: boolean;
terminalPalette: ThemeInfo["terminalPalette"] | null;
setTheme: (name: string) => void;
setAccentColor: (color: string) => void;
setFollowSystem: (enabled: boolean) => void;
applyAppearance: (appearance: {
theme: SyntaxThemeName;
accent: string;
followSystem: boolean;
}) => void;
setGlassBackground: (enabled: boolean) => void;
setGlassOpacity: (opacity: number) => void;
setProminentActiveTab: (enabled: boolean) => void;
};
type ThemeProviderProps = {
children: ReactNode;
defaultTheme?: SyntaxThemeName;
};
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
function isValidThemeName(name: string): name is SyntaxThemeName {
return (SYNTAX_THEMES as readonly string[]).includes(name);
}
/** Read stored theme, migrating legacy "light"/"dark"/"system" values. */
function readStoredTheme(fallback: SyntaxThemeName): SyntaxThemeName {
// block/buzz#5078 — WebKit throws SecurityError from getItem under a
// denied-storage origin; the throw-safe helper lets the provider degrade to
// the fallback instead of unmounting the root during first render.
const stored = getStorageItem(THEME_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 getContrastColor(hex: string): string {
const m = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})/i.exec(hex);
if (!m) return "#ffffff";
const r = parseInt(m[1], 16);
const g = parseInt(m[2], 16);
const b = parseInt(m[3], 16);
const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return lum > 0.5 ? "#000000" : "#ffffff";
}
type Rgb = {
r: number;
g: number;
b: number;
};
function hexToRgb(hex: string): Rgb {
const m = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})/i.exec(hex);
if (!m) return { r: 255, g: 255, b: 255 };
return {
r: parseInt(m[1], 16),
g: parseInt(m[2], 16),
b: parseInt(m[3], 16),
};
}
function mixRgb(from: Rgb, to: Rgb, factor: number): Rgb {
return {
r: from.r + (to.r - from.r) * factor,
g: from.g + (to.g - from.g) * factor,
b: from.b + (to.b - from.b) * factor,
};
}
function compositeRgb(foreground: Rgb, background: Rgb, alpha: number): Rgb {
return mixRgb(background, foreground, alpha);
}
function relativeLuminance({ r, g, b }: Rgb): number {
const [rs, gs, bs] = [r, g, b].map((channel) => {
const value = channel / 255;
return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
}
function contrastRatio(a: Rgb, b: Rgb): number {
const aLum = relativeLuminance(a);
const bLum = relativeLuminance(b);
return (Math.max(aLum, bLum) + 0.05) / (Math.min(aLum, bLum) + 0.05);
}
function getReviewAccentForeground(hex: string): string {
const accent = hexToRgb(hex);
const surface = hexToRgb(VIDEO_REVIEW_CHIP_SURFACE);
const white = { r: 255, g: 255, b: 255 };
const backgrounds = VIDEO_REVIEW_CHIP_BACKGROUND_ALPHAS.map((alpha) =>
compositeRgb(accent, surface, alpha),
);
let low = 0;
let high = 1;
for (let i = 0; i < 20; i++) {
const mid = (low + high) / 2;
const candidate = mixRgb(accent, white, mid);
const minContrast = Math.min(
...backgrounds.map((background) => contrastRatio(candidate, background)),
);
if (minContrast >= VIDEO_REVIEW_TEXT_CONTRAST) {
high = mid;
} else {
low = mid;
}
}
return hexToHsl(rgbToHex(mixRgb(accent, white, high)));
}
function rgbToHex({ r, g, b }: Rgb): string {
const clamp = (value: number) =>
Math.max(0, Math.min(255, Math.round(value)));
return `#${[r, g, b]
.map((channel) => clamp(channel).toString(16).padStart(2, "0"))
.join("")}`;
}
function applyAccentColor(value: string) {
const root = document.documentElement;
if (value === NEUTRAL_ACCENT) {
const styles = window.getComputedStyle(root);
const foreground = styles.getPropertyValue("--foreground").trim();
const background = styles.getPropertyValue("--background").trim();
root.style.setProperty("--buzz-selected-accent", foreground);
root.style.setProperty(
"--buzz-video-review-accent",
VIDEO_REVIEW_NEUTRAL_ACCENT,
);
root.style.setProperty(
"--buzz-video-review-accent-foreground",
VIDEO_REVIEW_NEUTRAL_ACCENT,
);
root.style.setProperty("--primary", foreground);
root.style.setProperty("--primary-foreground", background);
root.style.setProperty("--sidebar-primary", foreground);
root.style.setProperty("--sidebar-primary-foreground", background);
root.style.setProperty("--sidebar-active", foreground);
root.style.setProperty("--sidebar-active-foreground", background);
return;
}
const hex = value;
const accentHsl = hexToHsl(hex);
const fgHsl = hexToHsl(getContrastColor(hex));
root.style.setProperty("--buzz-selected-accent", accentHsl);
root.style.setProperty("--buzz-video-review-accent", accentHsl);
root.style.setProperty(
"--buzz-video-review-accent-foreground",
getReviewAccentForeground(hex),
);
root.style.setProperty("--primary", accentHsl);
root.style.setProperty("--primary-foreground", fgHsl);
root.style.setProperty("--sidebar-primary", accentHsl);
root.style.setProperty("--sidebar-primary-foreground", fgHsl);
root.style.setProperty("--sidebar-active", accentHsl);
root.style.setProperty("--sidebar-active-foreground", fgHsl);
}
/**
* The Buzz themes ship with a fixed neutral accent (the GitHub black/white
* foreground) rather than a user-selectable accent color. When a Buzz theme is
* active we force `NEUTRAL_ACCENT` regardless of the stored preference, and the
* appearance panel hides the accent picker. The user's chosen accent is left
* untouched in storage so it returns when they switch back to another theme.
*/
export function isBuzzTheme(themeName: string): boolean {
return themeName === "buzz" || themeName === "buzz-dark";
}
/**
* Resolve the accent to actually apply for a theme: Buzz themes are pinned to
* the neutral accent; every other theme uses the stored/selected accent.
*/
function resolveEffectiveAccent(
themeName: string,
accentColor: string,
): string {
return isBuzzTheme(themeName) ? NEUTRAL_ACCENT : accentColor;
}
/** Toggle the Buzz-specific gradient marker independently from glass. */
function applyBuzzSidebar(themeName: string) {
const root = document.documentElement;
if (isBuzzTheme(themeName)) {
root.setAttribute("data-buzz-sidebar", "");
// Keep the concrete Buzz variant on the root as well as the generic
// marker. The gradient stylesheet matches this attribute directly, which
// makes WKWebView invalidate the painted background when light/dark mode
// changes instead of relying only on a custom-property dependency update.
root.setAttribute("data-buzz-theme", themeName);
} else {
root.removeAttribute("data-buzz-sidebar");
root.removeAttribute("data-buzz-theme");
}
}
/**
* Toggle the transparent CSS surfaces that reveal native macOS vibrancy behind
* the navigation and outer chrome. The center content panel remains opaque.
*
* IMPORTANT: enabling glass exposes whatever the compositor paints
* behind the webview. Only enable it once the native `NSVisualEffectView`
* vibrancy layer and the active theme colors are both ready.
*/
function setGlassBackgroundActive(enabled: boolean) {
const root = document.documentElement;
if (enabled) {
// WKWebView keeps its page canvas opaque unless the root background is an
// inline transparent value, even when the equivalent author rule wins in
// the stylesheet. Clear it before exposing the native vibrancy layer.
root.style.setProperty("background", "transparent");
root.setAttribute("data-glass-background", "");
} else {
root.removeAttribute("data-glass-background");
root.style.removeProperty("background");
}
}
/** Apply the optional higher-contrast selected navigation surface. */
function setProminentActiveTabActive(enabled: boolean) {
document.documentElement.toggleAttribute(
"data-prominent-active-tab",
enabled,
);
}
function clampGlassOpacity(value: number): number {
if (!Number.isFinite(value)) return DEFAULT_GLASS_OPACITY;
return Math.min(
GLASS_OPACITY_MAX,
Math.max(GLASS_OPACITY_MIN, Math.round(value)),
);
}
function readStoredGlassOpacity(): number {
const stored = getStorageItem(GLASS_OPACITY_STORAGE_KEY);
return stored === null
? DEFAULT_GLASS_OPACITY
: clampGlassOpacity(Number(stored));
}
/** Set the tint opacity layered above native blur; lower values reveal more. */
function applyGlassOpacity(value: number) {
document.documentElement.style.setProperty(
"--glass-background-opacity",
`${clampGlassOpacity(value)}%`,
);
}
/** Only the newest overlapping native glass request may update CSS state. */
let glassVibrancyRequest = 0;
/** Whether the native vibrancy layer is confirmed installed. */
let glassVibrancyReady = false;
/** The native layer does not need rebuilding when only the theme changes. */
let glassVibrancyEnabled = false;
/** Mirrors the current preference for the async theme/native handshake. */
let glassBackgroundPreferenceEnabled = false;
/** Theme colors must be installed before the transparent surface is exposed. */
let glassThemeReady = false;
/**
* Theme loading and native vibrancy can finish in either order. Whichever lands
* last reveals the glass once both prerequisites are ready.
*/
function maybeEnableGlassBackground(requestToken: number) {
if (requestToken !== glassVibrancyRequest) return;
if (!glassBackgroundPreferenceEnabled || !isMacPlatform()) return;
if (!glassVibrancyReady || !glassThemeReady) return;
setGlassBackgroundActive(true);
}
/**
* Install native vibrancy before making the webview transparent. Non-macOS and
* web builds retain the normal opaque theme surface.
*/
async function applyWindowGlass(enabled: boolean) {
glassBackgroundPreferenceEnabled = enabled;
const requestToken = ++glassVibrancyRequest;
if (!isTauri() || !isMacPlatform()) {
glassBackgroundPreferenceEnabled = false;
glassVibrancyEnabled = false;
glassVibrancyReady = false;
setGlassBackgroundActive(false);
return;
}
if (enabled && glassVibrancyEnabled && glassVibrancyReady) {
maybeEnableGlassBackground(requestToken);
return;
}
glassVibrancyReady = false;
try {
await invokeTauri<void>("set_window_vibrancy", {
enabled,
material: GLASS_VIBRANCY_MATERIAL,
});
if (requestToken !== glassVibrancyRequest) return;
glassVibrancyEnabled = enabled;
if (enabled && isMacPlatform()) {
glassVibrancyReady = true;
maybeEnableGlassBackground(requestToken);
}
} catch (error) {
console.warn("set_window_vibrancy failed", error);
if (requestToken !== glassVibrancyRequest) return;
glassVibrancyEnabled = false;
setGlassBackgroundActive(false);
}
}
/** Apply cached CSS vars synchronously to prevent FOUC. */
function applyCachedVars(): string | null {
try {
const cached = window.localStorage.getItem(CACHE_KEY);
if (!cached) return null;
const { themeName, vars, isDark } = JSON.parse(cached);
const root = document.documentElement;
for (const [key, value] of Object.entries(vars)) {
root.style.setProperty(key, value as string);
}
root.classList.remove("light", "dark");
root.classList.add(isDark ? "dark" : "light");
applyBuzzSidebar(themeName);
glassThemeReady = true;
const accent = getStorageItem(ACCENT_STORAGE_KEY) ?? DEFAULT_ACCENT;
// Pin Buzz themes to the neutral accent here too, matching applyTheme.
// Otherwise a cached Buzz theme + non-neutral stored accent flashes the
// old accent on reload until the async applyTheme effect runs.
applyAccentColor(resolveEffectiveAccent(themeName, accent));
return themeName;
} catch {
return null;
}
}
/** The latest theme load is the only one allowed to write document styles. */
let themeApplyRequest = 0;
/** Apply a theme: load data, derive CSS vars, set them on :root. */
async function applyTheme(name: SyntaxThemeName): Promise<{
isDark: boolean;
terminalPalette: ThemeInfo["terminalPalette"];
} | null> {
const requestToken = ++themeApplyRequest;
const themeData = await loadThemeData(name);
if (requestToken !== themeApplyRequest) return null;
const info = extractThemeInfo(name, themeData);
const { 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)) {
root.style.setProperty(key, value);
}
root.classList.remove("light", "dark");
root.classList.add(isDark ? "dark" : "light");
applyBuzzSidebar(name);
glassThemeReady = true;
maybeEnableGlassBackground(glassVibrancyRequest);
// Apply the accent synchronously in the same batch as the theme vars so the
// browser paints the new theme + accent together. Doing this in a later
// microtask (e.g. the caller's `.then`) let the previous accent flash on the
// new theme for a frame — the flicker seen when switching to Buzz. Buzz
// themes resolve to the neutral accent regardless of the stored value.
applyAccentColor(
resolveEffectiveAccent(
name,
getStorageItem(ACCENT_STORAGE_KEY) ?? DEFAULT_ACCENT,
),
);
// Cache for FOUC prevention
try {
window.localStorage.setItem(
CACHE_KEY,
JSON.stringify({ themeName: name, vars, isDark }),
);
} catch {
// Storage full — non-critical
}
return { isDark, terminalPalette: info.terminalPalette };
}
export function ThemeProvider({
children,
defaultTheme = "buzz",
}: ThemeProviderProps) {
const glassBackgroundSupported = isTauri() && isMacPlatform();
// Apply cached vars synchronously before first render
const [selectedTheme, setSelectedTheme] = useState<string>(() => {
applyCachedVars();
return readStoredTheme(defaultTheme);
});
const [isDark, setIsDark] = useState<boolean>(() => {
return document.documentElement.classList.contains("dark");
});
const [isLoading, setIsLoading] = useState(true);
const [terminalPalette, setTerminalPalette] = useState<
ThemeInfo["terminalPalette"] | null
>(null);
const loadingRef = useRef<string | null>(null);
const [accentColor, setAccentColorState] = useState<string>(() => {
// block/buzz#5078 — use the throw-safe accessor for init-time reads; a
// denied-storage origin would otherwise kill the root on first mount.
return getStorageItem(ACCENT_STORAGE_KEY) ?? DEFAULT_ACCENT;
});
const [glassBackground, setGlassBackgroundState] = useState<boolean>(() => {
const stored = getStorageItem(GLASS_BACKGROUND_STORAGE_KEY);
// Glass is opt-in. Explicitly saved preferences remain intact, while a
// fresh profile starts with the normal opaque window treatment. Keep an
// unsupported platform opaque without erasing a preference saved on Mac.
const enabled = glassBackgroundSupported && stored === "true";
glassBackgroundPreferenceEnabled = enabled;
return enabled;
});
const [glassOpacity, setGlassOpacityState] = useState<number>(() => {
const opacity = readStoredGlassOpacity();
applyGlassOpacity(opacity);
return opacity;
});
const [prominentActiveTab, setProminentActiveTabState] = useState<boolean>(
() => {
const stored = getStorageItem(PROMINENT_ACTIVE_TAB_STORAGE_KEY);
return stored === null ? DEFAULT_PROMINENT_ACTIVE_TAB : stored === "true";
},
);
const [followSystem, setFollowSystemState] = useState<boolean>(() => {
const stored = getStorageItem(FOLLOW_SYSTEM_KEY);
if (stored !== null) return stored === "true";
// Fresh profiles (no saved theme) default to System mode so the Buzz
// default tracks the OS light/dark scheme. Profiles that picked a theme
// before this toggle existed keep their fixed theme until they opt in.
return getStorageItem(THEME_STORAGE_KEY) === null;
});
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(effectiveTheme)) return;
// Track which theme we're loading to avoid race conditions
const thisTheme = effectiveTheme;
loadingRef.current = thisTheme;
setIsLoading(true);
applyTheme(effectiveTheme as SyntaxThemeName).then((result) => {
if (!result) return;
// Only update if this is still the theme we want. The accent is applied
// inside applyTheme (synchronously with the theme vars), so there's no
// separate re-application here — that avoided the switch-time flicker.
if (loadingRef.current === thisTheme) {
setIsDark(result.isDark);
setTerminalPalette(result.terminalPalette);
setIsLoading(false);
}
});
}, [effectiveTheme]);
useEffect(() => {
// `initial-render-ready` fires from a layout effect, so it is already
// enqueued before this passive effect's IPC call is dispatched. The native
// reveal can in theory precede the transparency call; the Rust-side
// stable-geometry wait provides the gap in practice, and a brief opaque
// first frame is the accepted worst case for glass users.
void applyWindowGlass(glassBackground);
}, [glassBackground]);
// The stronger selected-row treatment belongs exclusively to Buzz. Keep
// the saved preference so it is restored when the user returns to Buzz,
// but remove the live marker for every other theme.
useEffect(() => {
setProminentActiveTabActive(
prominentActiveTab && isBuzzTheme(effectiveTheme),
);
}, [effectiveTheme, prominentActiveTab]);
// Listen for system color scheme changes when followSystem is enabled
useEffect(() => {
if (!followSystem) return;
const mq = window.matchMedia("(prefers-color-scheme: dark)");
const handleMediaChange = (event: MediaQueryListEvent) => {
setSystemIsDark(event.matches);
};
let disposed = false;
let unlistenNativeTheme: (() => void) | undefined;
setSystemIsDark(mq.matches);
mq.addEventListener("change", handleMediaChange);
// WKWebView can update the media query value without dispatching its
// change event until the page reloads. Tauri's native window event arrives
// immediately when macOS appearance changes, so use it as the reliable app
// signal while retaining matchMedia for the browser build.
if (isTauri()) {
void getCurrentWindow()
.onThemeChanged(({ payload }) => {
if (!disposed) setSystemIsDark(payload === "dark");
})
.then((unlisten) => {
if (disposed) {
unlisten();
} else {
unlistenNativeTheme = unlisten;
}
})
.catch((error) => {
console.warn("system theme listener unavailable", error);
});
}
return () => {
disposed = true;
mq.removeEventListener("change", handleMediaChange);
unlistenNativeTheme?.();
};
}, [followSystem]);
// Re-apply the accent when the user picks a new swatch or the effective theme
// changes. applyTheme already applies the (Buzz-neutral-aware) accent in the
// same synchronous batch as the theme vars — the flicker fix — so this effect
// is idempotent on theme changes and simply covers accent-only changes.
useEffect(() => {
applyAccentColor(resolveEffectiveAccent(effectiveTheme, accentColor));
}, [accentColor, effectiveTheme]);
const setTheme = useCallback((name: string) => {
if (!isValidThemeName(name)) return;
setSelectedTheme(name);
window.localStorage.setItem(THEME_STORAGE_KEY, name);
}, []);
const setAccentColor = useCallback((color: string) => {
window.localStorage.setItem(ACCENT_STORAGE_KEY, color);
setAccentColorState(color);
}, []);
const setFollowSystem = useCallback((enabled: boolean) => {
window.localStorage.setItem(FOLLOW_SYSTEM_KEY, enabled ? "true" : "false");
setFollowSystemState(enabled);
}, []);
const applyAppearance = useCallback(
(appearance: {
theme: SyntaxThemeName;
accent: string;
followSystem: boolean;
}) => {
// Write the complete preference before updating state so applyTheme reads
// the target community's accent in the same batch, never the previous one.
try {
window.localStorage.setItem(THEME_STORAGE_KEY, appearance.theme);
window.localStorage.setItem(ACCENT_STORAGE_KEY, appearance.accent);
window.localStorage.setItem(
FOLLOW_SYSTEM_KEY,
appearance.followSystem ? "true" : "false",
);
} catch {
// Keep the active appearance responsive even if the local cache is full.
}
setSelectedTheme(appearance.theme);
setAccentColorState(appearance.accent);
setFollowSystemState(appearance.followSystem);
},
[],
);
const setGlassBackground = useCallback(
(enabled: boolean) => {
if (!glassBackgroundSupported) {
glassBackgroundPreferenceEnabled = false;
setGlassBackgroundActive(false);
setGlassBackgroundState(false);
return;
}
window.localStorage.setItem(
GLASS_BACKGROUND_STORAGE_KEY,
enabled ? "true" : "false",
);
glassBackgroundPreferenceEnabled = enabled;
if (!enabled) {
setGlassBackgroundActive(false);
}
setGlassBackgroundState(enabled);
},
[glassBackgroundSupported],
);
const setGlassOpacity = useCallback((opacity: number) => {
const nextOpacity = clampGlassOpacity(opacity);
window.localStorage.setItem(GLASS_OPACITY_STORAGE_KEY, String(nextOpacity));
applyGlassOpacity(nextOpacity);
setGlassOpacityState(nextOpacity);
}, []);
const setProminentActiveTab = useCallback((enabled: boolean) => {
window.localStorage.setItem(
PROMINENT_ACTIVE_TAB_STORAGE_KEY,
enabled ? "true" : "false",
);
setProminentActiveTabState(enabled);
}, []);
const value: ThemeContextValue = {
themeName: effectiveTheme,
selectedThemeName: selectedTheme,
isDark,
isLoading,
accentColor,
followSystem,
glassBackground,
glassOpacity,
glassBackgroundSupported,
prominentActiveTab,
hasPair,
terminalPalette,
setTheme,
setAccentColor,
setFollowSystem,
applyAppearance,
setGlassBackground,
setGlassOpacity,
setProminentActiveTab,
};
return (
<ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error("useTheme must be used within a ThemeProvider");
}
return context;
}