From 08539663a804fe1270311d985bf4b0b6f52b379c Mon Sep 17 00:00:00 2001
From: npub13fn4ahfnvaa2qwylvegdgeajqs0mph6v4qsw4jcqnw4mjh3hzh2quuucm5
<8a675edd33677aa0389f6650d467b2041fb0df4ca820eacb009babb95e3715d4@sprout-oss.stage.blox.sqprod.co>
Date: Thu, 9 Jul 2026 16:54:30 +0100
Subject: [PATCH 01/10] fix(desktop): match Buzz theme settings nav to Left Nav
+ pin neutral accent
The Buzz theme's active-pill and hover styling in theme.css was scoped to
[data-testid="app-sidebar"], so the settings pane's sidebar
([data-testid="settings-sidebar"]) fell back to the default grey pill/hover.
Broaden the active-token, active-pill-shadow, dark-tint, and hover-fill rules
to also match settings-sidebar so both light and dark Buzz variants render the
white active pill and translucent-white hover, matching the Left Nav.
Also pin the accent to the neutral (GitHub black/white) accent whenever a Buzz
theme is active and hide the accent picker in Appearance. The stored accent is
left untouched so it returns when switching back to another theme.
Extend the existing buzz-theme Playwright spec to cover the settings nav active
pill (light + dark) and assert the accent picker is hidden under Buzz.
Signed-off-by: npub13fn4ahfnvaa2qwylvegdgeajqs0mph6v4qsw4jcqnw4mjh3hzh2quuucm5 <8a675edd33677aa0389f6650d467b2041fb0df4ca820eacb009babb95e3715d4@sprout-oss.stage.blox.sqprod.co>
---
.../features/settings/ui/SettingsPanels.tsx | 75 +++++++++++--------
desktop/src/shared/styles/globals/theme.css | 22 ++++--
desktop/src/shared/theme/ThemeProvider.tsx | 32 ++++++--
.../tests/e2e/buzz-theme-screenshots.spec.ts | 40 ++++++++++
4 files changed, 126 insertions(+), 43 deletions(-)
diff --git a/desktop/src/features/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx
index d2b439928c..fe5405b199 100644
--- a/desktop/src/features/settings/ui/SettingsPanels.tsx
+++ b/desktop/src/features/settings/ui/SettingsPanels.tsx
@@ -32,6 +32,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";
@@ -382,6 +383,7 @@ function ThemeSettingsCard() {
const {
setTheme,
selectedThemeName,
+ themeName,
isDark,
accentColor,
setAccentColor,
@@ -389,6 +391,11 @@ 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 previewVarsByTheme = useThemePreviewVars();
const { pairedLight, lightOnly, darkOnly } = useThemeCategories();
@@ -572,40 +579,42 @@ function ThemeSettingsCard() {
- {/* Accent color picker */}
-
+
+ )}
+
);
}
diff --git a/desktop/src/shared/theme/ThemeProvider.tsx b/desktop/src/shared/theme/ThemeProvider.tsx
index 85302a781c..3ba431502e 100644
--- a/desktop/src/shared/theme/ThemeProvider.tsx
+++ b/desktop/src/shared/theme/ThemeProvider.tsx
@@ -319,6 +319,18 @@ async function applyTheme(name: SyntaxThemeName): Promise<{ isDark: boolean }> {
root.classList.add(isDark ? "dark" : "light");
applyBuzzSidebar(name);
+ // 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 {
window.localStorage.setItem(
@@ -376,18 +388,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).
- // Buzz themes force the neutral accent regardless of stored value.
- applyAccentColor(
- resolveEffectiveAccent(
- thisTheme,
- window.localStorage.getItem(ACCENT_STORAGE_KEY) ?? DEFAULT_ACCENT,
- ),
- );
}
});
}, [effectiveTheme]);
@@ -411,6 +417,10 @@ 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(resolveEffectiveAccent(effectiveTheme, accentColor));
}, [accentColor, effectiveTheme]);
diff --git a/desktop/tests/e2e/buzz-theme-screenshots.spec.ts b/desktop/tests/e2e/buzz-theme-screenshots.spec.ts
index b7f33997f8..cb55c64f76 100644
--- a/desktop/tests/e2e/buzz-theme-screenshots.spec.ts
+++ b/desktop/tests/e2e/buzz-theme-screenshots.spec.ts
@@ -121,3 +121,22 @@ test("appearance hides accent picker under Buzz", async ({ page }) => {
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();
+});
From 8479f50a2498d9aae670d783cffe3f08d0f2b4f2 Mon Sep 17 00:00:00 2001
From: npub13fn4ahfnvaa2qwylvegdgeajqs0mph6v4qsw4jcqnw4mjh3hzh2quuucm5
<8a675edd33677aa0389f6650d467b2041fb0df4ca820eacb009babb95e3715d4@sprout-oss.stage.blox.sqprod.co>
Date: Thu, 9 Jul 2026 17:23:19 +0100
Subject: [PATCH 03/10] fix(desktop): polish accent-picker reveal per motion
review
Apply Animator's review of the Buzz-theme accent picker motion:
- Soften enter/exit scale 0.95 -> 0.98 (subtle settings reveal rather
than a modal spawning inside the form) + add will-change hint.
- Reduced motion no longer animates height/opacity via AnimatePresence;
it conditionally renders/unrenders instantly (no spatial collapse).
- Extract shared AccentPickerContent so animated and reduced-motion
paths share one markup source.
- Update comment to reflect the settings-reveal vocabulary.
9/9 buzz-theme-screenshots.spec.ts pass; tsc + biome clean.
Signed-off-by: npub13fn4ahfnvaa2qwylvegdgeajqs0mph6v4qsw4jcqnw4mjh3hzh2quuucm5 <8a675edd33677aa0389f6650d467b2041fb0df4ca820eacb009babb95e3715d4@sprout-oss.stage.blox.sqprod.co>
---
.../features/settings/ui/SettingsPanels.tsx | 137 ++++++++++--------
1 file changed, 78 insertions(+), 59 deletions(-)
diff --git a/desktop/src/features/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx
index ed65a6d586..1cdbebba50 100644
--- a/desktop/src/features/settings/ui/SettingsPanels.tsx
+++ b/desktop/src/features/settings/ui/SettingsPanels.tsx
@@ -590,69 +590,88 @@ function ThemeSettingsCard() {
{/* Accent color picker — hidden for Buzz themes (pinned neutral accent).
- Reveal/hide with the app's modal fade + zoom-95 motion. */}
-
- {accentPickerHidden ? null : (
-
-
+ );
+}
+
export function renderSettingsSection(
section: SettingsSection,
props: SettingsPanelProps,
From a0933e7d791dc9de6d387ab75539735845e2dfa3 Mon Sep 17 00:00:00 2001
From: npub13fn4ahfnvaa2qwylvegdgeajqs0mph6v4qsw4jcqnw4mjh3hzh2quuucm5
<8a675edd33677aa0389f6650d467b2041fb0df4ca820eacb009babb95e3715d4@sprout-oss.stage.blox.sqprod.co>
Date: Thu, 9 Jul 2026 17:26:32 +0100
Subject: [PATCH 04/10] fix(desktop): stop see-through flash when switching to
Buzz theme
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The remaining theme-switch flicker was a race between two effects: the
CSS translucency (transparent root/body) landed synchronously via
data-buzz-translucent, but the native macOS NSVisualEffectView vibrancy
layer that paints *behind* the transparent webview is installed by an
async Tauri IPC (set_window_vibrancy). For the IPC round-trip the webview
was transparent with nothing painted behind it, so the compositor showed
the prior/main-app content through — the 'nav underneath' flash Kenny saw.
Sequence transparency behind the vibrancy state:
- Split the translucent toggle out of applyBuzzSidebar into
setBuzzTranslucent(). applyBuzzSidebar now only flips the opaque
data-buzz-sidebar gradient (always safe, never see-through).
- applyBuzzVibrancy awaits set_window_vibrancy(enabled:true) and only
THEN enables translucency, so the native layer is always present before
the root goes transparent.
- Leaving Buzz drops translucency synchronously first (opaque never shows
through), then clears the native layer.
- On IPC failure or non-Tauri/web, translucency stays off (opaque
gradient fallback) instead of showing through to nothing.
- Drop the redundant inline root background styles; the transparent
root/body is already driven by the data-buzz-translucent CSS rule.
9/9 buzz-theme-screenshots.spec.ts pass; tsc + biome clean.
Signed-off-by: npub13fn4ahfnvaa2qwylvegdgeajqs0mph6v4qsw4jcqnw4mjh3hzh2quuucm5 <8a675edd33677aa0389f6650d467b2041fb0df4ca820eacb009babb95e3715d4@sprout-oss.stage.blox.sqprod.co>
---
desktop/src/shared/theme/ThemeProvider.tsx | 85 ++++++++++++++++------
1 file changed, 62 insertions(+), 23 deletions(-)
diff --git a/desktop/src/shared/theme/ThemeProvider.tsx b/desktop/src/shared/theme/ThemeProvider.tsx
index 3ba431502e..6dd85042c3 100644
--- a/desktop/src/shared/theme/ThemeProvider.tsx
+++ b/desktop/src/shared/theme/ThemeProvider.tsx
@@ -232,47 +232,86 @@ function resolveEffectiveAccent(
return isBuzzTheme(themeName) ? NEUTRAL_ACCENT : accentColor;
}
-/** Toggle the Buzz sidebar-gradient and translucency markers on the root. */
+/**
+ * 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");
}
}
+/**
+ * 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.
+ */
async function applyBuzzVibrancy(themeName: string) {
- if (!isTauri()) return;
+ const buzz = isBuzzTheme(themeName);
+
+ 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,
});
+ // Only now that the native layer is installed is it safe to go transparent.
+ if (buzz && isMacPlatform()) {
+ setBuzzTranslucent(true);
+ }
} catch (error) {
console.warn("set_window_vibrancy failed", error);
+ // Vibrancy failed — don't go transparent or we'd show through to nothing.
+ setBuzzTranslucent(false);
}
}
From 93876048d62b2b709412615c8f65c8797a4aa2df Mon Sep 17 00:00:00 2001
From: npub13fn4ahfnvaa2qwylvegdgeajqs0mph6v4qsw4jcqnw4mjh3hzh2quuucm5
<8a675edd33677aa0389f6650d467b2041fb0df4ca820eacb009babb95e3715d4@sprout-oss.stage.blox.sqprod.co>
Date: Thu, 9 Jul 2026 17:29:10 +0100
Subject: [PATCH 05/10] fix(desktop): accent picker slides/fades instead of
white-bar clip
Per Animator's motion spec, replace the accent-picker's height+scale
reveal with a translate-up + opacity fade, and stop the theme grid's
bottom fade from masking the swatches:
- Motion: y 10 -> 0 on reveal (rises into place), y -10 on hide
(continues up + fades), opacity 0<->1, duration 0.16s, shared easing.
AnimatePresence mode=popLayout + layout so siblings reflow cleanly
without a collapsing surface. Drop height/scale/overflow-hidden.
- White bar: the grid's bottom fade is a near-white gradient under Buzz
light; it sat right above the picker and masked the swatches as they
moved. Hide the bottom fade while the accent picker is visible
(accentPickerHidden === false); keep it when the picker is hidden.
- Reduced-motion branch unchanged (instant render/unrender).
9/9 buzz-theme-screenshots.spec.ts pass; tsc + biome clean.
Signed-off-by: npub13fn4ahfnvaa2qwylvegdgeajqs0mph6v4qsw4jcqnw4mjh3hzh2quuucm5 <8a675edd33677aa0389f6650d467b2041fb0df4ca820eacb009babb95e3715d4@sprout-oss.stage.blox.sqprod.co>
---
.../features/settings/ui/SettingsPanels.tsx | 44 +++++++++++--------
1 file changed, 26 insertions(+), 18 deletions(-)
diff --git a/desktop/src/features/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx
index 1cdbebba50..f50f29865e 100644
--- a/desktop/src/features/settings/ui/SettingsPanels.tsx
+++ b/desktop/src/features/settings/ui/SettingsPanels.tsx
@@ -380,11 +380,14 @@ function SingleThemeTile({
type AppearanceMode = "system" | "light" | "dark";
-// Reveal/hide motion for the accent picker — mirrors the app's modal motion
-// (fade + zoom-95) and the ProfileSettingsCard reveal easing so it reads as one
-// consistent motion vocabulary.
+// Reveal/hide motion for the accent picker: a small translate-up + opacity
+// fade (rises into place from below on reveal, continues upward and fades on
+// hide). No height/scale — height collapse clipped the swatches behind the
+// grid's bottom fade (the "white bar"). Slightly 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.2,
+ duration: 0.16,
ease: [0.23, 1, 0.32, 1] as const,
};
@@ -539,15 +542,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}
{selectedMode === "system" &&
@@ -602,14 +609,15 @@ function ThemeSettingsCard() {
/>
)
) : (
-
+
{accentPickerHidden ? null : (
Date: Thu, 9 Jul 2026 17:32:16 +0100
Subject: [PATCH 06/10] fix(desktop): accent picker slides purely vertical,
tucks up behind grid
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two refinements from Kenny on the accent-picker reveal:
- Direction: the picker sits below the theme grid and should read as
tucking behind it. Enter from above (y: -10 -> 0, slides down into
place when a non-Buzz theme reveals it) and exit upward (y: -10, slides
up behind the grid when Buzz hides it).
- Kill horizontal drift: drop AnimatePresence mode=popLayout + the layout
prop. popLayout pops the exiting node out of flow and layout animates
positional deltas, which introduced a slight left/right shift. Without
them the element only animates the y/opacity we specify — pure vertical
motion, x pinned at 0. Height is no longer animated (picker is the last
element in the section) so the layout machinery isn't needed.
9/9 buzz-theme-screenshots.spec.ts pass; tsc + biome clean.
Signed-off-by: npub13fn4ahfnvaa2qwylvegdgeajqs0mph6v4qsw4jcqnw4mjh3hzh2quuucm5 <8a675edd33677aa0389f6650d467b2041fb0df4ca820eacb009babb95e3715d4@sprout-oss.stage.blox.sqprod.co>
---
.../features/settings/ui/SettingsPanels.tsx | 18 +++++++++---------
1 file changed, 9 insertions(+), 9 deletions(-)
diff --git a/desktop/src/features/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx
index f50f29865e..1cc04dee6d 100644
--- a/desktop/src/features/settings/ui/SettingsPanels.tsx
+++ b/desktop/src/features/settings/ui/SettingsPanels.tsx
@@ -380,12 +380,13 @@ function SingleThemeTile({
type AppearanceMode = "system" | "light" | "dark";
-// Reveal/hide motion for the accent picker: a small translate-up + opacity
-// fade (rises into place from below on reveal, continues upward and fades on
-// hide). No height/scale — height collapse clipped the swatches behind the
-// grid's bottom fade (the "white bar"). Slightly snappier than the modal 0.2s
-// since this is a small settings control, sharing the modal/ProfileSettingsCard
-// easing curve.
+// 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,
@@ -609,15 +610,14 @@ function ThemeSettingsCard() {
/>
)
) : (
-
+
{accentPickerHidden ? null : (
Date: Thu, 9 Jul 2026 17:44:09 +0100
Subject: [PATCH 07/10] docs(desktop): fix stale accent-picker reveal comment
Signed-off-by: npub13fn4ahfnvaa2qwylvegdgeajqs0mph6v4qsw4jcqnw4mjh3hzh2quuucm5 <8a675edd33677aa0389f6650d467b2041fb0df4ca820eacb009babb95e3715d4@sprout-oss.stage.blox.sqprod.co>
---
desktop/src/features/settings/ui/SettingsPanels.tsx | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/desktop/src/features/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx
index 1cc04dee6d..07092372cc 100644
--- a/desktop/src/features/settings/ui/SettingsPanels.tsx
+++ b/desktop/src/features/settings/ui/SettingsPanels.tsx
@@ -598,9 +598,9 @@ function ThemeSettingsCard() {
{/* Accent color picker — hidden for Buzz themes (pinned neutral accent).
- Reveal/hide with a subtle settings fade/scale reveal (adjacent to, not
- identical to, the modal zoom-95 vocabulary). Reduced motion skips the
- spatial collapse/expand and just renders/unrenders. */}
+ Reveal/hide with the translate-up + opacity fade defined by
+ ACCENT_PICKER_TRANSITION above. Reduced motion skips the transition
+ and just renders/unrenders. */}
{shouldReduceMotion ? (
accentPickerHidden ? null : (
Date: Thu, 9 Jul 2026 17:56:51 +0100
Subject: [PATCH 08/10] fix(desktop): guard stale Buzz vibrancy completion
against theme race
Signed-off-by: npub13fn4ahfnvaa2qwylvegdgeajqs0mph6v4qsw4jcqnw4mjh3hzh2quuucm5 <8a675edd33677aa0389f6650d467b2041fb0df4ca820eacb009babb95e3715d4@sprout-oss.stage.blox.sqprod.co>
---
desktop/src/shared/theme/ThemeProvider.tsx | 20 ++++++++++++++++++++
1 file changed, 20 insertions(+)
diff --git a/desktop/src/shared/theme/ThemeProvider.tsx b/desktop/src/shared/theme/ThemeProvider.tsx
index 6dd85042c3..15a449baf1 100644
--- a/desktop/src/shared/theme/ThemeProvider.tsx
+++ b/desktop/src/shared/theme/ThemeProvider.tsx
@@ -274,6 +274,18 @@ function setBuzzTranslucent(enabled: boolean) {
}
}
+/**
+ * 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;
+
/**
* 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
@@ -288,9 +300,13 @@ function setBuzzTranslucent(enabled: boolean) {
*
* 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) {
const buzz = isBuzzTheme(themeName);
+ const requestToken = ++buzzVibrancyRequest;
if (!isTauri()) {
// Web/dev preview: no native vibrancy layer exists, so translucency would
@@ -304,12 +320,16 @@ async function applyBuzzVibrancy(themeName: string) {
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;
// Only now that the native layer is installed is it safe to go transparent.
if (buzz && isMacPlatform()) {
setBuzzTranslucent(true);
}
} 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);
}
From 7d928b83522b751936d379ed7af23da059ec2265 Mon Sep 17 00:00:00 2001
From: npub13fn4ahfnvaa2qwylvegdgeajqs0mph6v4qsw4jcqnw4mjh3hzh2quuucm5
<8a675edd33677aa0389f6650d467b2041fb0df4ca820eacb009babb95e3715d4@sprout-oss.stage.blox.sqprod.co>
Date: Fri, 10 Jul 2026 16:25:29 +0100
Subject: [PATCH 09/10] fix(theme): gate Buzz translucency on sidebar marker
being applied
The two theme effects (applyTheme installing the Buzz gradient vars via
applyBuzzSidebar, and applyBuzzVibrancy installing the native vibrancy
layer) are independent async effects that can win their race in either
order. If the vibrancy IPC resolved first when switching to Buzz,
data-buzz-translucent cleared the body/sidebar surfaces before the Buzz
gradient vars were installed, flashing a transparent/unstyled sidebar.
Translucency is now enabled only once BOTH the native layer is ready and
data-buzz-sidebar is present. Both effects call maybeEnableBuzzTranslucent
after their own step so whichever lands last flips it on; the existing
monotonic token guards against a superseding theme switch.
Signed-off-by: npub13fn4ahfnvaa2qwylvegdgeajqs0mph6v4qsw4jcqnw4mjh3hzh2quuucm5 <8a675edd33677aa0389f6650d467b2041fb0df4ca820eacb009babb95e3715d4@sprout-oss.stage.blox.sqprod.co>
---
desktop/src/shared/theme/ThemeProvider.tsx | 48 +++++++++++++++++++++-
1 file changed, 46 insertions(+), 2 deletions(-)
diff --git a/desktop/src/shared/theme/ThemeProvider.tsx b/desktop/src/shared/theme/ThemeProvider.tsx
index 15a449baf1..de33e4e276 100644
--- a/desktop/src/shared/theme/ThemeProvider.tsx
+++ b/desktop/src/shared/theme/ThemeProvider.tsx
@@ -286,6 +286,37 @@ function setBuzzTranslucent(enabled: boolean) {
*/
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
@@ -307,6 +338,9 @@ let buzzVibrancyRequest = 0;
async function applyBuzzVibrancy(themeName: string) {
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
@@ -323,9 +357,13 @@ async function applyBuzzVibrancy(themeName: string) {
// 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;
- // Only now that the native layer is installed is it safe to go transparent.
+ // 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()) {
- setBuzzTranslucent(true);
+ buzzVibrancyReady = true;
+ maybeEnableBuzzTranslucent(themeName, requestToken);
}
} catch (error) {
console.warn("set_window_vibrancy failed", error);
@@ -377,6 +415,12 @@ 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
From eac44a2c034c1c98fad3e3934698da35c3eefb69 Mon Sep 17 00:00:00 2001
From: npub13fn4ahfnvaa2qwylvegdgeajqs0mph6v4qsw4jcqnw4mjh3hzh2quuucm5
<8a675edd33677aa0389f6650d467b2041fb0df4ca820eacb009babb95e3715d4@sprout-oss.stage.blox.sqprod.co>
Date: Fri, 10 Jul 2026 16:34:56 +0100
Subject: [PATCH 10/10] fix(theme): resolve cached Buzz accent to neutral on
boot
applyCachedVars() runs before first paint and applied the stored accent
raw, so a cached Buzz theme paired with a non-neutral stored accent
flashed the old accent on reload until the async applyTheme effect ran
and re-pinned to neutral. Route the cache-path accent through
resolveEffectiveAccent(themeName, accent), matching applyTheme, so Buzz
is pinned neutral synchronously before first paint.
Signed-off-by: npub13fn4ahfnvaa2qwylvegdgeajqs0mph6v4qsw4jcqnw4mjh3hzh2quuucm5 <8a675edd33677aa0389f6650d467b2041fb0df4ca820eacb009babb95e3715d4@sprout-oss.stage.blox.sqprod.co>
---
desktop/src/shared/theme/ThemeProvider.tsx | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/desktop/src/shared/theme/ThemeProvider.tsx b/desktop/src/shared/theme/ThemeProvider.tsx
index de33e4e276..bb22f63271 100644
--- a/desktop/src/shared/theme/ThemeProvider.tsx
+++ b/desktop/src/shared/theme/ThemeProvider.tsx
@@ -389,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 {