Polish Buzz theme and sidebar (#1971)

This commit is contained in:
klopez4212
2026-07-17 12:40:05 +01:00
committed by GitHub
parent 85fc64835e
commit 2121fd0452
18 changed files with 1351 additions and 446 deletions

View File

@@ -202,11 +202,6 @@ const overrides = new Map([
// mapper. This is the existing API boundary; split remains queued.
// team-instructions-first-class: createManagedAgent Tauri bridge threads the
// new teamId input through to the backend (+1 line).
// #1970 merge-skew: add-community deep-link wiring (+4) was based before
// #1923 (+3) landed, so PR CI was green while the merged total crossed the
// cap (996 -> 1003). Pre-existing red on main, not one PR's feature debt.
// Queued to split with the rest of this list.
["src/app/AppShell.tsx", 1003],
["src/shared/api/tauri.ts", 1305],
// doctor-npm-eacces-preflight: hint field added to InstallStepResult (+1 line).
// codex-acp-package-swap: "adapter_outdated" variant added to AcpAvailabilityStatus (+1 line).

View File

@@ -27,7 +27,7 @@
"dragDropEnabled": false,
"trafficLightPosition": {
"x": 16,
"y": 24
"y": 25
},
"backgroundThrottling": "disabled",
"minWidth": 800,

View File

@@ -4,6 +4,7 @@ import { Outlet, useLocation } from "@tanstack/react-router";
import { deriveShellRoute } from "@/app/AppShell.helpers";
import { AppShellProvider } from "@/app/AppShellContext";
import * as BuzzTheme from "@/app/BuzzThemeSurfaces";
import { AppShellOverlays } from "@/app/AppShellOverlays";
import { AppTopChrome } from "@/app/AppTopChrome";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
@@ -761,6 +762,7 @@ export function AppShell() {
isHuddleDrawerOpen && "buzz-huddle-app-surface-open",
)}
>
<BuzzTheme.GradientLayer />
{hasCommunityRail ? (
<CommunityRail
activeCommunityId={
@@ -921,11 +923,12 @@ export function AppShell() {
ref={mainInsetRef}
className="isolate min-h-0 min-w-0 overflow-hidden bg-sidebar"
data-buzz-glass-inset
data-buzz-shadow-viewport
style={chromeCssVarDefaults as React.CSSProperties}
>
<div className="relative z-10 mb-2 ml-px mr-2 mt-px flex min-h-0 flex-1 flex-col overflow-hidden rounded-2xl bg-background shadow-[-1px_-1px_0_0_hsl(var(--sidebar-border)/0.45)]">
<BuzzTheme.ContentSurface>
<Outlet />
</div>
</BuzzTheme.ContentSurface>
</SidebarInset>
</MainInsetProvider>
<RelayConnectionOverlay

View File

@@ -0,0 +1,31 @@
import type { ReactNode } from "react";
export function GradientLayer() {
return (
<div
aria-hidden="true"
className="buzz-theme-gradient-layer pointer-events-none absolute inset-0 -z-10"
data-buzz-gradient-layer
>
<div
className="buzz-theme-gradient-layer-light absolute inset-0 opacity-0"
data-buzz-gradient="light"
/>
<div
className="buzz-theme-gradient-layer-dark absolute inset-0 opacity-0"
data-buzz-gradient="dark"
/>
</div>
);
}
export function ContentSurface({ children }: { children: ReactNode }) {
return (
<div
className="relative z-10 mb-2 ml-px mr-2 mt-px flex min-h-0 flex-1 flex-col overflow-hidden rounded-2xl bg-background shadow-content-edge"
data-buzz-content-surface
>
{children}
</div>
);
}

View File

@@ -8,6 +8,7 @@ import {
resolveEnabled,
useFeatureSnapshot,
} from "@/shared/features/useFeatureEnabled";
import { topChromeBackdrop } from "@/shared/layout/chromeLayout";
import { cn } from "@/shared/lib/cn";
import {
Sidebar,
@@ -23,6 +24,7 @@ import {
SidebarMenuItem,
useSidebar,
} from "@/shared/ui/sidebar";
import { SidebarMenuLabel } from "@/shared/ui/sidebar-menu-label";
import {
renderSettingsSection,
settingsSections,
@@ -103,7 +105,7 @@ function SettingsSectionButton({
: "text-sidebar-foreground/70",
)}
/>
<span>{section.label}</span>
<SidebarMenuLabel>{section.label}</SidebarMenuLabel>
</SidebarMenuButton>
</SidebarMenuItem>
);
@@ -216,8 +218,13 @@ export function SettingsView({
data-testid="settings-sidebar"
variant="sidebar"
>
<div
aria-hidden="true"
className={cn("shrink-0", topChromeBackdrop.height)}
data-tauri-drag-region
/>
<SidebarHeader
className="cursor-default select-none pt-11"
className="cursor-default select-none pb-0 pt-3"
data-tauri-drag-region
>
<SidebarMenu>
@@ -257,7 +264,11 @@ export function SettingsView({
<SidebarFooter>
{appVersion ? (
<p className="px-2 pb-1 text-xs text-sidebar-foreground/45">
<p
className="px-2 pb-1 text-xs text-sidebar-foreground/45"
data-buzz-sidebar-secondary
data-testid="settings-version"
>
v{appVersion}
</p>
) : null}
@@ -269,15 +280,23 @@ export function SettingsView({
"isolate relative min-h-0 min-w-0 overflow-hidden bg-sidebar motion-safe:transition-opacity motion-safe:duration-200",
isLoaded ? "opacity-100" : "opacity-0",
)}
data-buzz-shadow-viewport
data-testid="settings-view"
>
<div
aria-hidden="true"
className="absolute inset-x-0 top-0 z-10 h-11"
className={cn("relative z-10 shrink-0", topChromeBackdrop.height)}
data-tauri-drag-region
/>
<div className="relative z-10 ml-px mt-px flex min-h-0 flex-1 flex-col overflow-hidden rounded-tl-xl bg-background pt-11 shadow-[-1px_-1px_0_0_hsl(var(--sidebar-border)/0.45)]">
<section className="min-h-0 flex-1 overflow-y-auto px-5 pb-12 pt-4 sm:px-6">
<div
className="relative z-10 mb-2 ml-px mr-2 mt-px flex min-h-0 flex-1 flex-col overflow-hidden rounded-2xl bg-background shadow-content-edge"
data-buzz-content-surface
data-testid="settings-content-surface"
>
<section
className="min-h-0 flex-1 overflow-y-auto px-5 pb-12 pt-6 sm:px-6"
data-testid="settings-content-scroll"
>
<div
className="mx-auto flex h-full w-full max-w-4xl flex-col gap-4"
data-testid={`settings-panel-${section}`}

View File

@@ -29,7 +29,10 @@ import {
useLeaveChannelDialog,
type SectionDialogValue,
} from "@/features/sidebar/ui/ChannelSectionDialogs";
import { AppSidebarPinnedHeader } from "@/features/sidebar/ui/AppSidebarPinnedHeader";
import {
AppSidebarPinnedHeader,
AppSidebarPrimaryMenu,
} from "@/features/sidebar/ui/AppSidebarPinnedHeader";
import { MoreUnreadButton } from "@/features/sidebar/ui/MoreUnreadButton";
import { SidebarSection } from "@/features/sidebar/ui/SidebarSection";
import {
@@ -551,20 +554,13 @@ export function AppSidebar({
<AppSidebarPinnedHeader
channelLabels={dmChannelLabels}
currentPubkey={currentPubkey}
homeBadgeCount={homeBadgeCount}
onCreateAgent={onCreateAgent}
onCreateChannel={handleOpenCreateChannel}
onOpenDm={onOpenDm}
onOpenSearchResult={onOpenSearchResult}
onSelectAgents={onSelectAgents}
onSelectChannel={onSelectChannel}
onSelectHome={onSelectHome}
onSelectProjects={onSelectProjects}
onSelectPulse={onSelectPulse}
onSelectWorkflows={onSelectWorkflows}
searchChannels={searchChannels}
searchFocusRequest={searchFocusRequest}
selectedView={selectedView}
suggestionChannels={channels}
/>
@@ -582,105 +578,57 @@ export function AppSidebar({
) : null}
<SidebarContent
className="buzz-sidebar-scrollbar overscroll-none pt-4"
className="buzz-sidebar-scrollbar overscroll-none"
ref={scrollRef}
>
{isLoading ? (
<SidebarLoadingContent shape={sidebarLoadingShape} />
) : null}
<div
className="flex w-full flex-col gap-2 px-[3px]"
data-testid="sidebar-scroll-content"
>
<AppSidebarPrimaryMenu
homeBadgeCount={homeBadgeCount}
onSelectAgents={onSelectAgents}
onSelectHome={onSelectHome}
onSelectProjects={onSelectProjects}
onSelectPulse={onSelectPulse}
onSelectWorkflows={onSelectWorkflows}
selectedView={selectedView}
/>
{!isLoading ? (
<>
{starredChannels.length > 0 ? (
<ChannelGroupSection
hasUnread={starredChannels.some((c) =>
unreadChannelIds.has(c.id),
)}
isCollapsed={collapsedGroups.starred}
isActiveChannel={selectedView === "channel"}
activeWorkingByChannelId={activeWorkingByChannelId}
items={starredChannels}
sortMode={sortModeFor("starred")}
onSortModeChange={(mode) => setSortModeFor("starred", mode)}
actionsTestId="section-actions-starred"
listTestId="starred-list"
onMarkAllRead={() => {
for (const channel of starredChannels) {
onMarkChannelRead(channel.id, channel.lastMessageAt);
}
}}
onMarkChannelRead={onMarkChannelRead}
onMarkChannelUnread={onMarkChannelUnread}
onSelectChannel={onSelectChannel}
onToggleCollapsed={() => toggleCollapsedGroup("starred")}
selectedChannelId={selectedChannelId}
title="Starred"
unreadChannelCounts={unreadChannelCounts}
unreadChannelIds={unreadChannelIds}
mutedChannelIds={mutedChannelIds}
onMuteChannel={onMuteChannel}
onUnmuteChannel={onUnmuteChannel}
starredChannelIds={starredChannelIds}
onStarChannel={onStarChannel}
onUnstarChannel={onUnstarChannel}
onLeaveChannel={requestLeaveChannel}
/>
) : null}
<SidebarDndContext
channels={channels}
sections={channelSections}
sectionIds={sectionIds}
onAssignChannel={assignChannel}
onUnassignChannel={unassignChannel}
onReorderSections={reorderSections}
>
{channelSections.map((section, idx) => (
<CustomChannelSection
key={section.id}
section={section}
channels={sectionBuckets.bySection[section.id] ?? []}
hasUnread={
sectionBuckets.bySection[section.id]?.some((c) =>
unreadChannelIds.has(c.id),
) ?? false
}
isCollapsed={collapsedSections[section.id] ?? false}
{isLoading ? (
<SidebarLoadingContent shape={sidebarLoadingShape} />
) : null}
{!isLoading ? (
<>
{starredChannels.length > 0 ? (
<ChannelGroupSection
hasUnread={starredChannels.some((c) =>
unreadChannelIds.has(c.id),
)}
isCollapsed={collapsedGroups.starred}
isActiveChannel={selectedView === "channel"}
activeWorkingByChannelId={activeWorkingByChannelId}
selectedChannelId={selectedChannelId}
unreadChannelCounts={unreadChannelCounts}
unreadChannelIds={unreadChannelIds}
sections={channelSections}
assignments={channelAssignments}
isFirst={idx === 0}
isLast={idx === channelSections.length - 1}
sortMode={sortModeFor(sectionSortGroupKey(section.id))}
items={starredChannels}
sortMode={sortModeFor("starred")}
onSortModeChange={(mode) =>
setSortModeFor(sectionSortGroupKey(section.id), mode)
setSortModeFor("starred", mode)
}
onToggleCollapsed={() =>
toggleCollapsedSection(section.id)
}
onSelectChannel={onSelectChannel}
onMarkChannelRead={onMarkChannelRead}
onMarkChannelUnread={onMarkChannelUnread}
onMarkSectionRead={() => {
for (const channel of sectionBuckets.bySection[
section.id
] ?? []) {
actionsTestId="section-actions-starred"
listTestId="starred-list"
onMarkAllRead={() => {
for (const channel of starredChannels) {
onMarkChannelRead(channel.id, channel.lastMessageAt);
}
}}
onAssignChannel={assignChannel}
onUnassignChannel={unassignChannel}
onCreateSectionForChannel={handleCreateSectionForChannel}
onCreateChannel={() =>
handleCreateChannelInSection(section.id)
}
onRenameSection={() => setRenameSectionTarget(section)}
onDeleteSection={() => setDeleteSectionTarget(section)}
onMoveSectionUp={() => moveSectionUp(section.id)}
onMoveSectionDown={() => moveSectionDown(section.id)}
onMarkChannelRead={onMarkChannelRead}
onMarkChannelUnread={onMarkChannelUnread}
onSelectChannel={onSelectChannel}
onToggleCollapsed={() => toggleCollapsedGroup("starred")}
selectedChannelId={selectedChannelId}
title="Starred"
unreadChannelCounts={unreadChannelCounts}
unreadChannelIds={unreadChannelIds}
mutedChannelIds={mutedChannelIds}
onMuteChannel={onMuteChannel}
onUnmuteChannel={onUnmuteChannel}
@@ -689,123 +637,197 @@ export function AppSidebar({
onUnstarChannel={onUnstarChannel}
onLeaveChannel={requestLeaveChannel}
/>
))}
<ChannelGroupSection
draggable
hasUnread={unreadChannelIds.size > 0}
isCollapsed={collapsedGroups.channels}
isActiveChannel={selectedView === "channel"}
activeWorkingByChannelId={activeWorkingByChannelId}
items={sectionBuckets.unassigned}
sortMode={sortModeFor("channels")}
onSortModeChange={(mode) =>
setSortModeFor("channels", mode)
}
actionsTestId="section-actions-channels"
listTestId="stream-list"
quickCreateLabel="Add channel"
onQuickCreateClick={onBrowseChannels}
showQuickCreate
onMarkAllRead={onMarkAllChannelsRead}
onMarkChannelRead={onMarkChannelRead}
onMarkChannelUnread={onMarkChannelUnread}
onSelectChannel={onSelectChannel}
onToggleCollapsed={() => toggleCollapsedGroup("channels")}
selectedChannelId={selectedChannelId}
title="Channels"
unreadChannelCounts={unreadChannelCounts}
unreadChannelIds={unreadChannelIds}
) : null}
<SidebarDndContext
channels={channels}
sections={channelSections}
assignments={channelAssignments}
sectionIds={sectionIds}
onAssignChannel={assignChannel}
onUnassignChannel={unassignChannel}
onCreateSectionForChannel={handleCreateSectionForChannel}
mutedChannelIds={mutedChannelIds}
onMuteChannel={onMuteChannel}
onUnmuteChannel={onUnmuteChannel}
starredChannelIds={starredChannelIds}
onStarChannel={onStarChannel}
onUnstarChannel={onUnstarChannel}
onLeaveChannel={requestLeaveChannel}
/>
</SidebarDndContext>
<FeatureGate feature="forum">
<ChannelGroupSection
createLabel="New forum"
hasUnread={unreadChannelIds.size > 0}
isCollapsed={collapsedGroups.forums}
onReorderSections={reorderSections}
>
{channelSections.map((section, idx) => (
<CustomChannelSection
key={section.id}
section={section}
channels={sectionBuckets.bySection[section.id] ?? []}
hasUnread={
sectionBuckets.bySection[section.id]?.some((c) =>
unreadChannelIds.has(c.id),
) ?? false
}
isCollapsed={collapsedSections[section.id] ?? false}
isActiveChannel={selectedView === "channel"}
activeWorkingByChannelId={activeWorkingByChannelId}
selectedChannelId={selectedChannelId}
unreadChannelCounts={unreadChannelCounts}
unreadChannelIds={unreadChannelIds}
sections={channelSections}
assignments={channelAssignments}
isFirst={idx === 0}
isLast={idx === channelSections.length - 1}
sortMode={sortModeFor(sectionSortGroupKey(section.id))}
onSortModeChange={(mode) =>
setSortModeFor(sectionSortGroupKey(section.id), mode)
}
onToggleCollapsed={() =>
toggleCollapsedSection(section.id)
}
onSelectChannel={onSelectChannel}
onMarkChannelRead={onMarkChannelRead}
onMarkChannelUnread={onMarkChannelUnread}
onMarkSectionRead={() => {
for (const channel of sectionBuckets.bySection[
section.id
] ?? []) {
onMarkChannelRead(
channel.id,
channel.lastMessageAt,
);
}
}}
onAssignChannel={assignChannel}
onUnassignChannel={unassignChannel}
onCreateSectionForChannel={
handleCreateSectionForChannel
}
onCreateChannel={() =>
handleCreateChannelInSection(section.id)
}
onRenameSection={() => setRenameSectionTarget(section)}
onDeleteSection={() => setDeleteSectionTarget(section)}
onMoveSectionUp={() => moveSectionUp(section.id)}
onMoveSectionDown={() => moveSectionDown(section.id)}
mutedChannelIds={mutedChannelIds}
onMuteChannel={onMuteChannel}
onUnmuteChannel={onUnmuteChannel}
starredChannelIds={starredChannelIds}
onStarChannel={onStarChannel}
onUnstarChannel={onUnstarChannel}
onLeaveChannel={requestLeaveChannel}
/>
))}
<ChannelGroupSection
draggable
hasUnread={unreadChannelIds.size > 0}
isCollapsed={collapsedGroups.channels}
isActiveChannel={selectedView === "channel"}
activeWorkingByChannelId={activeWorkingByChannelId}
items={sectionBuckets.unassigned}
sortMode={sortModeFor("channels")}
onSortModeChange={(mode) =>
setSortModeFor("channels", mode)
}
actionsTestId="section-actions-channels"
listTestId="stream-list"
quickCreateLabel="Add channel"
onQuickCreateClick={onBrowseChannels}
showQuickCreate
onMarkAllRead={onMarkAllChannelsRead}
onMarkChannelRead={onMarkChannelRead}
onMarkChannelUnread={onMarkChannelUnread}
onSelectChannel={onSelectChannel}
onToggleCollapsed={() => toggleCollapsedGroup("channels")}
selectedChannelId={selectedChannelId}
title="Channels"
unreadChannelCounts={unreadChannelCounts}
unreadChannelIds={unreadChannelIds}
sections={channelSections}
assignments={channelAssignments}
onAssignChannel={assignChannel}
onUnassignChannel={unassignChannel}
onCreateSectionForChannel={handleCreateSectionForChannel}
mutedChannelIds={mutedChannelIds}
onMuteChannel={onMuteChannel}
onUnmuteChannel={onUnmuteChannel}
starredChannelIds={starredChannelIds}
onStarChannel={onStarChannel}
onUnstarChannel={onUnstarChannel}
onLeaveChannel={requestLeaveChannel}
/>
</SidebarDndContext>
<FeatureGate feature="forum">
<ChannelGroupSection
createLabel="New forum"
hasUnread={unreadChannelIds.size > 0}
isCollapsed={collapsedGroups.forums}
isActiveChannel={selectedView === "channel"}
activeWorkingByChannelId={activeWorkingByChannelId}
items={forumChannels}
sortMode={sortModeFor("forums")}
onSortModeChange={(mode) =>
setSortModeFor("forums", mode)
}
actionsTestId="section-actions-forums"
listTestId="forum-list"
onCreateClick={() => openCreateDialog("forum")}
onMarkAllRead={onMarkAllChannelsRead}
onMarkChannelRead={onMarkChannelRead}
onMarkChannelUnread={onMarkChannelUnread}
onSelectChannel={onSelectChannel}
onToggleCollapsed={() => toggleCollapsedGroup("forums")}
selectedChannelId={selectedChannelId}
title="Forums"
unreadChannelCounts={unreadChannelCounts}
unreadChannelIds={unreadChannelIds}
mutedChannelIds={mutedChannelIds}
onMuteChannel={onMuteChannel}
onUnmuteChannel={onUnmuteChannel}
/>
</FeatureGate>
<SidebarSection
action={
<div className="absolute right-1 top-1/2 z-10 flex -translate-y-1/2 items-center gap-0.5">
<SectionQuickAction
label="New message"
onClick={onNewMessage}
testId="section-actions-dms-quick-create"
/>
<SectionActionsMenu
sectionLabel="direct messages"
testId="section-actions-dms"
onOpenChange={setDmActionsMenuOpen}
onNewMessage={onNewMessage}
sortMode={sortModeFor("dms")}
onSortModeChange={(mode) =>
setSortModeFor("dms", mode)
}
/>
</div>
}
dmParticipantsByChannelId={dmParticipantsByChannelId}
isCollapsed={collapsedGroups.directMessages}
isActiveChannel={selectedView === "channel"}
activeWorkingByChannelId={activeWorkingByChannelId}
items={forumChannels}
sortMode={sortModeFor("forums")}
onSortModeChange={(mode) => setSortModeFor("forums", mode)}
actionsTestId="section-actions-forums"
listTestId="forum-list"
onCreateClick={() => openCreateDialog("forum")}
onMarkAllRead={onMarkAllChannelsRead}
items={sortedDirectMessages}
channelLabels={dmChannelLabels}
onHideDm={onHideDm}
onMarkChannelRead={onMarkChannelRead}
onMarkChannelUnread={onMarkChannelUnread}
onSelectChannel={onSelectChannel}
onToggleCollapsed={() => toggleCollapsedGroup("forums")}
onToggleCollapsed={() =>
toggleCollapsedGroup("directMessages")
}
presenceByChannelId={dmPresenceByChannelId}
selectedChannelId={selectedChannelId}
title="Forums"
testId="dm-list"
title="Direct messages"
sectionActionsOpen={dmActionsMenuOpen}
unreadChannelCounts={unreadChannelCounts}
unreadChannelIds={unreadChannelIds}
mutedChannelIds={mutedChannelIds}
onMuteChannel={onMuteChannel}
onUnmuteChannel={onUnmuteChannel}
/>
</FeatureGate>
<SidebarSection
action={
<div className="absolute right-1 top-1/2 z-10 flex -translate-y-1/2 items-center gap-0.5">
<SectionQuickAction
label="New message"
onClick={onNewMessage}
testId="section-actions-dms-quick-create"
/>
<SectionActionsMenu
sectionLabel="direct messages"
testId="section-actions-dms"
onOpenChange={setDmActionsMenuOpen}
onNewMessage={onNewMessage}
sortMode={sortModeFor("dms")}
onSortModeChange={(mode) => setSortModeFor("dms", mode)}
/>
</div>
}
dmParticipantsByChannelId={dmParticipantsByChannelId}
isCollapsed={collapsedGroups.directMessages}
isActiveChannel={selectedView === "channel"}
activeWorkingByChannelId={activeWorkingByChannelId}
items={sortedDirectMessages}
channelLabels={dmChannelLabels}
onHideDm={onHideDm}
onMarkChannelRead={onMarkChannelRead}
onMarkChannelUnread={onMarkChannelUnread}
onSelectChannel={onSelectChannel}
onToggleCollapsed={() =>
toggleCollapsedGroup("directMessages")
}
presenceByChannelId={dmPresenceByChannelId}
selectedChannelId={selectedChannelId}
testId="dm-list"
title="Direct messages"
sectionActionsOpen={dmActionsMenuOpen}
unreadChannelCounts={unreadChannelCounts}
unreadChannelIds={unreadChannelIds}
mutedChannelIds={mutedChannelIds}
onMuteChannel={onMuteChannel}
onUnmuteChannel={onUnmuteChannel}
/>
</>
) : null}
</>
) : null}
{errorMessage && !relayConnectionCard.hasRelayUnreachableError ? (
<div className="px-3 py-2 text-sm text-destructive">
{errorMessage}
</div>
) : null}
{errorMessage && !relayConnectionCard.hasRelayUnreachableError ? (
<div className="px-3 py-2 text-sm text-destructive">
{errorMessage}
</div>
) : null}
</div>
</SidebarContent>
</div>

View File

@@ -10,6 +10,7 @@ import {
SidebarMenuButton,
SidebarMenuItem,
} from "@/shared/ui/sidebar";
import { SidebarMenuLabel } from "@/shared/ui/sidebar-menu-label";
type SidebarSelectedView =
| "home"
@@ -23,44 +24,43 @@ type SidebarSelectedView =
type AppSidebarPinnedHeaderProps = {
channelLabels: Record<string, string>;
currentPubkey?: string;
homeBadgeCount: number;
onCreateAgent: () => void;
onCreateChannel: () => void;
onOpenDm: (input: { pubkeys: string[] }) => Promise<void>;
onOpenSearchResult: (hit: SearchHit) => void;
onSelectAgents: () => void;
onSelectChannel: (channelId: string) => void;
searchChannels: Channel[];
searchFocusRequest: number;
suggestionChannels: Channel[];
};
type AppSidebarPrimaryMenuProps = {
homeBadgeCount: number;
onSelectAgents: () => void;
onSelectHome: () => void;
onSelectProjects: () => void;
onSelectPulse: () => void;
onSelectWorkflows: () => void;
searchChannels: Channel[];
searchFocusRequest: number;
selectedView: SidebarSelectedView;
suggestionChannels: Channel[];
};
export function AppSidebarPinnedHeader({
channelLabels,
currentPubkey,
homeBadgeCount,
onCreateAgent,
onCreateChannel,
onOpenDm,
onOpenSearchResult,
onSelectAgents,
onSelectChannel,
onSelectHome,
onSelectProjects,
onSelectPulse,
onSelectWorkflows,
searchChannels,
searchFocusRequest,
selectedView,
suggestionChannels,
}: AppSidebarPinnedHeaderProps) {
return (
<div className="shrink-0 px-2 pt-3" data-testid="sidebar-pinned-header">
<div
className="mx-[3px] shrink-0 px-2 pb-2 pt-3"
data-testid="sidebar-pinned-header"
>
<TopbarSearch
channelLabels={channelLabels}
channels={searchChannels}
@@ -73,86 +73,100 @@ export function AppSidebarPinnedHeader({
onCreateChannel={onCreateChannel}
suggestionChannels={suggestionChannels}
/>
<SidebarHeader
className="cursor-default select-none px-0 pb-0 pt-2.5"
data-tauri-drag-region
>
<SidebarMenu className="pb-2">
<SidebarMenuItem>
<SidebarMenuButton
isActive={selectedView === "home"}
onClick={onSelectHome}
tooltip="Inbox"
type="button"
>
<Inbox className="h-4 w-4" />
<span>Inbox</span>
</SidebarMenuButton>
{homeBadgeCount > 0 ? (
<SidebarMenuBadge
className="right-2 rounded-full bg-primary/15 px-1.5 text-2xs text-primary peer-data-[active=true]/menu-button:bg-sidebar-active-foreground/20 peer-data-[active=true]/menu-button:text-sidebar-active-foreground"
data-testid="sidebar-home-count"
>
{Math.min(homeBadgeCount, 99)}
</SidebarMenuBadge>
) : null}
</SidebarMenuItem>
<FeatureGate feature="pulse">
<SidebarMenuItem>
<SidebarMenuButton
data-testid="open-pulse-view"
isActive={selectedView === "pulse"}
onClick={onSelectPulse}
tooltip="Pulse"
type="button"
>
<Activity className="h-4 w-4" />
<span>Pulse</span>
</SidebarMenuButton>
</SidebarMenuItem>
</FeatureGate>
<FeatureGate feature="projects">
<SidebarMenuItem>
<SidebarMenuButton
data-testid="open-projects-view"
isActive={selectedView === "projects"}
onClick={onSelectProjects}
tooltip="Projects"
type="button"
>
<FolderGit2 className="h-4 w-4" />
<span>Projects</span>
</SidebarMenuButton>
</SidebarMenuItem>
</FeatureGate>
<SidebarMenuItem>
<SidebarMenuButton
data-testid="open-agents-view"
isActive={selectedView === "agents"}
onClick={onSelectAgents}
tooltip="Agents"
type="button"
>
<Bot className="h-4 w-4" />
<span>Agents</span>
</SidebarMenuButton>
</SidebarMenuItem>
<FeatureGate feature="workflows">
<SidebarMenuItem>
<SidebarMenuButton
data-testid="open-workflows-view"
isActive={selectedView === "workflows"}
onClick={onSelectWorkflows}
tooltip="Workflows"
type="button"
>
<Zap className="h-4 w-4" />
<span>Workflows</span>
</SidebarMenuButton>
</SidebarMenuItem>
</FeatureGate>
</SidebarMenu>
</SidebarHeader>
</div>
);
}
export function AppSidebarPrimaryMenu({
homeBadgeCount,
onSelectAgents,
onSelectHome,
onSelectProjects,
onSelectPulse,
onSelectWorkflows,
selectedView,
}: AppSidebarPrimaryMenuProps) {
return (
<SidebarHeader
className="cursor-default select-none px-2 pb-0 pt-0"
data-tauri-drag-region
data-testid="sidebar-primary-menu"
>
<SidebarMenu className="pb-2">
<SidebarMenuItem>
<SidebarMenuButton
isActive={selectedView === "home"}
onClick={onSelectHome}
tooltip="Inbox"
type="button"
>
<Inbox className="h-4 w-4" />
<SidebarMenuLabel>Inbox</SidebarMenuLabel>
</SidebarMenuButton>
{homeBadgeCount > 0 ? (
<SidebarMenuBadge
className="right-2 rounded-full bg-primary/15 px-1.5 text-2xs text-primary peer-data-[active=true]/menu-button:bg-sidebar-active-foreground/20 peer-data-[active=true]/menu-button:text-sidebar-active-foreground"
data-testid="sidebar-home-count"
>
{Math.min(homeBadgeCount, 99)}
</SidebarMenuBadge>
) : null}
</SidebarMenuItem>
<FeatureGate feature="pulse">
<SidebarMenuItem>
<SidebarMenuButton
data-testid="open-pulse-view"
isActive={selectedView === "pulse"}
onClick={onSelectPulse}
tooltip="Pulse"
type="button"
>
<Activity className="h-4 w-4" />
<SidebarMenuLabel>Pulse</SidebarMenuLabel>
</SidebarMenuButton>
</SidebarMenuItem>
</FeatureGate>
<FeatureGate feature="projects">
<SidebarMenuItem>
<SidebarMenuButton
data-testid="open-projects-view"
isActive={selectedView === "projects"}
onClick={onSelectProjects}
tooltip="Projects"
type="button"
>
<FolderGit2 className="h-4 w-4" />
<SidebarMenuLabel>Projects</SidebarMenuLabel>
</SidebarMenuButton>
</SidebarMenuItem>
</FeatureGate>
<SidebarMenuItem>
<SidebarMenuButton
data-testid="open-agents-view"
isActive={selectedView === "agents"}
onClick={onSelectAgents}
tooltip="Agents"
type="button"
>
<Bot className="h-4 w-4" />
<SidebarMenuLabel>Agents</SidebarMenuLabel>
</SidebarMenuButton>
</SidebarMenuItem>
<FeatureGate feature="workflows">
<SidebarMenuItem>
<SidebarMenuButton
data-testid="open-workflows-view"
isActive={selectedView === "workflows"}
onClick={onSelectWorkflows}
tooltip="Workflows"
type="button"
>
<Zap className="h-4 w-4" />
<SidebarMenuLabel>Workflows</SidebarMenuLabel>
</SidebarMenuButton>
</SidebarMenuItem>
</FeatureGate>
</SidebarMenu>
</SidebarHeader>
);
}

View File

@@ -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"
>
<span>{title}</span>
<span data-sidebar-section-title>{title}</span>
<span aria-hidden="true" className={SECTION_LABEL_CHEVRON_CLASS}>
<ChevronDown
className={cn(
@@ -493,6 +496,7 @@ export function ChannelGroupSection({
isCollapsed={isCollapsed}
onToggleCollapsed={onToggleCollapsed}
title={title}
testId={listTestId}
actions={
<>
{showQuickCreate && (onQuickCreateClick ?? onCreateClick) ? (
@@ -626,11 +630,17 @@ export function CustomChannelSection({
<ContextMenu>
<ContextMenuTrigger asChild>
<div className="relative" {...dragHandleProps}>
<SidebarGroupLabel asChild>
<SidebarGroupLabel
asChild
className={section.icon ? undefined : "pl-8"}
>
<button
aria-controls={contentId}
aria-expanded={!isCollapsed}
className={SECTION_LABEL_BUTTON_CLASS}
className={cn(
SECTION_LABEL_BUTTON_CLASS,
section.icon && "gap-2",
)}
onClick={onToggleCollapsed}
type="button"
>
@@ -648,6 +658,7 @@ export function CustomChannelSection({
) : null}
<span
className="truncate"
data-sidebar-section-title
data-testid={`section-title-${section.id}`}
>
{section.name}

View File

@@ -78,7 +78,10 @@ export function SidebarProfileCard({
const hasStatus = Boolean(selfUserStatus?.text || selfUserStatus?.emoji);
const communityLabel = activeCommunity?.name ?? "No community";
const readonlyCommunityLabel = (
<span className="flex min-w-0 cursor-pointer items-center gap-1 text-xs leading-snug text-sidebar-foreground/70">
<span
className="flex min-w-0 cursor-pointer items-center gap-1 text-xs leading-snug text-sidebar-foreground/70"
data-buzz-sidebar-secondary
>
<span
aria-hidden="true"
className="flex w-3.5 shrink-0 items-center justify-center text-2xs"
@@ -191,6 +194,7 @@ export function SidebarProfileCard({
"flex w-full min-w-0 items-center truncate rounded-sm text-left text-xs leading-snug text-sidebar-foreground/70 outline-hidden transition-opacity duration-150 focus:outline-none focus-visible:outline-none group-hover/profile-card:opacity-0",
profilePopoverOpen && "opacity-100",
)}
data-buzz-sidebar-secondary
data-testid="sidebar-profile-user-status"
onClick={(event) => {
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}
</div>

View File

@@ -294,7 +294,9 @@ export function ChannelMenuButton({
dmParticipants={dmParticipants}
presenceStatus={presenceStatus}
/>
<span className="min-w-0 flex-1 truncate">{resolvedLabel}</span>
<span className="min-w-0 flex-1 truncate" data-sidebar-row-label>
{resolvedLabel}
</span>
{ephemeralDisplay ? (
<EphemeralChannelBadge
display={ephemeralDisplay}
@@ -405,10 +407,11 @@ export function SidebarSection({
aria-controls={contentId}
aria-expanded={!isCollapsed}
className={SECTION_LABEL_BUTTON_CLASS}
data-testid={`${testId}-section-label`}
onClick={onToggleCollapsed}
type="button"
>
<span>{title}</span>
<span data-sidebar-section-title>{title}</span>
<span aria-hidden="true" className={SECTION_LABEL_CHEVRON_CLASS}>
<ChevronDown
className={cn(

View File

@@ -3,7 +3,11 @@
}
.buzz-sidebar-scrollbar:hover {
scrollbar-color: hsl(var(--sidebar-border) / 0.8) transparent;
scrollbar-color: var(
--buzz-sidebar-scrollbar-thumb,
hsl(var(--sidebar-border) / 0.8)
)
transparent;
}
.buzz-sidebar-scrollbar::-webkit-scrollbar {
@@ -23,7 +27,10 @@
}
.buzz-sidebar-scrollbar:hover::-webkit-scrollbar-thumb {
background-color: hsl(var(--sidebar-border) / 0.8);
background-color: var(
--buzz-sidebar-scrollbar-thumb,
hsl(var(--sidebar-border) / 0.8)
);
}
/*

View File

@@ -47,6 +47,12 @@
--sidebar-accent-foreground: 234 16.02% 35.49%;
--sidebar-border: 225 13.56% 76.86%;
--sidebar-ring: 225 13.56% 76.86%;
/* Exact Buzz palette tokens; components consume these semantically. */
--buzz-gradient-light-top: #e6e6b6;
--buzz-gradient-light-bottom: #c4d0da;
--buzz-gradient-dark-top: #4a4616;
--buzz-gradient-dark-bottom: #0a1423;
--buzz-content-dark: 0 0% 10.1960784314%;
}
.dark {
@@ -176,60 +182,144 @@
* lives in Tailwind's `utilities` layer, and unlayered declarations win over
* any layered ones, so this reliably overrides it without an `!important`.
*
* The gradient is applied to the shell wrapper plus every `bg-sidebar` canvas
* surface inside it (top chrome, sidebar column, and the inset margin behind
* the white content card). A few panes that do not literally carry
* `bg-sidebar` are marked explicitly (`data-buzz-glass-inset` /
* `data-buzz-glass-footer-wrap`) so they join the same visual layer. Two
* chrome surfaces rendered outside the wrapper also get precise selectors: the
* portaled mobile sidebar (`[data-sidebar="sidebar"][data-mobile="true"]`) and
* the community rail (`[data-testid="community-rail"]`).
* Scoping this way keeps the branding on the app chrome and off unrelated
* `bg-sidebar` consumers rendered in portals outside the shell (e.g. the
* persona catalog dialog). `background-attachment: fixed` anchors the gradient
* to the viewport so these separate surfaces read as one continuous gradient
* rather than each restarting the ramp. `background-color` is cleared so the
* grey `--sidebar-background` never shows through.
* The gradient is painted once on the full app surface. Child chrome canvases
* stay transparent so the sidebar, rail, top chrome, inset margin, and Settings
* all reveal the same continuous ramp. Keeping one non-fixed background avoids
* WKWebView's stale fixed-background raster when the app changes appearance
* without a page reload. The portaled mobile sidebar is the sole separate
* paint because it lives outside the app surface.
*
* Small transient `bg-sidebar` chips inside the shell that are NOT canvas
* surfaces (the drag-overlay pills) opt out via `[data-buzz-flat]` - a
* viewport-fixed gradient behind a small floating chip would show an arbitrary
* slice of the ramp.
* floating chip should retain its own fill rather than becoming transparent
* over the full-app gradient.
*
* Item hover/active states use `bg-sidebar-accent` / `bg-sidebar-active`
* (distinct tokens) and are intentionally left untouched.
*/
:root[data-buzz-sidebar] {
--buzz-gradient-top: #e6e6b6;
--buzz-gradient-bottom: #c4d0da;
--buzz-gradient-top: var(--buzz-gradient-light-top);
--buzz-gradient-bottom: var(--buzz-gradient-light-bottom);
--buzz-muted-foreground: rgb(0 0 0 / 40%);
--buzz-search-surface: rgb(0 0 0 / 4%);
--buzz-chrome-foreground: rgb(0 0 0 / 50%);
--buzz-channel-fg: inherit;
--buzz-dm-fg: inherit;
--buzz-nav-fg: inherit;
--buzz-hover-fill: #ffffff;
--buzz-hover-alpha: 48%;
--buzz-hover-surface: color-mix(
in srgb,
var(--buzz-hover-fill) var(--buzz-hover-alpha),
transparent
);
--buzz-active-fill: 0 0% 100%;
--buzz-hover-surface: rgb(0 0 0 / 4%);
--buzz-sidebar-scrollbar-thumb: var(--buzz-hover-surface);
--buzz-active-fill: 0 0% 0%;
--buzz-active-surface: rgb(0 0 0 / 7%);
--buzz-active-foreground: var(--foreground);
}
/*
* Dark variant (Buzz Dark). Reuses the GitHub Dark base palette; only the
* three Buzz tokens change, so every downstream rule (gradient fill, hover,
* search box, active pill) adapts automatically. The `.dark` class is set on
* the document root whenever a dark theme is active.
* Buzz color tokens change, so every downstream rule (gradient fill, hover,
* search box, muted labels, active pill) adapts automatically. The `.dark`
* class is set on the document root whenever a dark theme is active.
*/
:root[data-buzz-sidebar].dark {
--buzz-gradient-top: #2b2b18;
--buzz-gradient-bottom: #1b2530;
--buzz-hover-alpha: 9.6%;
--buzz-gradient-top: var(--buzz-gradient-dark-top);
--buzz-gradient-bottom: var(--buzz-gradient-dark-bottom);
--buzz-muted-foreground: rgb(255 255 255 / 40%);
--buzz-search-surface: rgb(255 255 255 / 4%);
--buzz-chrome-foreground: rgb(255 255 255 / 50%);
--buzz-hover-surface: rgb(255 255 255 / 4%);
--buzz-active-fill: 0 0% 100%;
--buzz-active-surface: color-mix(
in srgb,
hsl(var(--buzz-active-fill)) 16%,
transparent
);
--buzz-active-foreground: 0 0% 100%;
}
/* Keep the subtle content-card lift in Buzz Light only. */
:root[data-buzz-sidebar]:not(.dark) [data-buzz-content-surface] {
box-shadow:
-1px -1px 0 0 hsl(var(--sidebar-border) / 0.45),
0 0 4px 0 rgb(0 0 0 / 7%);
}
/* Let the content card's shadow feather into the surrounding gradient. */
:root[data-buzz-sidebar]:not(.dark) [data-buzz-shadow-viewport] {
overflow: visible;
}
/*
* Buzz Dark uses a warmer neutral for the inset main/settings content canvas.
* Override `--background` locally as well as the surface fill so descendant
* headers and panes using `bg-background` remain visually continuous.
*/
:root[data-buzz-sidebar].dark [data-buzz-content-surface] {
--background: var(--buzz-content-dark);
background-color: hsl(var(--background));
}
:root[data-buzz-sidebar] .buzz-huddle-app-surface {
background-color: transparent;
background-image: none;
isolation: isolate;
}
/*
* Keep both Buzz gradients painted and switch only which layer is visible.
* WKWebView can retain the old raster when `background-image` is replaced on
* a translucent surface. The center content already follows `.dark`, so using
* that same state for layer visibility keeps both surfaces in lockstep.
*/
:root[data-buzz-sidebar] .buzz-theme-gradient-layer-light {
background-image: linear-gradient(
to bottom,
var(--buzz-gradient-light-top),
var(--buzz-gradient-light-bottom)
);
background-size: 100vw 100vh;
background-repeat: no-repeat;
}
:root[data-buzz-sidebar] .buzz-theme-gradient-layer-dark {
background-image: linear-gradient(
to bottom,
var(--buzz-gradient-dark-top),
var(--buzz-gradient-dark-bottom)
);
background-size: 100vw 100vh;
background-repeat: no-repeat;
}
:root[data-buzz-sidebar]:not(.dark) .buzz-theme-gradient-layer-light,
:root[data-buzz-sidebar].dark .buzz-theme-gradient-layer-dark {
opacity: 1;
}
/* The mobile sidebar is portaled outside the app surface. */
:root[data-buzz-sidebar]:not(.dark)
[data-sidebar="sidebar"][data-mobile="true"].bg-sidebar {
background-color: transparent;
background-image: linear-gradient(
to bottom,
var(--buzz-gradient-light-top),
var(--buzz-gradient-light-bottom)
);
background-size: 100vw 100vh;
background-repeat: no-repeat;
}
:root[data-buzz-sidebar].dark
[data-sidebar="sidebar"][data-mobile="true"].bg-sidebar {
background-color: transparent;
background-image: linear-gradient(
to bottom,
var(--buzz-gradient-dark-top),
var(--buzz-gradient-dark-bottom)
);
background-size: 100vw 100vh;
background-repeat: no-repeat;
}
/* All in-shell chrome reveals the single gradient painted above. */
:root[data-buzz-sidebar] .group\/sidebar-wrapper,
:root[data-buzz-sidebar] [data-testid="app-sidebar"],
:root[data-buzz-sidebar] [data-buzz-glass-inset],
@@ -237,42 +327,13 @@
:root[data-buzz-sidebar]
.group\/sidebar-wrapper
.bg-sidebar:not([data-buzz-flat]),
:root[data-buzz-sidebar]
[data-sidebar="sidebar"][data-mobile="true"].bg-sidebar,
:root[data-buzz-sidebar] [data-testid="community-rail"].bg-sidebar {
background-color: transparent;
background-image: linear-gradient(
to bottom,
var(--buzz-gradient-top),
var(--buzz-gradient-bottom)
);
background-attachment: fixed;
background-size: 100vw 100vh;
background-repeat: no-repeat;
}
/*
* The pinned header (search + nav) and the footer (profile card) are painted
* with an opaque `--sidebar-background` fill plus a soft edge-fade pseudo-
* element (see `components.css`). Under GitHub Light that fill is the flat
* grey we are replacing, so it reads as an opaque panel floating over the
* gradient. Repaint both regions with the SAME viewport-fixed gradient so
* they line up seamlessly with the surrounding sidebar canvas, and drop the
* edge-fade (there is no longer a distinct surface to fade into).
*/
:root[data-buzz-sidebar] [data-testid="community-rail"].bg-sidebar,
:root[data-buzz-sidebar]
[data-testid="app-sidebar"]
[data-testid="sidebar-pinned-header"],
:root[data-buzz-sidebar] [data-testid="app-sidebar"] [data-sidebar="footer"] {
background-color: transparent;
background-image: linear-gradient(
to bottom,
var(--buzz-gradient-top),
var(--buzz-gradient-bottom)
);
background-attachment: fixed;
background-size: 100vw 100vh;
background-repeat: no-repeat;
background-image: none;
}
:root[data-buzz-sidebar]
@@ -288,12 +349,11 @@
* Active nav item. The accent system (`applyAccentColor` in ThemeProvider)
* sets `--sidebar-active` to the theme foreground as an INLINE style on
* :root, which renders the selected page as a solid black pill under GitHub
* Light. For the Buzz theme we want a white pill instead. Override the two
* active tokens on the sidebar element itself; element-local custom properties
* win over the inherited inline values from :root. Text flips to the theme
* foreground so it stays legible on the white pill; the derived
* `bg-sidebar-active-foreground/20` badge on active items follows
* automatically.
* Light. For the Buzz theme we want a subtle theme-specific tint instead.
* Override the two active tokens on the sidebar element itself; element-local
* custom properties win over the inherited inline values from :root. Text
* uses the theme foreground, and the derived
* `bg-sidebar-active-foreground/20` badge follows automatically.
*
* SCOPED to the app sidebar container, NOT :root - the `bg-sidebar-active` /
* `text-sidebar-active-foreground` tokens are also consumed by non-sidebar
@@ -319,36 +379,22 @@
}
/*
* Active nav pill: drop the drop shadow so the white tab sits flat on the
* gradient (Buzz-only; other themes keep `shadow-xs`). Higher specificity
* than the `data-[active=true]:shadow-xs` utility and unlayered, so it wins.
* Active nav pill: use a subtle 7% black tint in light mode and drop the drop
* shadow so the selection sits flat on the gradient (Buzz-only; other themes
* keep `shadow-xs`). Higher specificity than the active utilities and
* unlayered, so it wins.
*/
:root[data-buzz-sidebar] [data-testid="app-sidebar"] [data-active="true"],
:root[data-buzz-sidebar] [data-testid="settings-sidebar"] [data-active="true"] {
background-color: var(--buzz-active-surface);
box-shadow: none;
}
/*
* 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="settings-sidebar"]
[data-active="true"] {
background-color: color-mix(
in srgb,
hsl(var(--buzz-active-fill)) 16%,
transparent
);
}
/*
* Hover on non-active nav items uses a translucent white fill (see
* `--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, in the app nav and the settings nav.
* Hover on non-active nav items uses the theme-specific translucent neutral
* fill instead of the grey `--sidebar-accent`. Active items are excluded so
* they retain the active surface. Covers both top-level menu buttons and
* channel sub-buttons, in the app nav and the settings nav.
*/
:root[data-buzz-sidebar]
[data-testid="app-sidebar"]
@@ -366,9 +412,22 @@
}
/*
* Search box: match the same translucent white fill (see
* `--buzz-hover-surface`) instead of the grey `bg-sidebar-border/35` fill, at
* rest and on hover/focus.
* Row actions such as the DM close button are siblings of the menu button.
* Hovering them still activates the row's Tailwind `group-hover` treatment,
* so cover the whole menu item to keep that sibling-hover path on the same
* Buzz neutral surface instead of falling back to the legacy accent.
*/
:root[data-buzz-sidebar]
[data-testid="app-sidebar"]
:where([data-sidebar="menu-item"]:hover)
> [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;
}

View File

@@ -4,16 +4,22 @@ import { cn } from "@/shared/lib/cn";
export type ThemePreviewVars = Record<string, string>;
/**
* 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 = {

View File

@@ -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

View File

@@ -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) => (
<span
className={cn("grid min-w-0 overflow-hidden", className)}
data-sidebar="menu-label"
ref={ref}
{...props}
>
<span
aria-hidden="true"
className="invisible col-start-1 row-start-1 truncate font-semibold"
>
{children}
</span>
<span className="col-start-1 row-start-1 truncate">{children}</span>
</span>
));
SidebarMenuLabel.displayName = "SidebarMenuLabel";
export { SidebarMenuLabel };

View File

@@ -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)",

View File

@@ -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<string> {
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<string, unknown>,
) => Promise<unknown>;
};
};
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");
});

View File

@@ -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,
);