mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
Polish Buzz theme and sidebar (#1971)
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
"dragDropEnabled": false,
|
||||
"trafficLightPosition": {
|
||||
"x": 16,
|
||||
"y": 24
|
||||
"y": 25
|
||||
},
|
||||
"backgroundThrottling": "disabled",
|
||||
"minWidth": 800,
|
||||
|
||||
@@ -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
desktop/src/app/BuzzThemeSurfaces.tsx
Normal file
31
desktop/src/app/BuzzThemeSurfaces.tsx
Normal 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}`}
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
31
desktop/src/shared/ui/sidebar-menu-label.tsx
Normal file
31
desktop/src/shared/ui/sidebar-menu-label.tsx
Normal 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 };
|
||||
@@ -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)",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user