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 */} -
-

Accent color

-
- {ACCENT_COLORS.map((color) => { - const isNeutral = color.value === NEUTRAL_ACCENT; - const swatchColor = isNeutral - ? "hsl(var(--foreground))" - : color.value; - const checkClassName = - isNeutral && isDark ? "text-black" : "text-white"; - - return ( - - ); - })} + {/* Accent color picker — hidden for Buzz themes (pinned neutral accent) */} + {accentPickerHidden ? null : ( +
+

Accent color

+
+ {ACCENT_COLORS.map((color) => { + const isNeutral = color.value === NEUTRAL_ACCENT; + const swatchColor = isNeutral + ? "hsl(var(--foreground))" + : color.value; + const checkClassName = + isNeutral && isDark ? "text-black" : "text-white"; + + return ( + + ); + })} +
-
+ )} ); } 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..85302a781c 100644 --- a/desktop/src/shared/theme/ThemeProvider.tsx +++ b/desktop/src/shared/theme/ThemeProvider.tsx @@ -210,10 +210,28 @@ 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"; } +/** + * 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 sidebar-gradient and translucency markers on the root. */ function applyBuzzSidebar(themeName: string) { const root = document.documentElement; @@ -362,9 +380,13 @@ export function ThemeProvider({ if (loadingRef.current === thisTheme) { setIsDark(dark); setIsLoading(false); - // Re-apply accent after theme load (theme vars don't include primary) + // Re-apply accent after theme load (theme vars don't include primary). + // Buzz themes force the neutral accent regardless of stored value. applyAccentColor( - window.localStorage.getItem(ACCENT_STORAGE_KEY) ?? DEFAULT_ACCENT, + resolveEffectiveAccent( + thisTheme, + window.localStorage.getItem(ACCENT_STORAGE_KEY) ?? DEFAULT_ACCENT, + ), ); } }); @@ -390,8 +412,8 @@ export function ThemeProvider({ }, [followSystem]); 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..b7f33997f8 100644 --- a/desktop/tests/e2e/buzz-theme-screenshots.spec.ts +++ b/desktop/tests/e2e/buzz-theme-screenshots.spec.ts @@ -81,3 +81,43 @@ 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` }); +}); From a0c24c864f93037954eccd2f81e35d3061a4bf79 Mon Sep 17 00:00:00 2001 From: npub13fn4ahfnvaa2qwylvegdgeajqs0mph6v4qsw4jcqnw4mjh3hzh2quuucm5 <8a675edd33677aa0389f6650d467b2041fb0df4ca820eacb009babb95e3715d4@sprout-oss.stage.blox.sqprod.co> Date: Thu, 9 Jul 2026 17:10:21 +0100 Subject: [PATCH 02/10] fix(desktop): kill Buzz theme-switch flicker + animate accent picker reveal Flicker: applyTheme set the theme CSS vars synchronously but the accent (--primary / --sidebar-active) was re-applied a microtask later in the caller's .then, so the previous accent flashed on the new theme for a frame when switching to Buzz. Apply the resolved accent inside applyTheme, in the same synchronous batch as the theme vars, so theme + accent paint together. Accent picker: wrap the Appearance accent section in AnimatePresence + a motion.div that fades and zooms (scale 0.95) on enter/exit, matching the app's modal motion (MODAL_CONTENT_MOTION_CLASS) and the ProfileSettingsCard reveal easing. Honors prefers-reduced-motion. So toggling Buzz on/off now reveals and hides the accent choices with the app's standard motion instead of popping. Extend the Playwright spec with a reveal/hide toggle test (github-light -> buzz -> github-light). 9/9 pass. Signed-off-by: npub13fn4ahfnvaa2qwylvegdgeajqs0mph6v4qsw4jcqnw4mjh3hzh2quuucm5 <8a675edd33677aa0389f6650d467b2041fb0df4ca820eacb009babb95e3715d4@sprout-oss.stage.blox.sqprod.co> --- .../features/settings/ui/SettingsPanels.tsx | 106 ++++++++++++------ desktop/src/shared/theme/ThemeProvider.tsx | 28 +++-- .../tests/e2e/buzz-theme-screenshots.spec.ts | 19 ++++ 3 files changed, 108 insertions(+), 45 deletions(-) diff --git a/desktop/src/features/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx index fe5405b199..ed65a6d586 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, @@ -379,6 +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. +const ACCENT_PICKER_TRANSITION = { + duration: 0.2, + ease: [0.23, 1, 0.32, 1] as const, +}; + function ThemeSettingsCard() { const { setTheme, @@ -395,6 +404,7 @@ function ThemeSettingsCard() { // 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(); @@ -579,42 +589,66 @@ function ThemeSettingsCard() {
- {/* Accent color picker — hidden for Buzz themes (pinned neutral accent) */} - {accentPickerHidden ? null : ( -
-

Accent color

-
- {ACCENT_COLORS.map((color) => { - const isNeutral = color.value === NEUTRAL_ACCENT; - const swatchColor = isNeutral - ? "hsl(var(--foreground))" - : color.value; - const checkClassName = - isNeutral && isDark ? "text-black" : "text-white"; - - return ( - - ); - })} -
-
- )} + {/* Accent color picker — hidden for Buzz themes (pinned neutral accent). + Reveal/hide with the app's modal fade + zoom-95 motion. */} + + {accentPickerHidden ? null : ( + +
+

Accent color

+
+ {ACCENT_COLORS.map((color) => { + const isNeutral = color.value === NEUTRAL_ACCENT; + const swatchColor = isNeutral + ? "hsl(var(--foreground))" + : color.value; + const checkClassName = + isNeutral && isDark ? "text-black" : "text-white"; + + return ( + + ); + })} +
+
+
+ )} +
); } 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 : ( - -
-

Accent color

-
- {ACCENT_COLORS.map((color) => { - const isNeutral = color.value === NEUTRAL_ACCENT; - const swatchColor = isNeutral - ? "hsl(var(--foreground))" - : color.value; - const checkClassName = - isNeutral && isDark ? "text-black" : "text-white"; - - return ( - - ); - })} -
-
-
- )} -
+ 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. */} + {shouldReduceMotion ? ( + accentPickerHidden ? null : ( + + ) + ) : ( + + {accentPickerHidden ? null : ( + + + + )} + + )} ); } +/** Accent swatch grid — shared by the animated and reduced-motion reveal paths. */ +function AccentPickerContent({ + accentColor, + isDark, + setAccentColor, +}: { + accentColor: string; + isDark: boolean; + setAccentColor: (value: string) => void; +}) { + return ( +
+

Accent color

+
+ {ACCENT_COLORS.map((color) => { + const isNeutral = color.value === NEUTRAL_ACCENT; + const swatchColor = isNeutral + ? "hsl(var(--foreground))" + : color.value; + const checkClassName = + isNeutral && isDark ? "text-black" : "text-white"; + + return ( + + ); + })} +
+
+ ); +} + 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 */} -