diff --git a/desktop/src/features/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx
index d2b439928c..07092372cc 100644
--- a/desktop/src/features/settings/ui/SettingsPanels.tsx
+++ b/desktop/src/features/settings/ui/SettingsPanels.tsx
@@ -1,4 +1,5 @@
import { useMemo, useState } from "react";
+import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
Archive,
BellRing,
@@ -32,6 +33,7 @@ import { LocalArchiveSettingsCard } from "@/features/local-archive/ui/LocalArchi
import { cn } from "@/shared/lib/cn";
import {
ACCENT_COLORS,
+ isBuzzTheme,
NEUTRAL_ACCENT,
useTheme,
} from "@/shared/theme/ThemeProvider";
@@ -378,10 +380,23 @@ function SingleThemeTile({
type AppearanceMode = "system" | "light" | "dark";
+// Reveal/hide motion for the accent picker: a small translate + opacity fade.
+// The picker sits below the theme grid and reads as tucking up behind it, so
+// it enters from above (slides *down* into place when a non-Buzz theme reveals
+// it) and exits upward (slides up behind the grid when Buzz hides it). No
+// height/scale — height collapse clipped the swatches behind the grid's bottom
+// fade (the "white bar"). Snappier than the modal 0.2s since this is a small
+// settings control, sharing the modal/ProfileSettingsCard easing curve.
+const ACCENT_PICKER_TRANSITION = {
+ duration: 0.16,
+ ease: [0.23, 1, 0.32, 1] as const,
+};
+
function ThemeSettingsCard() {
const {
setTheme,
selectedThemeName,
+ themeName,
isDark,
accentColor,
setAccentColor,
@@ -389,6 +404,12 @@ function ThemeSettingsCard() {
setFollowSystem,
} = useTheme();
+ // Buzz themes pin a neutral accent (GitHub black in light, white in dark),
+ // so the accent picker is hidden while a Buzz theme is active. `themeName` is
+ // the effective theme, so this also covers System mode resolving to Buzz.
+ const accentPickerHidden = isBuzzTheme(themeName);
+ const shouldReduceMotion = useReducedMotion();
+
const previewVarsByTheme = useThemePreviewVars();
const { pairedLight, lightOnly, darkOnly } = useThemeCategories();
@@ -522,15 +543,19 @@ function ThemeSettingsCard() {
"linear-gradient(to bottom, hsl(var(--background)), hsl(var(--background) / 0))",
}}
/>
- {/* Bottom fade */}
-
+ {/* Bottom fade — hidden while the accent picker is visible so its
+ near-white gradient (Buzz light) can't mask the swatches below it
+ (the "white bar"). Kept only when the picker is hidden. */}
+ {accentPickerHidden ? (
+
+ ) : null}
+ );
+}
+
export function renderSettingsSection(
section: SettingsSection,
props: SettingsPanelProps,
diff --git a/desktop/src/shared/styles/globals/theme.css b/desktop/src/shared/styles/globals/theme.css
index 13d9bbce66..85305ca4d6 100644
--- a/desktop/src/shared/styles/globals/theme.css
+++ b/desktop/src/shared/styles/globals/theme.css
@@ -308,7 +308,8 @@
* turned those white (white-on-white under Buzz Dark); scoping keeps them on
* the normal accent-driven active colors.
*/
-:root[data-buzz-sidebar] [data-testid="app-sidebar"] {
+:root[data-buzz-sidebar] [data-testid="app-sidebar"],
+:root[data-buzz-sidebar] [data-testid="settings-sidebar"] {
--sidebar-active: var(--buzz-active-fill);
--sidebar-active-foreground: var(--buzz-active-foreground);
}
@@ -317,7 +318,8 @@
* Dark variant: keep the active-pill foreground white on the dark gradient.
* (The pill itself is repainted to a translucent tint by the rule below.)
*/
-:root[data-buzz-sidebar].dark [data-testid="app-sidebar"] {
+:root[data-buzz-sidebar].dark [data-testid="app-sidebar"],
+:root[data-buzz-sidebar].dark [data-testid="settings-sidebar"] {
--sidebar-active: var(--buzz-active-fill);
--sidebar-active-foreground: var(--buzz-active-foreground);
}
@@ -327,7 +329,8 @@
* gradient (Buzz-only; other themes keep `shadow-xs`). Higher specificity
* than the `data-[active=true]:shadow-xs` utility and unlayered, so it wins.
*/
-:root[data-buzz-sidebar] [data-testid="app-sidebar"] [data-active="true"] {
+:root[data-buzz-sidebar] [data-testid="app-sidebar"] [data-active="true"],
+:root[data-buzz-sidebar] [data-testid="settings-sidebar"] [data-active="true"] {
box-shadow: none;
}
@@ -335,7 +338,10 @@
* On dark the active pill is a translucent white tint rather than a solid
* fill, so it sits on the gradient without blowing out.
*/
-:root[data-buzz-sidebar].dark [data-testid="app-sidebar"] [data-active="true"] {
+:root[data-buzz-sidebar].dark [data-testid="app-sidebar"] [data-active="true"],
+:root[data-buzz-sidebar].dark
+ [data-testid="settings-sidebar"]
+ [data-active="true"] {
background-color: color-mix(
in srgb,
hsl(var(--buzz-active-fill)) 16%,
@@ -348,13 +354,19 @@
* `--buzz-hover-surface`) instead of the grey `--sidebar-accent`. Active
* items are excluded - they stay solid on hover (their
* `data-[active=true]:hover:bg-sidebar-active` rule). Covers both top-level
- * menu buttons and channel sub-buttons.
+ * menu buttons and channel sub-buttons, in the app nav and the settings nav.
*/
:root[data-buzz-sidebar]
[data-testid="app-sidebar"]
[data-sidebar="menu-button"]:not([data-active="true"]):hover,
:root[data-buzz-sidebar]
[data-testid="app-sidebar"]
+ [data-sidebar="menu-sub-button"]:not([data-active="true"]):hover,
+:root[data-buzz-sidebar]
+ [data-testid="settings-sidebar"]
+ [data-sidebar="menu-button"]:not([data-active="true"]):hover,
+:root[data-buzz-sidebar]
+ [data-testid="settings-sidebar"]
[data-sidebar="menu-sub-button"]:not([data-active="true"]):hover {
background-color: var(--buzz-hover-surface);
}
diff --git a/desktop/src/shared/theme/ThemeProvider.tsx b/desktop/src/shared/theme/ThemeProvider.tsx
index fd6e44683e..bb22f63271 100644
--- a/desktop/src/shared/theme/ThemeProvider.tsx
+++ b/desktop/src/shared/theme/ThemeProvider.tsx
@@ -210,51 +210,166 @@ function applyAccentColor(value: string) {
root.style.setProperty("--sidebar-active-foreground", fgHsl);
}
-function isBuzzTheme(themeName: string): boolean {
+/**
+ * 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";
}
-/** Toggle the Buzz sidebar-gradient and translucency markers on the root. */
+/**
+ * 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 opaque Buzz sidebar-gradient marker. This is always safe to apply
+ * synchronously: `data-buzz-sidebar` paints solid gradient colors, so it never
+ * makes the window see-through. The *translucent* treatment (transparent
+ * root/body) is handled separately via {@link setBuzzTranslucent} because it
+ * must be sequenced against the native vibrancy layer — see
+ * {@link applyBuzzVibrancy}.
+ */
function applyBuzzSidebar(themeName: string) {
const root = document.documentElement;
if (isBuzzTheme(themeName)) {
root.setAttribute("data-buzz-sidebar", "");
- // The translucent treatment (transparent root/body + semi-transparent
- // sidebar gradient) relies on the native macOS `NSVisualEffectView`
- // vibrancy layer painting behind the webview. On Windows/Linux
- // `set_window_vibrancy` is a no-op, but the window is transparent
- // globally (tauri.conf.json), so a transparent root would show raw
- // desktop content through the UI. Gate the translucent marker and
- // transparent root background to macOS; other platforms fall back to the
- // opaque Buzz gradient (`data-buzz-sidebar` paints solid colors) with the
- // normal `bg-background` body fill.
- if (isMacPlatform()) {
- root.setAttribute("data-buzz-translucent", "");
- root.style.setProperty("background-color", "transparent");
- root.style.setProperty("background-image", "none");
- } else {
- root.removeAttribute("data-buzz-translucent");
- root.style.removeProperty("background-color");
- root.style.removeProperty("background-image");
- }
} else {
root.removeAttribute("data-buzz-sidebar");
+ // Leaving Buzz: drop translucency synchronously here too. Going *opaque*
+ // never shows desktop/prior content through, so there's no ordering risk
+ // on the way out — only on the way in.
+ setBuzzTranslucent(false);
+ }
+}
+
+/**
+ * Toggle the translucent (see-through) treatment: transparent root/body so the
+ * native macOS vibrancy layer shows through behind the sidebar glass. The
+ * transparent root/body themselves are driven by the `data-buzz-translucent`
+ * CSS rule (theme.css), so we only flip the attribute here — no inline styles.
+ *
+ * IMPORTANT: enabling translucency exposes whatever the compositor paints
+ * behind the webview. Only enable it once the native `NSVisualEffectView`
+ * vibrancy layer is confirmed installed, otherwise there's a frame where the
+ * transparent webview reveals the content behind it (the "main app nav
+ * underneath" flicker). {@link applyBuzzVibrancy} owns that sequencing.
+ */
+function setBuzzTranslucent(enabled: boolean) {
+ const root = document.documentElement;
+ if (enabled) {
+ root.setAttribute("data-buzz-translucent", "");
+ } else {
root.removeAttribute("data-buzz-translucent");
- root.style.removeProperty("background-color");
- root.style.removeProperty("background-image");
}
}
+/**
+ * Monotonic token identifying the most recent vibrancy request. Because
+ * {@link applyBuzzVibrancy} awaits the native `set_window_vibrancy` IPC, a rapid
+ * Buzz → non-Buzz toggle can fire two overlapping calls whose awaits resolve out
+ * of order. Each call captures the token before awaiting and re-checks it after;
+ * a stale continuation (superseded by a newer request) bails without touching
+ * translucency — otherwise the earlier Buzz call could re-add
+ * `data-buzz-translucent` after the later non-Buzz call already cleared it,
+ * leaving the window transparent under a non-Buzz theme.
+ */
+let buzzVibrancyRequest = 0;
+
+/**
+ * Whether the native vibrancy layer is confirmed installed for a Buzz theme.
+ * Set true only after `set_window_vibrancy(true)` resolves; cleared as soon as a
+ * new vibrancy request is issued (its outcome is not yet known).
+ */
+let buzzVibrancyReady = false;
+
+/**
+ * Enable the CSS translucency treatment, but only once BOTH prerequisites for
+ * the current request are in place:
+ *
+ * 1. the native vibrancy layer is installed ({@link buzzVibrancyReady}), and
+ * 2. the Buzz sidebar marker + gradient vars are applied (`data-buzz-sidebar`,
+ * set synchronously by {@link applyBuzzSidebar} inside {@link applyTheme}).
+ *
+ * Translucency clears the body/sidebar surfaces so the vibrancy layer shows
+ * through; enabling it before the Buzz gradient vars are installed would flash a
+ * transparent/unstyled sidebar. `applyTheme` (theme vars) and
+ * `applyBuzzVibrancy` (native layer) are independent async effects that can win
+ * their race in either order, so each calls this after its own step completes —
+ * whichever lands last flips translucency on. The token check drops stale
+ * continuations superseded by a newer theme switch.
+ */
+function maybeEnableBuzzTranslucent(themeName: string, requestToken: number) {
+ if (requestToken !== buzzVibrancyRequest) return;
+ if (!isBuzzTheme(themeName) || !isMacPlatform()) return;
+ if (!buzzVibrancyReady) return;
+ if (!document.documentElement.hasAttribute("data-buzz-sidebar")) return;
+ setBuzzTranslucent(true);
+}
+
+/**
+ * Sequence the native vibrancy layer and the CSS translucency so they land in
+ * the right order and never leave a transparent webview with nothing painted
+ * behind it:
+ *
+ * - Entering Buzz (macOS): install the vibrancy layer first (await the IPC),
+ * *then* flip on translucency. This closes the frame-gap where the root was
+ * transparent before the vibrancy view existed — the flicker.
+ * - Leaving Buzz: translucency was already removed synchronously in
+ * `applyBuzzSidebar` (safe — opaque never shows through), so here we just
+ * clear the native layer.
+ *
+ * On non-macOS `set_window_vibrancy` is a no-op and translucency stays off, so
+ * these platforms fall back to the opaque Buzz gradient.
+ *
+ * Overlapping calls are guarded by {@link buzzVibrancyRequest} so a stale async
+ * continuation can't re-enable translucency after a newer theme superseded it.
+ */
async function applyBuzzVibrancy(themeName: string) {
- if (!isTauri()) return;
+ const buzz = isBuzzTheme(themeName);
+ const requestToken = ++buzzVibrancyRequest;
+ // A new request is in flight — the vibrancy layer's readiness for it is not
+ // yet known, so any stale "ready" from a prior request must not gate this one.
+ buzzVibrancyReady = false;
+
+ if (!isTauri()) {
+ // Web/dev preview: no native vibrancy layer exists, so translucency would
+ // show raw page background. Keep it off; the opaque gradient stands in.
+ setBuzzTranslucent(false);
+ return;
+ }
try {
await invokeTauri("set_window_vibrancy", {
- enabled: isBuzzTheme(themeName),
+ enabled: buzz,
material: BUZZ_VIBRANCY_MATERIAL,
});
+ // A newer theme change superseded this request while the IPC was in flight;
+ // that later call owns the current translucency state, so don't clobber it.
+ if (requestToken !== buzzVibrancyRequest) return;
+ // Native layer is installed. Record readiness and try to enable translucency
+ // — but only if `applyBuzzSidebar` has already installed the Buzz gradient
+ // vars. If that effect hasn't landed yet (the IPC won the race), it will
+ // call maybeEnableBuzzTranslucent itself once the marker is applied.
+ if (buzz && isMacPlatform()) {
+ buzzVibrancyReady = true;
+ maybeEnableBuzzTranslucent(themeName, requestToken);
+ }
} catch (error) {
console.warn("set_window_vibrancy failed", error);
+ if (requestToken !== buzzVibrancyRequest) return;
+ // Vibrancy failed — don't go transparent or we'd show through to nothing.
+ setBuzzTranslucent(false);
}
}
@@ -274,7 +389,10 @@ function applyCachedVars(): string | null {
const accent =
window.localStorage.getItem(ACCENT_STORAGE_KEY) ?? DEFAULT_ACCENT;
- applyAccentColor(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 {
@@ -300,6 +418,24 @@ async function applyTheme(name: SyntaxThemeName): Promise<{ isDark: boolean }> {
root.classList.remove("light", "dark");
root.classList.add(isDark ? "dark" : "light");
applyBuzzSidebar(name);
+ // The Buzz gradient vars are now installed. If the vibrancy layer already
+ // resolved for the current request (the IPC won the race against this theme
+ // load), enable translucency now — otherwise applyBuzzVibrancy does it. This
+ // is the second half of the two-effect handshake; the token guards against a
+ // superseding theme switch.
+ maybeEnableBuzzTranslucent(name, buzzVibrancyRequest);
+
+ // 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,
+ window.localStorage.getItem(ACCENT_STORAGE_KEY) ?? DEFAULT_ACCENT,
+ ),
+ );
// Cache for FOUC prevention
try {
@@ -358,14 +494,12 @@ export function ThemeProvider({
setIsLoading(true);
applyTheme(effectiveTheme as SyntaxThemeName).then(({ isDark: dark }) => {
- // Only update if this is still the theme we want
+ // 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(dark);
setIsLoading(false);
- // Re-apply accent after theme load (theme vars don't include primary)
- applyAccentColor(
- window.localStorage.getItem(ACCENT_STORAGE_KEY) ?? DEFAULT_ACCENT,
- );
}
});
}, [effectiveTheme]);
@@ -389,9 +523,13 @@ export function ThemeProvider({
return () => mq.removeEventListener("change", handler);
}, [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(accentColor);
- }, [accentColor]);
+ applyAccentColor(resolveEffectiveAccent(effectiveTheme, accentColor));
+ }, [accentColor, effectiveTheme]);
const setTheme = useCallback((name: string) => {
if (!isValidThemeName(name)) return;
diff --git a/desktop/tests/e2e/buzz-theme-screenshots.spec.ts b/desktop/tests/e2e/buzz-theme-screenshots.spec.ts
index 60856cddc9..cb55c64f76 100644
--- a/desktop/tests/e2e/buzz-theme-screenshots.spec.ts
+++ b/desktop/tests/e2e/buzz-theme-screenshots.spec.ts
@@ -81,3 +81,62 @@ test("appearance picker — dark tab (Buzz Dark)", async ({ page }) => {
const panel = await openAppearance(page, "dark");
await panel.screenshot({ path: `${SHOTS}/05-picker-dark.png` });
});
+
+test("settings nav uses Buzz active pill + hover (light)", async ({ page }) => {
+ await seedTheme(page, "buzz");
+ await installMockBridge(page);
+ await page.goto("/", { waitUntil: "domcontentloaded" });
+ await page.getByTestId("open-settings").click();
+ await page.getByTestId("profile-popover-settings").click();
+ const sidebar = page.getByTestId("settings-sidebar");
+ await expect(sidebar).toBeVisible({ timeout: 10_000 });
+ // Appearance is the active section here; its nav row should carry the Buzz
+ // white active pill (data-active=true), matching the Left Nav treatment.
+ await page.getByTestId("settings-nav-appearance").click();
+ const activeRow = page.getByTestId("settings-nav-appearance");
+ await expect(activeRow).toHaveAttribute("data-active", "true");
+ await waitForAnimations(page);
+ await sidebar.screenshot({ path: `${SHOTS}/06-settings-nav-light.png` });
+});
+
+test("settings nav uses Buzz active pill + hover (dark)", async ({ page }) => {
+ await seedTheme(page, "buzz-dark");
+ await installMockBridge(page);
+ await page.goto("/", { waitUntil: "domcontentloaded" });
+ await page.getByTestId("open-settings").click();
+ await page.getByTestId("profile-popover-settings").click();
+ const sidebar = page.getByTestId("settings-sidebar");
+ await expect(sidebar).toBeVisible({ timeout: 10_000 });
+ await page.getByTestId("settings-nav-appearance").click();
+ await waitForAnimations(page);
+ await sidebar.screenshot({ path: `${SHOTS}/07-settings-nav-dark.png` });
+});
+
+test("appearance hides accent picker under Buzz", async ({ page }) => {
+ await seedTheme(page, "buzz");
+ await installMockBridge(page);
+ const panel = await openAppearance(page, "light");
+ // The accent picker is hidden while a Buzz theme is active. Its neutral
+ // swatch testid must not be present.
+ await expect(page.getByTestId("accent-color-neutral")).toHaveCount(0);
+ await panel.screenshot({ path: `${SHOTS}/08-appearance-no-accent.png` });
+});
+
+test("accent picker reveals/hides when toggling Buzz", async ({ page }) => {
+ // Start on a non-Buzz theme so the accent picker is present, then select the
+ // Buzz tile — the picker should animate out and unmount. Reselecting a
+ // non-Buzz tile brings it back. Asserts the presence toggle (the motion
+ // wrapper) works end to end.
+ await seedTheme(page, "github-light");
+ await installMockBridge(page);
+ await openAppearance(page, "light");
+ await expect(page.getByTestId("accent-color-neutral")).toBeVisible();
+
+ // Switch to Buzz — picker should leave (allow the exit animation to settle).
+ await page.getByTestId("theme-option-buzz").click();
+ await expect(page.getByTestId("accent-color-neutral")).toHaveCount(0);
+
+ // Back to a non-Buzz theme — picker returns.
+ await page.getByTestId("theme-option-github-light").click();
+ await expect(page.getByTestId("accent-color-neutral")).toBeVisible();
+});