mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(desktop): make theme selection explicit
Remove the auto-detected light/dark promise from Appearance and treat the saved theme as the source of truth during startup so the shell comes up with the selected palette instead of a system-driven fallback. Keep user-selected accent colors layered on top of the chosen theme and cover both theme persistence and accent persistence in the Playwright settings flow.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useState, useMemo, useRef } from "react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
BellRing,
|
||||
Check,
|
||||
@@ -98,7 +98,7 @@ export const settingsSections: SettingsSectionDescriptor[] = [
|
||||
function formatThemeLabel(name: string): string {
|
||||
return name
|
||||
.split("-")
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
@@ -113,10 +113,13 @@ function ThemeSettingsCard() {
|
||||
}
|
||||
};
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.toLowerCase().trim();
|
||||
if (!q) return SYNTAX_THEMES;
|
||||
return SYNTAX_THEMES.filter((name) => name.includes(q));
|
||||
const filteredThemes = useMemo(() => {
|
||||
const query = search.toLowerCase().trim();
|
||||
if (!query) {
|
||||
return SYNTAX_THEMES;
|
||||
}
|
||||
|
||||
return SYNTAX_THEMES.filter((name) => name.includes(query));
|
||||
}, [search]);
|
||||
|
||||
return (
|
||||
@@ -124,7 +127,8 @@ function ThemeSettingsCard() {
|
||||
<div className="mb-3 min-w-0">
|
||||
<h2 className="text-sm font-semibold tracking-tight">Appearance</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Choose a theme for Sprout. Light and dark mode is auto-detected.
|
||||
Pick the theme Sprout should use. Your selection stays active until
|
||||
you choose another one.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -132,7 +136,7 @@ function ThemeSettingsCard() {
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
className="w-full rounded-lg border border-border/70 bg-background/70 py-2 pl-9 pr-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="Search themes..."
|
||||
type="text"
|
||||
value={search}
|
||||
@@ -140,12 +144,12 @@ function ThemeSettingsCard() {
|
||||
</div>
|
||||
|
||||
<div className="max-h-72 overflow-y-auto rounded-lg border border-border/70 bg-background/70">
|
||||
{filtered.length === 0 ? (
|
||||
{filteredThemes.length === 0 ? (
|
||||
<p className="px-3 py-4 text-center text-sm text-muted-foreground">
|
||||
No themes match your search.
|
||||
</p>
|
||||
) : (
|
||||
filtered.map((name) => {
|
||||
filteredThemes.map((name) => {
|
||||
const isActive = themeName === name;
|
||||
const light = isLightTheme(name);
|
||||
|
||||
@@ -172,9 +176,9 @@ function ThemeSettingsCard() {
|
||||
<span className="flex-1 truncate">
|
||||
{formatThemeLabel(name)}
|
||||
</span>
|
||||
{isActive && (
|
||||
{isActive ? (
|
||||
<Check className="h-4 w-4 shrink-0 text-primary" />
|
||||
)}
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
@@ -198,9 +202,9 @@ function ThemeSettingsCard() {
|
||||
title={color.name}
|
||||
type="button"
|
||||
>
|
||||
{accentColor === color.value && (
|
||||
{accentColor === color.value ? (
|
||||
<Check className="h-3.5 w-3.5 text-white" />
|
||||
)}
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -29,7 +29,7 @@ function renderApp() {
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider defaultTheme="houston">
|
||||
<ThemeProvider>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<App />
|
||||
<Toaster />
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { createThemeVars, hexToHsl } from "./adaptive-theme";
|
||||
import {
|
||||
SYNTAX_THEMES,
|
||||
isLightTheme,
|
||||
type SyntaxThemeName,
|
||||
extractThemeInfo,
|
||||
loadThemeData,
|
||||
@@ -18,6 +19,9 @@ import {
|
||||
const STORAGE_KEY = "sprout-theme";
|
||||
const CACHE_KEY = "sprout-theme-cache";
|
||||
const ACCENT_KEY = "sprout-accent-color";
|
||||
const CACHE_VERSION = 2;
|
||||
const DEFAULT_THEME: SyntaxThemeName = "houston";
|
||||
const DEFAULT_ACCENT = "#3b82f6";
|
||||
|
||||
export const ACCENT_COLORS = [
|
||||
{ name: "Blue", value: "#3b82f6" },
|
||||
@@ -31,10 +35,8 @@ export const ACCENT_COLORS = [
|
||||
{ name: "Indigo", value: "#6366f1" },
|
||||
] as const;
|
||||
|
||||
const DEFAULT_ACCENT = "#3b82f6";
|
||||
|
||||
type ThemeContextValue = {
|
||||
themeName: string;
|
||||
themeName: SyntaxThemeName;
|
||||
isDark: boolean;
|
||||
isLoading: boolean;
|
||||
accentColor: string;
|
||||
@@ -44,7 +46,6 @@ type ThemeContextValue = {
|
||||
|
||||
type ThemeProviderProps = {
|
||||
children: ReactNode;
|
||||
defaultTheme?: SyntaxThemeName;
|
||||
};
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
|
||||
@@ -53,16 +54,22 @@ 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 {
|
||||
/** Read the stored explicit theme, migrating legacy appearance values. */
|
||||
function readStoredTheme(): SyntaxThemeName {
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!stored) return fallback;
|
||||
if (!stored) {
|
||||
return DEFAULT_THEME;
|
||||
}
|
||||
|
||||
// Migrate legacy values
|
||||
if (stored === "light") return "catppuccin-latte";
|
||||
if (stored === "dark" || stored === "system") return "houston";
|
||||
if (stored === "light") {
|
||||
return "catppuccin-latte";
|
||||
}
|
||||
|
||||
return isValidThemeName(stored) ? stored : fallback;
|
||||
if (stored === "dark" || stored === "system") {
|
||||
return DEFAULT_THEME;
|
||||
}
|
||||
|
||||
return isValidThemeName(stored) ? stored : DEFAULT_THEME;
|
||||
}
|
||||
|
||||
function getContrastColor(hex: string): string {
|
||||
@@ -85,23 +92,47 @@ function applyAccentColor(hex: string) {
|
||||
root.style.setProperty("--sidebar-primary-foreground", fgHsl);
|
||||
}
|
||||
|
||||
function applyThemeClass(themeName: SyntaxThemeName) {
|
||||
const root = document.documentElement;
|
||||
root.classList.remove("light", "dark");
|
||||
root.classList.add(isLightTheme(themeName) ? "light" : "dark");
|
||||
}
|
||||
|
||||
/** Apply cached CSS vars synchronously to prevent FOUC. */
|
||||
function applyCachedVars(): string | null {
|
||||
function applyCachedVars(): SyntaxThemeName | null {
|
||||
try {
|
||||
const cached = window.localStorage.getItem(CACHE_KEY);
|
||||
if (!cached) return null;
|
||||
const { themeName, vars, isDark } = JSON.parse(cached);
|
||||
|
||||
const {
|
||||
version,
|
||||
themeName,
|
||||
vars,
|
||||
isDark,
|
||||
}: {
|
||||
version?: number;
|
||||
themeName?: string;
|
||||
vars?: Record<string, string>;
|
||||
isDark?: boolean;
|
||||
} = JSON.parse(cached);
|
||||
|
||||
if (
|
||||
version !== CACHE_VERSION ||
|
||||
!themeName ||
|
||||
!isValidThemeName(themeName) ||
|
||||
!vars ||
|
||||
typeof isDark !== "boolean"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const root = document.documentElement;
|
||||
for (const [key, value] of Object.entries(vars)) {
|
||||
root.style.setProperty(key, value as string);
|
||||
root.style.setProperty(key, value);
|
||||
}
|
||||
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);
|
||||
|
||||
applyAccentColor(window.localStorage.getItem(ACCENT_KEY) ?? DEFAULT_ACCENT);
|
||||
return themeName;
|
||||
} catch {
|
||||
return null;
|
||||
@@ -130,7 +161,7 @@ async function applyTheme(name: SyntaxThemeName): Promise<{ isDark: boolean }> {
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
CACHE_KEY,
|
||||
JSON.stringify({ themeName: name, vars, isDark }),
|
||||
JSON.stringify({ version: CACHE_VERSION, themeName: name, vars, isDark }),
|
||||
);
|
||||
} catch {
|
||||
// Storage full — non-critical
|
||||
@@ -139,53 +170,59 @@ async function applyTheme(name: SyntaxThemeName): Promise<{ isDark: boolean }> {
|
||||
return { isDark };
|
||||
}
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = "houston",
|
||||
}: ThemeProviderProps) {
|
||||
// Apply cached vars synchronously before first render
|
||||
const [themeName, setThemeName] = useState<string>(() => {
|
||||
const cached = applyCachedVars();
|
||||
return cached ?? readStoredTheme(defaultTheme);
|
||||
});
|
||||
const [isDark, setIsDark] = useState<boolean>(() => {
|
||||
return document.documentElement.classList.contains("dark");
|
||||
export function ThemeProvider({ children }: ThemeProviderProps) {
|
||||
// Apply cached vars synchronously before first render when available.
|
||||
const [themeName, setThemeName] = useState<SyntaxThemeName>(() => {
|
||||
const cachedTheme = applyCachedVars();
|
||||
if (cachedTheme) {
|
||||
return cachedTheme;
|
||||
}
|
||||
|
||||
const storedTheme = readStoredTheme();
|
||||
applyThemeClass(storedTheme);
|
||||
return storedTheme;
|
||||
});
|
||||
const [isDark, setIsDark] = useState<boolean>(() =>
|
||||
document.documentElement.classList.contains("dark"),
|
||||
);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const loadingRef = useRef<string | null>(null);
|
||||
const [accentColor, setAccentColorState] = useState<string>(() => {
|
||||
return window.localStorage.getItem(ACCENT_KEY) ?? DEFAULT_ACCENT;
|
||||
});
|
||||
const loadingRef = useRef<SyntaxThemeName | null>(null);
|
||||
|
||||
// Load and apply theme
|
||||
// Load and apply the selected theme.
|
||||
useEffect(() => {
|
||||
if (!isValidThemeName(themeName)) return;
|
||||
|
||||
// Track which theme we're loading to avoid race conditions
|
||||
const thisTheme = themeName;
|
||||
loadingRef.current = thisTheme;
|
||||
setIsLoading(true);
|
||||
|
||||
applyTheme(themeName).then(({ isDark: dark }) => {
|
||||
// Only update if this is still the theme we want
|
||||
if (loadingRef.current === thisTheme) {
|
||||
applyTheme(themeName)
|
||||
.then(({ isDark: dark }) => {
|
||||
if (loadingRef.current !== thisTheme) return;
|
||||
setIsDark(dark);
|
||||
setIsLoading(false);
|
||||
// Re-apply accent after theme load (theme vars don't include primary)
|
||||
applyAccentColor(
|
||||
window.localStorage.getItem(ACCENT_KEY) ?? DEFAULT_ACCENT,
|
||||
);
|
||||
}
|
||||
});
|
||||
window.localStorage.setItem(STORAGE_KEY, thisTheme);
|
||||
})
|
||||
.catch(() => {
|
||||
if (loadingRef.current !== thisTheme) return;
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, [themeName]);
|
||||
|
||||
// Apply accent color changes
|
||||
// Apply accent color changes on top of the selected theme.
|
||||
useEffect(() => {
|
||||
applyAccentColor(accentColor);
|
||||
}, [accentColor]);
|
||||
|
||||
const setTheme = useCallback((name: string) => {
|
||||
if (!isValidThemeName(name)) return;
|
||||
if (!isValidThemeName(name)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setThemeName(name);
|
||||
window.localStorage.setItem(STORAGE_KEY, name);
|
||||
}, []);
|
||||
|
||||
@@ -73,8 +73,8 @@ export const SYNTAX_THEMES = [
|
||||
|
||||
export type SyntaxThemeName = (typeof SYNTAX_THEMES)[number];
|
||||
|
||||
// Known light themes — used by the theme picker to show sun/moon icons
|
||||
// for themes that haven't been loaded yet.
|
||||
// Known light themes — used by the theme picker icons and to seed the root
|
||||
// class before the full theme variables finish loading.
|
||||
export const LIGHT_THEMES: ReadonlySet<SyntaxThemeName> = new Set([
|
||||
"catppuccin-latte",
|
||||
"everforest-light",
|
||||
@@ -204,15 +204,29 @@ function stripAlpha(color: string): string {
|
||||
return color;
|
||||
}
|
||||
|
||||
function findColor(
|
||||
colors: Record<string, string> | undefined,
|
||||
keys: readonly string[],
|
||||
): string | null {
|
||||
if (!colors) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const key of keys) {
|
||||
const value = colors[key];
|
||||
if (value) {
|
||||
return stripAlpha(value);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractGitColors(colors: Record<string, string> | undefined): {
|
||||
added: string | null;
|
||||
deleted: string | null;
|
||||
modified: string | null;
|
||||
} {
|
||||
if (!colors) {
|
||||
return { added: null, deleted: null, modified: null };
|
||||
}
|
||||
|
||||
const addedKeys = [
|
||||
"gitDecoration.addedResourceForeground",
|
||||
"editorGutter.addedBackground",
|
||||
@@ -228,18 +242,10 @@ function extractGitColors(colors: Record<string, string> | undefined): {
|
||||
"editorGutter.modifiedBackground",
|
||||
];
|
||||
|
||||
const findColor = (keys: string[]): string | null => {
|
||||
for (const key of keys) {
|
||||
const value = colors[key];
|
||||
if (value) return stripAlpha(value);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return {
|
||||
added: findColor(addedKeys),
|
||||
deleted: findColor(deletedKeys),
|
||||
modified: findColor(modifiedKeys),
|
||||
added: findColor(colors, addedKeys),
|
||||
deleted: findColor(colors, deletedKeys),
|
||||
modified: findColor(colors, modifiedKeys),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -261,9 +267,8 @@ export function extractThemeInfo(
|
||||
(theme.colors?.["editor.background"] as string | undefined) || "#1e1e1e";
|
||||
const fg =
|
||||
(theme.colors?.["editor.foreground"] as string | undefined) || "#d4d4d4";
|
||||
const gitColors = extractGitColors(
|
||||
theme.colors as Record<string, string> | undefined,
|
||||
);
|
||||
const colors = theme.colors as Record<string, string> | undefined;
|
||||
const gitColors = extractGitColors(colors);
|
||||
return {
|
||||
name: themeName,
|
||||
bg,
|
||||
|
||||
@@ -276,7 +276,7 @@ test("desktop notification clicks open the matching forum thread", async ({
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("opens settings with the keyboard shortcut and updates theme", async ({
|
||||
test("opens settings with the keyboard shortcut, applies the selected theme, and preserves the user accent", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
@@ -290,14 +290,14 @@ test("opens settings with the keyboard shortcut and updates theme", async ({
|
||||
await expect(page.getByTestId("settings-nav-appearance")).toBeVisible();
|
||||
await page.getByTestId("settings-nav-appearance").click();
|
||||
|
||||
// Default theme is catppuccin-macchiato (dark)
|
||||
// The default theme is applied directly on load.
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => document.documentElement.classList.contains("dark")),
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
// Switch to a light theme — verifies dark→light transition
|
||||
// Selecting a theme applies it directly and persists the choice.
|
||||
await page.getByTestId("theme-option-github-light").click();
|
||||
|
||||
await expect
|
||||
@@ -306,27 +306,36 @@ test("opens settings with the keyboard shortcut and updates theme", async ({
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => document.documentElement.classList.contains("dark")),
|
||||
)
|
||||
.toBe(false);
|
||||
|
||||
// CSS variables are set on the root element (the real theming mechanism)
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() =>
|
||||
document.documentElement.style.getPropertyValue("--background").trim(),
|
||||
),
|
||||
)
|
||||
.toBeTruthy();
|
||||
|
||||
// Theme name persists in localStorage
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => localStorage.getItem("sprout-theme")))
|
||||
.toBe("github-light");
|
||||
|
||||
// Switch back to a dark theme — verifies light→dark transition
|
||||
const primaryBeforeAccent = await page.evaluate(() =>
|
||||
document.documentElement.style.getPropertyValue("--primary").trim(),
|
||||
);
|
||||
expect(primaryBeforeAccent).toBeTruthy();
|
||||
|
||||
await page.getByTestId("accent-color-red").click();
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => localStorage.getItem("sprout-accent-color")),
|
||||
)
|
||||
.toBe("#ef4444");
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() =>
|
||||
document.documentElement.style.getPropertyValue("--primary").trim(),
|
||||
),
|
||||
)
|
||||
.not.toBe(primaryBeforeAccent);
|
||||
|
||||
const redAccent = await page.evaluate(() =>
|
||||
document.documentElement.style.getPropertyValue("--primary").trim(),
|
||||
);
|
||||
expect(redAccent).toBeTruthy();
|
||||
|
||||
await page.getByTestId("theme-option-dracula").click();
|
||||
|
||||
await expect
|
||||
@@ -339,6 +348,14 @@ test("opens settings with the keyboard shortcut and updates theme", async ({
|
||||
.poll(() => page.evaluate(() => localStorage.getItem("sprout-theme")))
|
||||
.toBe("dracula");
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() =>
|
||||
document.documentElement.style.getPropertyValue("--primary").trim(),
|
||||
),
|
||||
)
|
||||
.toBe(redAccent);
|
||||
|
||||
// Close settings with keyboard shortcut
|
||||
await page.keyboard.press(
|
||||
process.platform === "darwin" ? "Meta+," : "Control+,",
|
||||
|
||||
Reference in New Issue
Block a user