+
-
-
-
-
-
- Inbox
-
- {homeBadgeCount > 0 ? (
-
- {Math.min(homeBadgeCount, 99)}
-
- ) : null}
-
-
-
-
-
- Pulse
-
-
-
-
-
-
-
- Projects
-
-
-
-
-
-
- Agents
-
-
-
-
-
-
- Workflows
-
-
-
-
-
);
}
+
+export function AppSidebarPrimaryMenu({
+ homeBadgeCount,
+ onSelectAgents,
+ onSelectHome,
+ onSelectProjects,
+ onSelectPulse,
+ onSelectWorkflows,
+ selectedView,
+}: AppSidebarPrimaryMenuProps) {
+ return (
+
+
+
+
+
+ Inbox
+
+ {homeBadgeCount > 0 ? (
+
+ {Math.min(homeBadgeCount, 99)}
+
+ ) : null}
+
+
+
+
+
+ Pulse
+
+
+
+
+
+
+
+ Projects
+
+
+
+
+
+
+ Agents
+
+
+
+
+
+
+ Workflows
+
+
+
+
+
+ );
+}
diff --git a/desktop/src/features/sidebar/ui/CustomChannelSection.tsx b/desktop/src/features/sidebar/ui/CustomChannelSection.tsx
index be7eba8cc0..2b266e887e 100644
--- a/desktop/src/features/sidebar/ui/CustomChannelSection.tsx
+++ b/desktop/src/features/sidebar/ui/CustomChannelSection.tsx
@@ -293,12 +293,14 @@ function ChannelSectionHeader({
isCollapsed,
onToggleCollapsed,
title,
+ testId,
actions,
}: {
contentId: string;
isCollapsed: boolean;
onToggleCollapsed: () => void;
title: string;
+ testId: string;
actions: React.ReactNode;
}) {
return (
@@ -308,10 +310,11 @@ function ChannelSectionHeader({
aria-controls={contentId}
aria-expanded={!isCollapsed}
className={SECTION_LABEL_BUTTON_CLASS}
+ data-testid={`${testId}-section-label`}
onClick={onToggleCollapsed}
type="button"
>
-
{title}
+
{title}
{showQuickCreate && (onQuickCreateClick ?? onCreateClick) ? (
@@ -626,11 +630,17 @@ export function CustomChannelSection({
-
+
@@ -648,6 +658,7 @@ export function CustomChannelSection({
) : null}
{section.name}
diff --git a/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx b/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx
index 467b2fae7f..a714d5e58a 100644
--- a/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx
+++ b/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx
@@ -78,7 +78,10 @@ export function SidebarProfileCard({
const hasStatus = Boolean(selfUserStatus?.text || selfUserStatus?.emoji);
const communityLabel = activeCommunity?.name ?? "No community";
const readonlyCommunityLabel = (
-
+
{
event.stopPropagation();
@@ -211,6 +215,7 @@ export function SidebarProfileCard({
"pointer-events-none absolute inset-0 flex min-w-0 items-center text-xs leading-snug text-sidebar-foreground/70 opacity-0 transition-opacity duration-150 group-hover/profile-card:opacity-100",
profilePopoverOpen && "opacity-0",
)}
+ data-buzz-sidebar-secondary
>
{readonlyCommunityLabel}
diff --git a/desktop/src/features/sidebar/ui/SidebarSection.tsx b/desktop/src/features/sidebar/ui/SidebarSection.tsx
index 84b4a7a27b..3e39c60a6d 100644
--- a/desktop/src/features/sidebar/ui/SidebarSection.tsx
+++ b/desktop/src/features/sidebar/ui/SidebarSection.tsx
@@ -294,7 +294,9 @@ export function ChannelMenuButton({
dmParticipants={dmParticipants}
presenceStatus={presenceStatus}
/>
- {resolvedLabel}
+
+ {resolvedLabel}
+
{ephemeralDisplay ? (
- {title}
+ {title}
[data-sidebar="menu-button"]:not([data-active="true"]) {
+ background-color: var(--buzz-hover-surface);
+}
+
+/*
+ * Search box: use a neutral 7% tint instead of the grey
+ * `bg-sidebar-border/35` fill. Its label, shortcut, and magnifying glass share
+ * the 40% muted foreground at rest and on hover/focus.
*/
:root[data-buzz-sidebar]
[data-testid="app-sidebar"]
@@ -379,7 +438,45 @@
:root[data-buzz-sidebar]
[data-testid="app-sidebar"]
[data-testid="open-search"]:focus-visible {
- background-color: var(--buzz-hover-surface);
+ background-color: var(--buzz-search-surface);
+ color: var(--buzz-muted-foreground);
+}
+
+:root[data-buzz-sidebar]
+ [data-testid="app-sidebar"]
+ [data-testid="open-search"]
+ > * {
+ color: var(--buzz-muted-foreground);
+}
+
+/*
+ * App and settings sidebar section headings plus secondary footer text use the
+ * same tint. Navigation rows keep their normal foreground in both sidebars.
+ */
+:root[data-buzz-sidebar]
+ [data-testid="app-sidebar"]
+ [data-sidebar="group-label"],
+:root[data-buzz-sidebar]
+ [data-testid="settings-sidebar"]
+ [data-sidebar="group-label"],
+:root[data-buzz-sidebar]
+ [data-testid="app-sidebar"]
+ [data-buzz-sidebar-secondary],
+:root[data-buzz-sidebar]
+ [data-testid="settings-sidebar"]
+ [data-buzz-sidebar-secondary] {
+ color: var(--buzz-muted-foreground);
+}
+
+/* Sidebar toggle plus back/forward navigation controls in the top chrome. */
+:root[data-buzz-sidebar]
+ [data-testid="app-top-chrome"]
+ :is(
+ [data-sidebar="trigger"],
+ [data-testid="global-back"],
+ [data-testid="global-forward"]
+ ) {
+ color: var(--buzz-chrome-foreground);
}
/*
@@ -443,13 +540,13 @@
}
/*
- * Inbox + Agents nav rows: the pinned-header nav buttons (Inbox, Pulse,
+ * Inbox + Agents nav rows: the primary app nav buttons (Inbox, Pulse,
* Projects, Agents, Workflows). Excludes the active row so the active-pill
* foreground still wins.
*/
:root[data-buzz-sidebar]
[data-testid="app-sidebar"]
- [data-testid="sidebar-pinned-header"]
+ [data-testid="sidebar-primary-menu"]
[data-sidebar="menu-button"]:not([data-active="true"]) {
color: var(--buzz-nav-fg);
}
@@ -462,33 +559,25 @@
* surfaces pass through so the nav reads as one continuous pane.
*/
:root[data-buzz-translucent] {
- /* Neutral frosted wash; alpha applied via color-mix below. */
- --buzz-glass-tint: #ffffff;
- --buzz-glass-intensity: 1;
--buzz-translucency-gradient-alpha: 70%;
--buzz-translucency-wash-alpha: 2.4%;
}
-:root[data-buzz-translucent].dark {
- --buzz-glass-tint: #12161d;
-}
-:root[data-buzz-translucent] .group\/sidebar-wrapper,
-:root[data-buzz-translucent]
- [data-sidebar="sidebar"][data-mobile="true"].bg-sidebar,
-:root[data-buzz-translucent] [data-testid="community-rail"].bg-sidebar {
- background-color: transparent;
+:root[data-buzz-translucent] .buzz-theme-gradient-layer-light {
/* Buzz color wash above a neutral frost wash. */
background-image:
linear-gradient(
to bottom,
color-mix(
in srgb,
- var(--buzz-gradient-top) var(--buzz-translucency-gradient-alpha, 70%),
+ var(--buzz-gradient-light-top)
+ var(--buzz-translucency-gradient-alpha, 70%),
transparent
),
color-mix(
in srgb,
- var(--buzz-gradient-bottom) var(--buzz-translucency-gradient-alpha, 70%),
+ var(--buzz-gradient-light-bottom)
+ var(--buzz-translucency-gradient-alpha, 70%),
transparent
)
),
@@ -496,16 +585,127 @@
to bottom,
color-mix(
in srgb,
- var(--buzz-glass-tint) var(--buzz-translucency-wash-alpha, 2.4%),
+ #ffffff var(--buzz-translucency-wash-alpha, 2.4%),
transparent
),
color-mix(
in srgb,
- var(--buzz-glass-tint) var(--buzz-translucency-wash-alpha, 2.4%),
+ #ffffff var(--buzz-translucency-wash-alpha, 2.4%),
+ transparent
+ )
+ );
+ background-size:
+ 100vw 100vh,
+ 100vw 100vh;
+ background-repeat: no-repeat, no-repeat;
+}
+
+:root[data-buzz-translucent] .buzz-theme-gradient-layer-dark {
+ background-image:
+ linear-gradient(
+ to bottom,
+ color-mix(
+ in srgb,
+ var(--buzz-gradient-dark-top)
+ var(--buzz-translucency-gradient-alpha, 70%),
+ transparent
+ ),
+ color-mix(
+ in srgb,
+ var(--buzz-gradient-dark-bottom)
+ var(--buzz-translucency-gradient-alpha, 70%),
+ transparent
+ )
+ ),
+ linear-gradient(
+ to bottom,
+ color-mix(
+ in srgb,
+ #12161d var(--buzz-translucency-wash-alpha, 2.4%),
+ transparent
+ ),
+ color-mix(
+ in srgb,
+ #12161d var(--buzz-translucency-wash-alpha, 2.4%),
+ transparent
+ )
+ );
+ background-size:
+ 100vw 100vh,
+ 100vw 100vh;
+ background-repeat: no-repeat, no-repeat;
+}
+
+:root[data-buzz-translucent]:not(.dark)
+ [data-sidebar="sidebar"][data-mobile="true"].bg-sidebar {
+ background-color: transparent;
+ background-image:
+ linear-gradient(
+ to bottom,
+ color-mix(
+ in srgb,
+ var(--buzz-gradient-light-top)
+ var(--buzz-translucency-gradient-alpha, 70%),
+ transparent
+ ),
+ color-mix(
+ in srgb,
+ var(--buzz-gradient-light-bottom)
+ var(--buzz-translucency-gradient-alpha, 70%),
+ transparent
+ )
+ ),
+ linear-gradient(
+ to bottom,
+ color-mix(
+ in srgb,
+ #ffffff var(--buzz-translucency-wash-alpha, 2.4%),
+ transparent
+ ),
+ color-mix(
+ in srgb,
+ #ffffff var(--buzz-translucency-wash-alpha, 2.4%),
+ transparent
+ )
+ );
+ background-size:
+ 100vw 100vh,
+ 100vw 100vh;
+ background-repeat: no-repeat, no-repeat;
+}
+
+:root[data-buzz-translucent].dark
+ [data-sidebar="sidebar"][data-mobile="true"].bg-sidebar {
+ background-color: transparent;
+ background-image:
+ linear-gradient(
+ to bottom,
+ color-mix(
+ in srgb,
+ var(--buzz-gradient-dark-top)
+ var(--buzz-translucency-gradient-alpha, 70%),
+ transparent
+ ),
+ color-mix(
+ in srgb,
+ var(--buzz-gradient-dark-bottom)
+ var(--buzz-translucency-gradient-alpha, 70%),
+ transparent
+ )
+ ),
+ linear-gradient(
+ to bottom,
+ color-mix(
+ in srgb,
+ #12161d var(--buzz-translucency-wash-alpha, 2.4%),
+ transparent
+ ),
+ color-mix(
+ in srgb,
+ #12161d var(--buzz-translucency-wash-alpha, 2.4%),
transparent
)
);
- background-attachment: fixed, fixed;
background-size:
100vw 100vh,
100vw 100vh;
@@ -559,9 +759,8 @@
background-image: none;
}
-/* Shell wrappers stay clear; the main content card remains opaque. */
-:root[data-buzz-translucent] .buzz-huddle-shell,
-:root[data-buzz-translucent] .buzz-huddle-app-surface {
+/* The outer shell stays clear; the app surface owns the gradient above. */
+:root[data-buzz-translucent] .buzz-huddle-shell {
background: transparent;
}
diff --git a/desktop/src/shared/theme/ThemePreviewFrame.tsx b/desktop/src/shared/theme/ThemePreviewFrame.tsx
index 8003b52fe7..5b4f8b1790 100644
--- a/desktop/src/shared/theme/ThemePreviewFrame.tsx
+++ b/desktop/src/shared/theme/ThemePreviewFrame.tsx
@@ -4,16 +4,22 @@ import { cn } from "@/shared/lib/cn";
export type ThemePreviewVars = Record;
/**
- * Buzz sidebar-gradient stop colors, keyed by theme name. Single source of
- * truth for the picker swatch — must stay in sync with the `--buzz-gradient-*`
- * values in `shared/styles/globals/theme.css`.
+ * Buzz sidebar-gradient stop tokens, keyed by theme name. The actual custom
+ * colors live once in `shared/styles/globals/theme.css`; the picker consumes
+ * those same semantic variables instead of duplicating raw values here.
*/
export const BUZZ_GRADIENT_STOPS: Record<
string,
{ top: string; bottom: string }
> = {
- buzz: { top: "#e6e6b6", bottom: "#c4d0da" },
- "buzz-dark": { top: "#2b2b18", bottom: "#1b2530" },
+ buzz: {
+ top: "var(--buzz-gradient-light-top)",
+ bottom: "var(--buzz-gradient-light-bottom)",
+ },
+ "buzz-dark": {
+ top: "var(--buzz-gradient-dark-top)",
+ bottom: "var(--buzz-gradient-dark-bottom)",
+ },
};
export const LIGHT_PREVIEW_VARS: ThemePreviewVars = {
diff --git a/desktop/src/shared/theme/ThemeProvider.tsx b/desktop/src/shared/theme/ThemeProvider.tsx
index 6b41f4f057..d656815091 100644
--- a/desktop/src/shared/theme/ThemeProvider.tsx
+++ b/desktop/src/shared/theme/ThemeProvider.tsx
@@ -8,6 +8,7 @@ import {
useState,
} from "react";
import { isTauri } from "@tauri-apps/api/core";
+import { getCurrentWindow } from "@tauri-apps/api/window";
import { invokeTauri } from "@/shared/api/tauri";
import { isMacPlatform } from "@/shared/lib/platform";
import { createThemeVars, hexToHsl } from "./adaptive-theme";
@@ -244,8 +245,14 @@ function applyBuzzSidebar(themeName: string) {
const root = document.documentElement;
if (isBuzzTheme(themeName)) {
root.setAttribute("data-buzz-sidebar", "");
+ // Keep the concrete Buzz variant on the root as well as the generic
+ // marker. The gradient stylesheet matches this attribute directly, which
+ // makes WKWebView invalidate the painted background when light/dark mode
+ // changes instead of relying only on a custom-property dependency update.
+ root.setAttribute("data-buzz-theme", themeName);
} else {
root.removeAttribute("data-buzz-sidebar");
+ root.removeAttribute("data-buzz-theme");
// 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.
@@ -293,6 +300,9 @@ let buzzVibrancyRequest = 0;
*/
let buzzVibrancyReady = false;
+/** The native layer does not need rebuilding when Buzz only changes mode. */
+let buzzVibrancyEnabled = false;
+
/**
* Enable the CSS translucency treatment, but only once BOTH prerequisites for
* the current request are in place:
@@ -338,6 +348,16 @@ function maybeEnableBuzzTranslucent(themeName: string, requestToken: number) {
async function applyBuzzVibrancy(themeName: string) {
const buzz = isBuzzTheme(themeName);
const requestToken = ++buzzVibrancyRequest;
+
+ // Buzz Light and Buzz Dark use the same native material. Rebuilding the
+ // NSVisualEffectView on every mode change briefly clears the layer behind
+ // the webview and makes the new CSS theme appear late. Keep the installed
+ // layer and let applyTheme swap only the color tokens.
+ if (buzz && buzzVibrancyEnabled && buzzVibrancyReady) {
+ maybeEnableBuzzTranslucent(themeName, requestToken);
+ return;
+ }
+
// 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;
@@ -357,6 +377,7 @@ 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;
+ buzzVibrancyEnabled = buzz;
// 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
@@ -369,6 +390,7 @@ async function applyBuzzVibrancy(themeName: string) {
console.warn("set_window_vibrancy failed", error);
if (requestToken !== buzzVibrancyRequest) return;
// Vibrancy failed — don't go transparent or we'd show through to nothing.
+ buzzVibrancyEnabled = false;
setBuzzTranslucent(false);
}
}
@@ -400,9 +422,17 @@ function applyCachedVars(): string | null {
}
}
+/** The latest theme load is the only one allowed to write document styles. */
+let themeApplyRequest = 0;
+
/** Apply a theme: load data, derive CSS vars, set them on :root. */
-async function applyTheme(name: SyntaxThemeName): Promise<{ isDark: boolean }> {
+async function applyTheme(
+ name: SyntaxThemeName,
+): Promise<{ isDark: boolean } | null> {
+ const requestToken = ++themeApplyRequest;
const themeData = await loadThemeData(name);
+ if (requestToken !== themeApplyRequest) return null;
+
const info = extractThemeInfo(name, themeData);
const { isDark, vars } = createThemeVars(info.bg, info.fg, info.comment, {
added: info.added,
@@ -498,12 +528,13 @@ export function ThemeProvider({
loadingRef.current = thisTheme;
setIsLoading(true);
- applyTheme(effectiveTheme as SyntaxThemeName).then(({ isDark: dark }) => {
+ applyTheme(effectiveTheme as SyntaxThemeName).then((result) => {
+ if (!result) return;
// Only update if this is still the theme we want. The accent is applied
// inside applyTheme (synchronously with the theme vars), so there's no
// separate re-application here — that avoided the switch-time flicker.
if (loadingRef.current === thisTheme) {
- setIsDark(dark);
+ setIsDark(result.isDark);
setIsLoading(false);
}
});
@@ -519,13 +550,41 @@ export function ThemeProvider({
if (!followSystem) return;
const mq = window.matchMedia("(prefers-color-scheme: dark)");
- const handler = (event: MediaQueryListEvent) => {
+ const handleMediaChange = (event: MediaQueryListEvent) => {
setSystemIsDark(event.matches);
};
+ let disposed = false;
+ let unlistenNativeTheme: (() => void) | undefined;
setSystemIsDark(mq.matches);
- mq.addEventListener("change", handler);
- return () => mq.removeEventListener("change", handler);
+ mq.addEventListener("change", handleMediaChange);
+
+ // WKWebView can update the media query value without dispatching its
+ // change event until the page reloads. Tauri's native window event arrives
+ // immediately when macOS appearance changes, so use it as the reliable app
+ // signal while retaining matchMedia for the browser build.
+ if (isTauri()) {
+ void getCurrentWindow()
+ .onThemeChanged(({ payload }) => {
+ if (!disposed) setSystemIsDark(payload === "dark");
+ })
+ .then((unlisten) => {
+ if (disposed) {
+ unlisten();
+ } else {
+ unlistenNativeTheme = unlisten;
+ }
+ })
+ .catch((error) => {
+ console.warn("system theme listener unavailable", error);
+ });
+ }
+
+ return () => {
+ disposed = true;
+ mq.removeEventListener("change", handleMediaChange);
+ unlistenNativeTheme?.();
+ };
}, [followSystem]);
// Re-apply the accent when the user picks a new swatch or the effective theme
diff --git a/desktop/src/shared/ui/sidebar-menu-label.tsx b/desktop/src/shared/ui/sidebar-menu-label.tsx
new file mode 100644
index 0000000000..5b6e4b5128
--- /dev/null
+++ b/desktop/src/shared/ui/sidebar-menu-label.tsx
@@ -0,0 +1,31 @@
+import * as React from "react";
+
+import { cn } from "@/shared/lib/cn";
+
+/**
+ * Keeps a menu label's inline footprint stable when its parent changes font
+ * weight for the active state. The invisible semibold copy reserves the
+ * widest state while the visible copy remains the accessible label.
+ */
+const SidebarMenuLabel = React.forwardRef<
+ HTMLSpanElement,
+ React.ComponentProps<"span">
+>(({ children, className, ...props }, ref) => (
+
+
+ {children}
+
+ {children}
+
+));
+SidebarMenuLabel.displayName = "SidebarMenuLabel";
+
+export { SidebarMenuLabel };
diff --git a/desktop/tailwind.config.js b/desktop/tailwind.config.js
index 2d12c42112..fc42aed160 100644
--- a/desktop/tailwind.config.js
+++ b/desktop/tailwind.config.js
@@ -15,6 +15,9 @@ export default {
// 36px — the backup-step private key, shown large in monospace
"nsec-key": ["2.25rem", { lineHeight: "1.3" }],
},
+ boxShadow: {
+ "content-edge": "-1px -1px 0 0 hsl(var(--sidebar-border) / 0.45)",
+ },
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
diff --git a/desktop/tests/e2e/buzz-theme-screenshots.spec.ts b/desktop/tests/e2e/buzz-theme-screenshots.spec.ts
index cb55c64f76..991479e06e 100644
--- a/desktop/tests/e2e/buzz-theme-screenshots.spec.ts
+++ b/desktop/tests/e2e/buzz-theme-screenshots.spec.ts
@@ -5,6 +5,8 @@ import { installMockBridge } from "../helpers/bridge";
const SHOTS = "test-results/buzz-theme";
const THEME_STORAGE_KEY = "buzz-theme";
+const MOCK_PUBKEY = "deadbeef".repeat(8);
+const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
/**
* Seed the active theme into localStorage BEFORE the mock bridge installs so
@@ -20,6 +22,29 @@ async function seedTheme(page: Page, theme: string) {
);
}
+async function seedIconChannelSection(page: Page) {
+ await page.addInitScript(
+ ({ channelId, pubkey }) => {
+ window.localStorage.setItem(
+ `buzz-channel-sections.v1:${pubkey}`,
+ JSON.stringify({
+ version: 1,
+ sections: [
+ {
+ id: "alignment-section",
+ name: "Team channels",
+ icon: "📌",
+ order: 0,
+ },
+ ],
+ assignments: { [channelId]: "alignment-section" },
+ }),
+ );
+ },
+ { channelId: GENERAL_CHANNEL_ID, pubkey: MOCK_PUBKEY },
+ );
+}
+
async function openChannel(page: Page) {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByTestId("channel-general").click();
@@ -27,10 +52,317 @@ async function openChannel(page: Page) {
await expect(page.getByTestId("app-sidebar")).toBeVisible();
}
+async function expectBuzzSidebarPalette(page: Page, mode: "light" | "dark") {
+ const mutedColor =
+ mode === "light" ? "rgba(0, 0, 0, 0.4)" : "rgba(255, 255, 255, 0.4)";
+ const searchSurface =
+ mode === "light" ? "rgba(0, 0, 0, 0.04)" : "rgba(255, 255, 255, 0.04)";
+ const hoverSurface =
+ mode === "light" ? "rgba(0, 0, 0, 0.04)" : "rgba(255, 255, 255, 0.04)";
+ const activeSurface =
+ mode === "light" ? "rgba(0, 0, 0, 0.07)" : "color(srgb 1 1 1 / 0.16)";
+ const chromeColor =
+ mode === "light" ? "rgba(0, 0, 0, 0.5)" : "rgba(255, 255, 255, 0.5)";
+ const search = page.getByTestId("open-search");
+ const pinnedHeader = page.getByTestId("sidebar-pinned-header");
+ const sidebarScroller = page.locator(".buzz-sidebar-scrollbar");
+ const scrollContent = page.getByTestId("sidebar-scroll-content");
+ const primaryMenu = page.getByTestId("sidebar-primary-menu");
+ const sectionLabel = page
+ .locator('[data-sidebar="group-label"]')
+ .filter({ hasText: "Channels" })
+ .first();
+
+ await expect(sectionLabel).toHaveCSS("color", mutedColor);
+ await expect(search).toHaveCSS("background-color", searchSurface);
+ await expect(search.locator("svg").first()).toHaveCSS("color", mutedColor);
+ await expect(search.locator("span").first()).toHaveCSS("color", mutedColor);
+ await expect(pinnedHeader).toHaveCSS("padding-bottom", "8px");
+ await expect(pinnedHeader).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
+ await expect(pinnedHeader).toHaveCSS("margin-left", "3px");
+ await expect(pinnedHeader).toHaveCSS("margin-right", "3px");
+ await expect(pinnedHeader).toHaveCSS("padding-right", "8px");
+ await expect(sidebarScroller).toHaveCSS("padding-left", "0px");
+ await expect(sidebarScroller).toHaveCSS("padding-right", "0px");
+ await expect(scrollContent).toHaveCSS("padding-left", "3px");
+ await expect(scrollContent).toHaveCSS("padding-right", "3px");
+ const pinnedSpacerColor = await pinnedHeader.evaluate(
+ (element) => getComputedStyle(element, "::before").backgroundColor,
+ );
+ expect(pinnedSpacerColor).toBe("rgba(0, 0, 0, 0)");
+ await expect(sidebarScroller.getByTestId("open-agents-view")).toBeVisible();
+ const searchBox = await search.boundingBox();
+ const pinnedHeaderBox = await pinnedHeader.boundingBox();
+ const primaryMenuBox = await primaryMenu.boundingBox();
+ const primaryRowBox = await page
+ .getByTestId("open-agents-view")
+ .boundingBox();
+ const activeRowBox = await page.getByTestId("channel-general").boundingBox();
+ const hoverRowBox = await page.getByTestId("channel-random").boundingBox();
+ const scrollContentBox = await scrollContent.evaluate((element) => {
+ const box = element.getBoundingClientRect();
+ return { left: box.left, right: box.right };
+ });
+ expect(searchBox).not.toBeNull();
+ expect(pinnedHeaderBox).not.toBeNull();
+ expect(primaryMenuBox).not.toBeNull();
+ expect(primaryRowBox).not.toBeNull();
+ expect(activeRowBox).not.toBeNull();
+ expect(hoverRowBox).not.toBeNull();
+ if (
+ !searchBox ||
+ !pinnedHeaderBox ||
+ !primaryMenuBox ||
+ !primaryRowBox ||
+ !activeRowBox ||
+ !hoverRowBox
+ ) {
+ throw new Error("Sidebar search or primary navigation geometry is missing");
+ }
+ expect(primaryMenuBox.y - (searchBox.y + searchBox.height)).toBe(8);
+ expect(
+ pinnedHeaderBox.y +
+ pinnedHeaderBox.height -
+ (searchBox.y + searchBox.height),
+ ).toBe(8);
+ expect(primaryMenuBox.y - (pinnedHeaderBox.y + pinnedHeaderBox.height)).toBe(
+ 0,
+ );
+ for (const rowBox of [primaryRowBox, activeRowBox, hoverRowBox]) {
+ expect(Math.abs(rowBox.x - searchBox.x)).toBeLessThanOrEqual(0.5);
+ // Linux CI reserves a classic scrollbar gutter while macOS uses an
+ // overlay scrollbar. Compare each row to its usable scroll area so the
+ // alignment check remains platform-independent.
+ const rowLeftSpacing = rowBox.x - scrollContentBox.left;
+ const rowRightSpacing = scrollContentBox.right - (rowBox.x + rowBox.width);
+ expect(Math.abs(rowLeftSpacing - rowRightSpacing)).toBeLessThanOrEqual(0.5);
+ }
+ await expect(page.locator("[data-buzz-sidebar-secondary]").first()).toHaveCSS(
+ "color",
+ mutedColor,
+ );
+ await expect(page.locator('[data-sidebar="trigger"]')).toHaveCSS(
+ "color",
+ chromeColor,
+ );
+ await expect(page.getByTestId("global-back")).toHaveCSS("color", chromeColor);
+ await expect(page.getByTestId("global-forward")).toHaveCSS(
+ "color",
+ chromeColor,
+ );
+ await expect(page.getByTestId("channel-general")).not.toHaveCSS(
+ "color",
+ mutedColor,
+ );
+ await expect(page.getByTestId("channel-general")).toHaveCSS(
+ "background-color",
+ activeSurface,
+ );
+ const hoverChannel = page.getByTestId("channel-random");
+ await hoverChannel.hover();
+ await expect(hoverChannel).toHaveCSS("background-color", hoverSurface);
+
+ const firstDmItem = page
+ .getByTestId("dm-list")
+ .locator('[data-sidebar="menu-item"]')
+ .first();
+ const firstDmButton = firstDmItem.locator('[data-sidebar="menu-button"]');
+ const closeDmButton = firstDmItem.getByRole("button", {
+ name: "Close direct message",
+ });
+ await firstDmItem.hover();
+ await expect(closeDmButton).toBeVisible();
+ await closeDmButton.hover();
+ await expect(firstDmButton).toHaveCSS("background-color", hoverSurface);
+
+ const scrollbarThumbColor = await sidebarScroller.evaluate(
+ (element) =>
+ getComputedStyle(element, "::-webkit-scrollbar-thumb").backgroundColor,
+ );
+ expect(scrollbarThumbColor).toBe(hoverSurface);
+}
+
+async function expectIconlessSectionTitleAligned(
+ page: Page,
+ listTestId: "stream-list" | "dm-list",
+) {
+ const titleBox = await page
+ .getByTestId(`${listTestId}-section-label`)
+ .locator("[data-sidebar-section-title]")
+ .boundingBox();
+ const firstRowIconX = await page
+ .getByTestId(listTestId)
+ .locator('[data-sidebar="menu-button"]')
+ .first()
+ .evaluate((element) => {
+ const box = element.getBoundingClientRect();
+ const paddingLeft = Number.parseFloat(
+ getComputedStyle(element).paddingLeft,
+ );
+ return box.x + paddingLeft;
+ });
+
+ expect(titleBox).not.toBeNull();
+ if (!titleBox) {
+ throw new Error(`Sidebar section ${listTestId} is missing label geometry`);
+ }
+ expect(Math.abs(titleBox.x - firstRowIconX)).toBeLessThanOrEqual(0.5);
+}
+
+async function expectBuzzContentShadow(page: Page, mode: "light" | "dark") {
+ const effects = await page.evaluate(() => {
+ const shell = document.querySelector(".buzz-huddle-shell");
+ const content = document.querySelector("[data-buzz-content-surface]");
+ const shadowViewport = document.querySelector(
+ "[data-buzz-shadow-viewport]",
+ );
+ return {
+ appStroke: shell ? getComputedStyle(shell, "::before").boxShadow : "",
+ contentShadow: content ? getComputedStyle(content).boxShadow : "",
+ shadowViewportOverflow: shadowViewport
+ ? getComputedStyle(shadowViewport).overflow
+ : "",
+ };
+ });
+
+ expect(effects.appStroke).toBe("none");
+ if (mode === "light") {
+ expect(effects.contentShadow).toContain("4px");
+ expect(effects.contentShadow).toContain("rgba(0, 0, 0, 0.07)");
+ expect(effects.shadowViewportOverflow).toBe("visible");
+ } else {
+ expect(effects.contentShadow).not.toContain("4px");
+ expect(effects.contentShadow).not.toContain("rgba(255, 255, 255, 0.07)");
+ expect(effects.shadowViewportOverflow).toBe("hidden");
+ }
+}
+
+async function expectBuzzGradientPaint(
+ page: Page,
+ mode: "light" | "dark",
+): Promise {
+ const paint = await page.evaluate(() => {
+ const root = document.documentElement;
+ const appSurface = document.querySelector(".buzz-huddle-app-surface");
+ const lightLayer = document.querySelector('[data-buzz-gradient="light"]');
+ const darkLayer = document.querySelector('[data-buzz-gradient="dark"]');
+ const sidebarRoot = document.querySelector(
+ '[data-testid="app-sidebar"], [data-testid="settings-sidebar"]',
+ );
+ const sidebarSurface =
+ sidebarRoot?.querySelector('[data-sidebar="sidebar"]') ?? sidebarRoot;
+ const appStyles = appSurface ? getComputedStyle(appSurface) : null;
+ const lightStyles = lightLayer ? getComputedStyle(lightLayer) : null;
+ const darkStyles = darkLayer ? getComputedStyle(darkLayer) : null;
+ return {
+ isDark: root.classList.contains("dark"),
+ theme: root.getAttribute("data-buzz-theme"),
+ surfaceImage: appStyles?.backgroundImage ?? "",
+ lightImage: lightStyles?.backgroundImage ?? "",
+ lightOpacity: lightStyles?.opacity ?? "",
+ darkImage: darkStyles?.backgroundImage ?? "",
+ darkOpacity: darkStyles?.opacity ?? "",
+ sidebarImage: sidebarSurface
+ ? getComputedStyle(sidebarSurface).backgroundImage
+ : "",
+ };
+ });
+
+ expect(paint.theme).toBe(mode === "light" ? "buzz" : "buzz-dark");
+ expect(paint.isDark).toBe(mode === "dark");
+ expect(paint.surfaceImage).toBe("none");
+ expect(paint.lightImage).not.toBe("");
+ expect(paint.lightImage).not.toBe("none");
+ expect(paint.darkImage).not.toBe("");
+ expect(paint.darkImage).not.toBe("none");
+ expect(paint.lightImage).not.toBe(paint.darkImage);
+ expect(paint.lightOpacity).toBe(mode === "light" ? "1" : "0");
+ expect(paint.darkOpacity).toBe(mode === "dark" ? "1" : "0");
+ expect(paint.sidebarImage).toBe("none");
+ return mode === "light" ? paint.lightImage : paint.darkImage;
+}
+
+async function expectBuzzSettingsPalette(page: Page, mode: "light" | "dark") {
+ const mutedColor =
+ mode === "light" ? "rgba(0, 0, 0, 0.4)" : "rgba(255, 255, 255, 0.4)";
+ const sidebar = page.getByTestId("settings-sidebar");
+ const sectionLabel = sidebar
+ .locator('[data-sidebar="group-label"]')
+ .filter({ hasText: "Personal" });
+
+ await expect(sectionLabel).toHaveCSS("color", mutedColor);
+ await expect(page.getByTestId("settings-nav-profile")).not.toHaveCSS(
+ "color",
+ mutedColor,
+ );
+
+ await expectBuzzGradientPaint(page, mode);
+
+ const version = page.getByTestId("settings-version");
+ if ((await version.count()) > 0) {
+ await expect(version).toHaveCSS("color", mutedColor);
+ }
+}
+
+async function expectAppliedBuzzTheme(
+ page: Page,
+ themeName: "buzz" | "buzz-dark",
+ storedTheme: "buzz" | "buzz-dark" = themeName,
+) {
+ const isDark = themeName === "buzz-dark";
+ await expect
+ .poll(() =>
+ page.evaluate((storageKey) => {
+ const root = document.documentElement;
+ const styles = getComputedStyle(root);
+ return {
+ storedTheme: window.localStorage.getItem(storageKey),
+ isDark: root.classList.contains("dark"),
+ buzzTheme: root.getAttribute("data-buzz-theme"),
+ gradientTop: styles.getPropertyValue("--buzz-gradient-top").trim(),
+ gradientBottom: styles
+ .getPropertyValue("--buzz-gradient-bottom")
+ .trim(),
+ };
+ }, THEME_STORAGE_KEY),
+ )
+ .toEqual({
+ storedTheme,
+ isDark,
+ buzzTheme: themeName,
+ gradientTop: isDark ? "#4a4616" : "#e6e6b6",
+ gradientBottom: isDark ? "#0a1423" : "#c4d0da",
+ });
+}
+
+async function emitNativeThemeChange(page: Page, theme: "light" | "dark") {
+ await page.evaluate(async (nextTheme) => {
+ const tauriWindow = window as typeof window & {
+ __TAURI_INTERNALS__?: {
+ invoke?: (
+ command: string,
+ payload?: Record,
+ ) => Promise;
+ };
+ };
+ const invoke = tauriWindow.__TAURI_INTERNALS__?.invoke;
+ if (!invoke) throw new Error("Mock Tauri invoke bridge is unavailable.");
+ await invoke("plugin:event|emit", {
+ event: "tauri://theme-changed",
+ payload: nextTheme,
+ });
+ }, theme);
+}
+
test("buzz light sidebar gradient", async ({ page }) => {
await seedTheme(page, "buzz");
await installMockBridge(page);
await openChannel(page);
+ await expectBuzzGradientPaint(page, "light");
+ await expectBuzzSidebarPalette(page, "light");
+ await expectBuzzContentShadow(page, "light");
+ await expectIconlessSectionTitleAligned(page, "stream-list");
+ await expectIconlessSectionTitleAligned(page, "dm-list");
await waitForAnimations(page);
await page
.getByTestId("app-sidebar")
@@ -41,12 +373,64 @@ test("buzz dark sidebar gradient", async ({ page }) => {
await seedTheme(page, "buzz-dark");
await installMockBridge(page);
await openChannel(page);
+ await expectBuzzGradientPaint(page, "dark");
+ await expectBuzzSidebarPalette(page, "dark");
+ await expectBuzzContentShadow(page, "dark");
+ await expectIconlessSectionTitleAligned(page, "stream-list");
+ await expectIconlessSectionTitleAligned(page, "dm-list");
+ await expect(page.locator("[data-buzz-content-surface]")).toHaveCSS(
+ "background-color",
+ "rgb(26, 26, 26)",
+ );
await waitForAnimations(page);
await page
.getByTestId("app-sidebar")
.screenshot({ path: `${SHOTS}/02-buzz-dark-sidebar.png` });
});
+test("custom section icon and name align with channel columns", async ({
+ page,
+}) => {
+ await seedTheme(page, "buzz");
+ await seedIconChannelSection(page);
+ await installMockBridge(page);
+ await openChannel(page);
+
+ const sectionIconBox = await page
+ .getByTestId("section-icon-alignment-section")
+ .boundingBox();
+ const sectionTitleBox = await page
+ .getByTestId("section-title-alignment-section")
+ .boundingBox();
+ const channelButton = page.getByTestId("channel-general");
+ const channelIconBox = await channelButton
+ .locator("svg")
+ .first()
+ .boundingBox();
+ const channelTitleBox = await channelButton
+ .locator("[data-sidebar-row-label]")
+ .boundingBox();
+
+ expect(sectionIconBox).not.toBeNull();
+ expect(sectionTitleBox).not.toBeNull();
+ expect(channelIconBox).not.toBeNull();
+ expect(channelTitleBox).not.toBeNull();
+ if (
+ !sectionIconBox ||
+ !sectionTitleBox ||
+ !channelIconBox ||
+ !channelTitleBox
+ ) {
+ throw new Error("Custom section alignment geometry is missing");
+ }
+ expect(Math.abs(sectionIconBox.x - channelIconBox.x)).toBeLessThanOrEqual(
+ 0.5,
+ );
+ expect(Math.abs(sectionTitleBox.x - channelTitleBox.x)).toBeLessThanOrEqual(
+ 0.5,
+ );
+});
+
async function openAppearance(page: Page, mode: "system" | "light" | "dark") {
// Settings renders at the AppShell level; open it via the profile card
// button, then select the Appearance section.
@@ -90,9 +474,23 @@ test("settings nav uses Buzz active pill + hover (light)", async ({ page }) => {
await page.getByTestId("profile-popover-settings").click();
const sidebar = page.getByTestId("settings-sidebar");
await expect(sidebar).toBeVisible({ timeout: 10_000 });
+ const profileRow = page.getByTestId("settings-nav-profile");
+ const profileLabel = profileRow.locator('[data-sidebar="menu-label"]');
+ await expect(profileRow).toHaveAttribute("data-active", "true");
+ await expect(profileRow).toHaveCSS("font-weight", "600");
+ const selectedLabelBox = await profileLabel.boundingBox();
// 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();
+ await expect(profileRow).toHaveCSS("font-weight", "400");
+ const unselectedLabelBox = await profileLabel.boundingBox();
+ expect(selectedLabelBox).not.toBeNull();
+ expect(unselectedLabelBox).not.toBeNull();
+ if (!selectedLabelBox || !unselectedLabelBox) {
+ throw new Error("Settings nav label geometry is missing");
+ }
+ expect(Math.abs(selectedLabelBox.width - unselectedLabelBox.width)).toBe(0);
+ await expectBuzzSettingsPalette(page, "light");
const activeRow = page.getByTestId("settings-nav-appearance");
await expect(activeRow).toHaveAttribute("data-active", "true");
await waitForAnimations(page);
@@ -108,8 +506,64 @@ test("settings nav uses Buzz active pill + hover (dark)", async ({ page }) => {
const sidebar = page.getByTestId("settings-sidebar");
await expect(sidebar).toBeVisible({ timeout: 10_000 });
await page.getByTestId("settings-nav-appearance").click();
+ await expectBuzzSettingsPalette(page, "dark");
+ await expect(page.getByTestId("settings-content-surface")).toHaveCSS(
+ "background-color",
+ "rgb(26, 26, 26)",
+ );
await waitForAnimations(page);
await sidebar.screenshot({ path: `${SHOTS}/07-settings-nav-dark.png` });
+ await page.getByTestId("settings-view").screenshot({
+ path: `${SHOTS}/09-settings-content-dark.png`,
+ });
+});
+
+test("settings content uses the same inset surface as the main app", async ({
+ page,
+}) => {
+ await seedTheme(page, "buzz");
+ await installMockBridge(page);
+ await page.goto("/", { waitUntil: "domcontentloaded" });
+ const searchBox = await page.getByTestId("open-search").boundingBox();
+ await page.getByTestId("open-settings").click();
+ await page.getByTestId("profile-popover-settings").click();
+
+ const settingsView = page.getByTestId("settings-view");
+ const contentSurface = page.getByTestId("settings-content-surface");
+ const backToAppBox = await page
+ .getByTestId("settings-back-to-app")
+ .boundingBox();
+ await expect(contentSurface).toBeVisible({ timeout: 10_000 });
+ await expect(page.getByTestId("settings-content-scroll")).toHaveCSS(
+ "padding-top",
+ "24px",
+ );
+
+ const viewBox = await settingsView.boundingBox();
+ const surfaceBox = await contentSurface.boundingBox();
+ expect(searchBox).not.toBeNull();
+ expect(backToAppBox).not.toBeNull();
+ expect(viewBox).not.toBeNull();
+ expect(surfaceBox).not.toBeNull();
+ if (!searchBox || !backToAppBox || !viewBox || !surfaceBox) {
+ throw new Error("Settings layout is missing");
+ }
+
+ expect(Math.abs(backToAppBox.y - searchBox.y)).toBeLessThanOrEqual(0.5);
+
+ // Match the normal app shell: a fixed 40px top chrome strip, then a 1px
+ // top/left inset and 8px right/bottom inset around the rounded content card.
+ expect(surfaceBox.y - viewBox.y).toBe(41);
+ expect(surfaceBox.x - viewBox.x).toBe(1);
+ expect(viewBox.x + viewBox.width - (surfaceBox.x + surfaceBox.width)).toBe(8);
+ expect(viewBox.y + viewBox.height - (surfaceBox.y + surfaceBox.height)).toBe(
+ 8,
+ );
+
+ await waitForAnimations(page);
+ await settingsView.screenshot({
+ path: `${SHOTS}/08-settings-content-inset.png`,
+ });
});
test("appearance hides accent picker under Buzz", async ({ page }) => {
@@ -119,7 +573,7 @@ test("appearance hides accent picker under Buzz", async ({ page }) => {
// 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` });
+ await panel.screenshot({ path: `${SHOTS}/10-appearance-no-accent.png` });
});
test("accent picker reveals/hides when toggling Buzz", async ({ page }) => {
@@ -140,3 +594,46 @@ test("accent picker reveals/hides when toggling Buzz", async ({ page }) => {
await page.getByTestId("theme-option-github-light").click();
await expect(page.getByTestId("accent-color-neutral")).toBeVisible();
});
+
+test("Buzz light and dark modes apply live without a reload", async ({
+ page,
+}) => {
+ await seedTheme(page, "buzz");
+ await installMockBridge(page);
+ await openAppearance(page, "light");
+ await expectAppliedBuzzTheme(page, "buzz");
+ const lightGradient = await expectBuzzGradientPaint(page, "light");
+
+ await page.getByTestId("appearance-mode-dark").click();
+ await expectAppliedBuzzTheme(page, "buzz-dark");
+ const darkGradient = await expectBuzzGradientPaint(page, "dark");
+ expect(darkGradient).not.toBe(lightGradient);
+
+ await page.getByTestId("appearance-mode-light").click();
+ await expectAppliedBuzzTheme(page, "buzz");
+ await expectBuzzGradientPaint(page, "light");
+
+ // Exercise the overlap that previously let a slower, stale theme load win.
+ await page.getByTestId("appearance-mode-dark").click();
+ await page.getByTestId("appearance-mode-light").click();
+ await expectAppliedBuzzTheme(page, "buzz");
+});
+
+test("Buzz follows native system theme changes without a reload", async ({
+ page,
+}) => {
+ await seedTheme(page, "buzz");
+ await page.addInitScript(() => {
+ (window as typeof window & { isTauri?: boolean }).isTauri = true;
+ });
+ await installMockBridge(page);
+ await openAppearance(page, "system");
+
+ await emitNativeThemeChange(page, "dark");
+ await expectAppliedBuzzTheme(page, "buzz-dark", "buzz");
+ await expectBuzzGradientPaint(page, "dark");
+
+ await emitNativeThemeChange(page, "light");
+ await expectAppliedBuzzTheme(page, "buzz", "buzz");
+ await expectBuzzGradientPaint(page, "light");
+});
diff --git a/desktop/tests/e2e/top-chrome-zoom-clearance.spec.ts b/desktop/tests/e2e/top-chrome-zoom-clearance.spec.ts
index 52b25ed7c4..eb69953dbd 100644
--- a/desktop/tests/e2e/top-chrome-zoom-clearance.spec.ts
+++ b/desktop/tests/e2e/top-chrome-zoom-clearance.spec.ts
@@ -17,7 +17,7 @@ const tauriConfig = JSON.parse(
"utf8",
),
) as TauriConfig;
-const EXPECTED_TRAFFIC_LIGHT_POSITION = { x: 16, y: 24 };
+const EXPECTED_TRAFFIC_LIGHT_POSITION = { x: 16, y: 25 };
const EXPECTED_NAV_CENTER_Y = 23;
// The macOS traffic lights are native chrome: with `trafficLightPosition`
@@ -118,9 +118,9 @@ test.describe("top chrome macOS traffic-light clearance under text zoom", () =>
.getByRole("button", { name: "Toggle Sidebar", exact: true })
.boundingBox();
expect(toggleBox).not.toBeNull();
- // Tauri interprets y:24 as a native titlebar inset, not the literal
- // traffic-light center. The established 40px row + 3px webview offset
- // centers the adjacent controls at y:23.
+ // Tauri interprets y:25 as a native titlebar inset, not the literal
+ // traffic-light center. The native controls use a small optical correction
+ // while the adjacent web controls remain centered at y:23.
expect((toggleBox?.y ?? 0) + (toggleBox?.height ?? 0) / 2).toBe(
EXPECTED_NAV_CENTER_Y,
);