Polish Buzz theme and sidebar (#1971)

This commit is contained in:
klopez4212
2026-07-17 11:40:05 +00:00
committed by GitHub
parent 85fc64835e
commit 2121fd0452
18 changed files with 1351 additions and 446 deletions
+5 -2
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
+31
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>
);
}
@@ -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}`}
+220 -198
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>
@@ -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>
);
}
@@ -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}
@@ -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>
@@ -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(
@@ -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)
);
}
/*
+316 -117
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;
}
+11 -5
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 = {
+65 -6
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
@@ -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 };