From 7b698ed9157fa1ca953d48bbf5ff5b57c61d67e9 Mon Sep 17 00:00:00 2001
From: Arjun Mahanti
Date: Thu, 20 Aug 2026 14:04:36 -0400
Subject: [PATCH 1/3] feat(desktop-settings): clean up copy and layout
Co-authored-by: Codex
Signed-off-by: Arjun Mahanti
---
.../agents/ui/AgentDefaultsEditor.tsx | 6 +-
.../ui/CommunityMembersSettingsCard.tsx | 2 +-
.../ui/CustomEmojiSettingsCard.tsx | 2 +-
.../ui/LocalArchiveSettingsCard.tsx | 2 +-
.../src/features/notifications/lib/sound.ts | 6 +-
.../src/features/settings/UpdateChecker.tsx | 2 +-
.../ui/AppearanceSettingsControls.tsx | 617 +++++++++++-------
.../features/settings/ui/BackupTestFlow.tsx | 2 +-
.../ui/ChannelTemplatesSettingsCard.tsx | 2 +-
.../settings/ui/CustomHarnessForm.tsx | 2 +-
.../settings/ui/EncryptedBackupCreator.tsx | 2 +-
.../settings/ui/HarnessCatalogDialog.tsx | 2 +-
.../src/features/settings/ui/HarnessRow.tsx | 2 +-
.../settings/ui/HarnessesSettingsPanel.tsx | 2 +-
.../ui/HostedCommunitiesSettingsCard.tsx | 3 +-
.../settings/ui/MobilePairingCard.tsx | 2 +-
.../settings/ui/ModerationQueueCard.tsx | 2 +-
.../settings/ui/NotificationSettingsCard.tsx | 27 +-
.../settings/ui/PrivateKeyBackupRow.tsx | 14 +-
.../settings/ui/ProfileSettingsCard.tsx | 27 +-
.../settings/ui/SendFeedbackDialog.tsx | 2 +-
.../settings/ui/SettingsActionButton.tsx | 23 +
.../features/settings/ui/SettingsPanels.tsx | 95 +--
.../settings/ui/SettingsSectionHeader.tsx | 15 +-
.../src/features/settings/ui/SettingsView.tsx | 2 +-
.../features/settings/ui/SignOutSection.tsx | 2 +-
.../src/features/settings/ui/SoundPicker.tsx | 152 ++++-
.../settings/ui/VoiceSettingsCard.tsx | 2 +-
desktop/src/shared/styles/globals/motion.css | 65 ++
desktop/src/shared/theme/ThemeProvider.tsx | 17 +-
desktop/src/shared/ui/switch.tsx | 54 +-
desktop/tests/e2e/appearance-previews.spec.ts | 150 +++--
.../tests/e2e/buzz-theme-screenshots.spec.ts | 141 ++--
.../global-agent-config-screenshots.spec.ts | 4 +
.../tests/e2e/observer-archive-policy.spec.ts | 41 ++
desktop/tests/e2e/profile.spec.ts | 88 +++
.../agent-defaults-after.png | Bin 0 -> 39331 bytes
.../agent-defaults-before.png | Bin 0 -> 39327 bytes
.../appearance-after.png | Bin 0 -> 88026 bytes
.../appearance-before.png | Bin 0 -> 98233 bytes
40 files changed, 1026 insertions(+), 553 deletions(-)
create mode 100644 desktop/src/features/settings/ui/SettingsActionButton.tsx
create mode 100644 docs/assets/screenshots/desktop-settings-copy-layout-cleanup/agent-defaults-after.png
create mode 100644 docs/assets/screenshots/desktop-settings-copy-layout-cleanup/agent-defaults-before.png
create mode 100644 docs/assets/screenshots/desktop-settings-copy-layout-cleanup/appearance-after.png
create mode 100644 docs/assets/screenshots/desktop-settings-copy-layout-cleanup/appearance-before.png
diff --git a/desktop/src/features/agents/ui/AgentDefaultsEditor.tsx b/desktop/src/features/agents/ui/AgentDefaultsEditor.tsx
index 69e8b3a5b43..b99b7b43d06 100644
--- a/desktop/src/features/agents/ui/AgentDefaultsEditor.tsx
+++ b/desktop/src/features/agents/ui/AgentDefaultsEditor.tsx
@@ -39,6 +39,7 @@ import {
} from "@/features/agents/ui/AgentConfigFields";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
+import { SettingsActionButton } from "@/features/settings/ui/SettingsActionButton";
type SaveState = "idle" | "saving" | "saved" | "error";
@@ -259,6 +260,7 @@ export function AgentDefaultsEditor({
const progressiveFieldsTransition = shouldReduceMotion
? { duration: 0 }
: PROGRESSIVE_FIELDS_TRANSITION;
+ const SaveButton = flatLayout ? SettingsActionButton : Button;
return (
@@ -337,7 +339,7 @@ export function AgentDefaultsEditor({
)}
{secondaryAction}
-
) : null}
Save defaults
-
+
)}
diff --git a/desktop/src/features/community-members/ui/CommunityMembersSettingsCard.tsx b/desktop/src/features/community-members/ui/CommunityMembersSettingsCard.tsx
index 06b19d61679..9a0f2d11974 100644
--- a/desktop/src/features/community-members/ui/CommunityMembersSettingsCard.tsx
+++ b/desktop/src/features/community-members/ui/CommunityMembersSettingsCard.tsx
@@ -20,7 +20,7 @@ import type {
UserProfileSummary,
} from "@/shared/api/types";
import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey";
-import { Button } from "@/shared/ui/button";
+import { SettingsActionButton as Button } from "@/features/settings/ui/SettingsActionButton";
import {
DropdownMenu,
DropdownMenuContent,
diff --git a/desktop/src/features/custom-emoji/ui/CustomEmojiSettingsCard.tsx b/desktop/src/features/custom-emoji/ui/CustomEmojiSettingsCard.tsx
index 37199297f2e..bfda700813c 100644
--- a/desktop/src/features/custom-emoji/ui/CustomEmojiSettingsCard.tsx
+++ b/desktop/src/features/custom-emoji/ui/CustomEmojiSettingsCard.tsx
@@ -14,7 +14,7 @@ import {
} from "@/shared/api/customEmoji";
import { pickAndUploadMedia } from "@/shared/api/tauri";
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
-import { Button } from "@/shared/ui/button";
+import { SettingsActionButton as Button } from "@/features/settings/ui/SettingsActionButton";
import { Input } from "@/shared/ui/input";
import { SettingsOptionGroup } from "@/features/settings/ui/SettingsOptionGroup";
import { SettingsSectionHeader } from "@/features/settings/ui/SettingsSectionHeader";
diff --git a/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.tsx b/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.tsx
index c03faa09481..65379cadd78 100644
--- a/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.tsx
+++ b/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.tsx
@@ -17,7 +17,7 @@ import {
} from "@/shared/constants/kinds";
import { useChannelsQuery } from "@/features/channels/hooks";
import { useIdentityQuery } from "@/shared/api/hooks";
-import { Button } from "@/shared/ui/button";
+import { SettingsActionButton as Button } from "@/features/settings/ui/SettingsActionButton";
import { Checkbox } from "@/shared/ui/checkbox";
import { Switch } from "@/shared/ui/switch";
import {
diff --git a/desktop/src/features/notifications/lib/sound.ts b/desktop/src/features/notifications/lib/sound.ts
index 1e9ccb3839a..b522ed82a34 100644
--- a/desktop/src/features/notifications/lib/sound.ts
+++ b/desktop/src/features/notifications/lib/sound.ts
@@ -137,10 +137,14 @@ export function shouldPlayNotificationSound(
const cache = new Map();
+export function createNotificationSound(name: SoundName): HTMLAudioElement {
+ return new Audio(`/sounds/${name}.mp3`);
+}
+
function getAudio(name: SoundName): HTMLAudioElement {
let audio = cache.get(name);
if (!audio) {
- audio = new Audio(`/sounds/${name}.mp3`);
+ audio = createNotificationSound(name);
cache.set(name, audio);
}
return audio;
diff --git a/desktop/src/features/settings/UpdateChecker.tsx b/desktop/src/features/settings/UpdateChecker.tsx
index cb1180994fa..563085fc156 100644
--- a/desktop/src/features/settings/UpdateChecker.tsx
+++ b/desktop/src/features/settings/UpdateChecker.tsx
@@ -1,6 +1,6 @@
import { openUrl } from "@tauri-apps/plugin-opener";
import { useUpdaterContext } from "./hooks/UpdaterProvider";
-import { Button } from "@/shared/ui/button";
+import { SettingsActionButton as Button } from "./ui/SettingsActionButton";
import {
SettingsOptionGroup,
SettingsOptionRow,
diff --git a/desktop/src/features/settings/ui/AppearanceSettingsControls.tsx b/desktop/src/features/settings/ui/AppearanceSettingsControls.tsx
index 003e2894ea2..ddd9ffdfeb9 100644
--- a/desktop/src/features/settings/ui/AppearanceSettingsControls.tsx
+++ b/desktop/src/features/settings/ui/AppearanceSettingsControls.tsx
@@ -1,7 +1,7 @@
import * as React from "react";
import type { ReactNode } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
-import { Eye } from "lucide-react";
+import { Hash, Inbox, MessageSquare, Search, X } from "lucide-react";
import {
setThreadViewMode,
useThreadViewMode,
@@ -9,7 +9,7 @@ import {
} from "@/features/channels/lib/threadViewModePreference";
import { useCommunities } from "@/features/communities/useCommunities";
import { AvatarFramingSlider } from "@/features/profile/ui/AnimatedAvatarControls";
-import { contrastColorForBackground } from "@/features/profile/ui/ProfileAvatarEditor.utils";
+import { cn } from "@/shared/lib/cn";
import {
setLinkPreviewStyle,
useLinkPreviewStyle,
@@ -160,45 +160,6 @@ function ConversationDensityPreviewMessage({
);
}
-function ConversationPreview() {
- return (
-
-
-
-
- Preview
-
-
-
- The revised conversation layout is ready to review.
-
-
-
- I added a longer message so you can compare line height and text
- spacing.
-
-
- The same rhythm carries through channels, threads, DMs, and Inbox.
-
-
-
-
-
- );
-}
-
/** App-wide type sizing and conversation-specific spacing controls. */
export function ConversationDisplaySettings() {
const density = useConversationDensity();
@@ -209,12 +170,6 @@ export function ConversationDisplaySettings() {
Font size
-
- Applies across conversations and interface text
-
-
+
Conversation density
-
- Spacing in conversations and Markdown content across Buzz
-
-
);
}
@@ -303,8 +254,127 @@ function SampleImageLightbox({
return {children}
;
}
-function LinkPreviewSample({ style }: { style: LinkPreviewStyle }) {
- const { isDark } = useTheme();
+const APPEARANCE_PREVIEW_EASE_OUT = [0.23, 1, 0.32, 1] as const;
+const APPEARANCE_PREVIEW_EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
+const APPEARANCE_PREVIEW_MORPH_TRANSITION = {
+ duration: 0.24,
+ ease: APPEARANCE_PREVIEW_EASE_IN_OUT,
+} as const;
+const APPEARANCE_PREVIEW_CROSSFADE_TRANSITION = {
+ duration: 0.18,
+ ease: APPEARANCE_PREVIEW_EASE_OUT,
+} as const;
+const APPEARANCE_PREVIEW_REDUCED_TRANSITION = {
+ duration: 0.12,
+ ease: "linear",
+} as const;
+const APPEARANCE_PREVIEW_INSTANT_TRANSITION = { duration: 0 } as const;
+const APPEARANCE_PREVIEW_BUTTON_MOTION =
+ "transition-[color,background-color,transform] duration-[var(--motion-duration-instant)] ease-[var(--motion-ease-out)] active:scale-[0.97] motion-reduce:transform-none motion-reduce:transition-colors";
+
+type AppearancePreviewMotionContext = {
+ direct: boolean;
+ modeChanged: boolean;
+ reduced: boolean;
+};
+
+function appearancePreviewTransition({
+ direct,
+ reduced,
+}: AppearancePreviewMotionContext) {
+ if (direct) return APPEARANCE_PREVIEW_INSTANT_TRANSITION;
+ if (reduced) return APPEARANCE_PREVIEW_REDUCED_TRANSITION;
+ return APPEARANCE_PREVIEW_MORPH_TRANSITION;
+}
+
+const APPEARANCE_PREVIEW_PANEL_VARIANTS = {
+ animate: (context: AppearancePreviewMotionContext) => ({
+ filter: "blur(0px)",
+ opacity: 1,
+ transform: "translateX(0)",
+ transition: appearancePreviewTransition(context),
+ }),
+ exit: (context: AppearancePreviewMotionContext) => ({
+ filter:
+ !context.direct && !context.reduced && context.modeChanged
+ ? "blur(2px)"
+ : "blur(0px)",
+ opacity: context.direct ? 1 : 0,
+ transform:
+ context.direct || context.reduced || context.modeChanged
+ ? "translateX(0)"
+ : "translateX(100%)",
+ transition: appearancePreviewTransition(context),
+ }),
+ initial: (context: AppearancePreviewMotionContext) => ({
+ filter:
+ !context.direct && !context.reduced && context.modeChanged
+ ? "blur(2px)"
+ : "blur(0px)",
+ opacity: context.direct ? 1 : 0,
+ transform:
+ context.direct || context.reduced || context.modeChanged
+ ? "translateX(0)"
+ : "translateX(100%)",
+ transition: appearancePreviewTransition(context),
+ }),
+} as const;
+
+const APPEARANCE_PREVIEW_CONTENT_VARIANTS = {
+ animate: (context: AppearancePreviewMotionContext) => ({
+ filter: "blur(0px)",
+ opacity: 1,
+ transition: context.direct
+ ? APPEARANCE_PREVIEW_INSTANT_TRANSITION
+ : context.reduced
+ ? APPEARANCE_PREVIEW_REDUCED_TRANSITION
+ : APPEARANCE_PREVIEW_CROSSFADE_TRANSITION,
+ }),
+ exit: (context: AppearancePreviewMotionContext) => ({
+ filter: context.direct || context.reduced ? "blur(0px)" : "blur(2px)",
+ opacity: context.direct ? 1 : 0,
+ transition: context.direct
+ ? APPEARANCE_PREVIEW_INSTANT_TRANSITION
+ : context.reduced
+ ? APPEARANCE_PREVIEW_REDUCED_TRANSITION
+ : APPEARANCE_PREVIEW_CROSSFADE_TRANSITION,
+ }),
+ initial: (context: AppearancePreviewMotionContext) => ({
+ filter: context.direct || context.reduced ? "blur(0px)" : "blur(2px)",
+ opacity: context.direct ? 1 : 0,
+ transition: context.direct
+ ? APPEARANCE_PREVIEW_INSTANT_TRANSITION
+ : context.reduced
+ ? APPEARANCE_PREVIEW_REDUCED_TRANSITION
+ : APPEARANCE_PREVIEW_CROSSFADE_TRANSITION,
+ }),
+} as const;
+
+export function AppearanceWorkspacePreview({
+ previewLinkStyle,
+ previewThreadMode,
+}: {
+ previewLinkStyle?: LinkPreviewStyle | null;
+ previewThreadMode?: ThreadViewMode | null;
+}) {
+ const savedLinkStyle = useLinkPreviewStyle();
+ const savedThreadMode = useThreadViewMode();
+ const { glassBackground, isDark, prominentActiveTab } = useTheme();
+ const shouldReduceMotion = useReducedMotion();
+ const [activeChannel, setActiveChannel] = React.useState<
+ "design" | "general"
+ >("design");
+ const [threadOpen, setThreadOpen] = React.useState(true);
+ const linkStyle = previewLinkStyle ?? savedLinkStyle;
+ const threadMode = previewThreadMode ?? savedThreadMode;
+ const directManipulationActive =
+ (previewLinkStyle !== null && previewLinkStyle !== undefined) ||
+ (previewThreadMode !== null && previewThreadMode !== undefined);
+ const previousThreadModeRef = React.useRef(threadMode);
+ const threadModeChanged = previousThreadModeRef.current !== threadMode;
+ React.useEffect(() => {
+ previousThreadModeRef.current = threadMode;
+ }, [threadMode]);
const preview = React.useMemo(
() => ({
...LINK_PREVIEW_SAMPLE_BASE,
@@ -312,57 +382,267 @@ function LinkPreviewSample({ style }: { style: LinkPreviewStyle }) {
}),
[isDark],
);
- return (
-
+
+ const navItemClass = (active: boolean) =>
+ cn(
+ "flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-xs",
+ APPEARANCE_PREVIEW_BUTTON_MOTION,
+ active
+ ? prominentActiveTab
+ ? "bg-primary text-primary-foreground"
+ : "bg-accent text-accent-foreground"
+ : "text-muted-foreground hover:bg-muted/70 hover:text-foreground",
+ );
+
+ const motionMode = directManipulationActive
+ ? "direct"
+ : shouldReduceMotion
+ ? "reduced"
+ : "animated";
+ const motionContext: AppearancePreviewMotionContext = {
+ direct: directManipulationActive,
+ modeChanged: threadModeChanged,
+ reduced: Boolean(shouldReduceMotion),
+ };
+
+ const threadPanel = (
+
+
+
+
Layout feedback
+
3 replies
+
+
setThreadOpen(false)}
+ type="button"
+ >
+
+
+
+
+
+ The revised conversation layout is ready to review.
+
+
+ This hierarchy feels much clearer.
+
+
+
+ Reply to thread…
+
+
+ );
+
+ const channelLayout = threadOpen && threadMode === "split" ? "split" : "full";
+ const channelContentKey = `${activeChannel}:${linkStyle}:${channelLayout}`;
+ const firstMessage =
+ activeChannel === "design"
+ ? "The revised conversation layout is ready to review."
+ : "The weekly project notes are ready for everyone.";
+ const secondMessage =
+ activeChannel === "design"
+ ? "I also updated how shared links appear in the conversation."
+ : "I linked the release notes so the team can catch up quickly.";
+
+ const channelPane = (
+
+
-
-
+ setThreadOpen(true)}
+ type="button"
+ >
+
+ {firstMessage}
+
+
+
+ {secondMessage}
+
+
+
+
+
+
+ Message #{activeChannel}
+
+
+ );
+
+ return (
+
+
+
Preview
-
-
-
+
+
+
+
+
+
+
+ Search
+
+
+ Inbox
+
+
+ Channels
+
+ setActiveChannel("design")}
+ type="button"
+ >
+ design
+
+ setActiveChannel("general")}
+ type="button"
+ >
+ general
+
+
+
+
+
+ {channelPane}
+
+
+ {threadOpen ? threadPanel : null}
+
);
}
-export function LinkPreviewStyleSetting() {
+export function LinkPreviewStyleSetting({
+ onPreviewChange,
+}: {
+ onPreviewChange?: (style: LinkPreviewStyle | null) => void;
+}) {
const style = useLinkPreviewStyle();
- const [previewStyle, setPreviewStyle] =
- React.useState
(null);
- const displayedStyle = previewStyle ?? style;
- const activeOption =
- LINK_PREVIEW_STYLE_OPTIONS.find(
- (option) => option.value === displayedStyle,
- ) ?? LINK_PREVIEW_STYLE_OPTIONS[0];
-
+ const handlePreviewChange = React.useCallback(
+ (nextStyle: LinkPreviewStyle | null) => {
+ onPreviewChange?.(nextStyle);
+ },
+ [onPreviewChange],
+ );
return (
Link previews
-
- {activeOption.description}
-
-
);
}
@@ -494,119 +773,22 @@ export function GlassBackgroundSetting() {
}
/** Compact thread preference row in the Appearance preferences card. */
-/**
- * Abstract diagram for the thread layout preview, in the same soft-block
- * style as the links sample: a rounded frame holding a channel surface and a
- * thread surface, with light skeleton bars. Inline SVG (not a data-URL image)
- * so fills reference theme tokens directly and follow light/dark and accent
- * changes automatically. Only the panel proportions change between modes.
- */
-function ThreadLayoutDiagram({ mode }: { mode: ThreadViewMode }) {
- const { isDark } = useTheme();
- const gradientId = React.useId();
- // Inline SVG resolves CSS variables, so the frame gradient references the
- // Buzz gradient tokens directly and follows theme.css automatically.
- const gradientTop = isDark
- ? "var(--buzz-gradient-dark-top, #4a4616)"
- : "var(--buzz-gradient-light-top, #e6e6b6)";
- const gradientBottom = isDark
- ? "var(--buzz-gradient-dark-bottom, #0a1423)"
- : "var(--buzz-gradient-light-bottom, #c4d0da)";
- const channelSurface = "hsl(var(--muted))";
- const threadSurface = "hsl(var(--background))";
- const channelOpacity = isDark ? 0.88 : 0.78;
- const threadOpacity = isDark ? 0.98 : 0.96;
- const bar = "hsl(var(--foreground) / 0.24)";
- const barSoft = "hsl(var(--foreground) / 0.14)";
-
- const isFocus = mode === "focus";
- // Inner content area: 10..230 x 10..122 (inside the frame padding).
- // Split: channel and thread share the area side by side with a gap.
- // Focus: the channel continues beneath the overlaid thread, leaving only
- // a narrow orientation sliver visible at the left edge.
- const gap = 6;
- const threadX = isFocus ? 42 : 124;
- const channelWidth = isFocus ? 64 : threadX - 10 - gap;
- const threadWidth = 230 - threadX;
-
- /** Two skeleton text bars, clipped to the panel they sit in. */
- const skeleton = (x: number, y: number, width: number) => (
- <>
-
-
- >
- );
-
- return (
-
-
-
-
-
-
-
- {/* Frame */}
-
- {/* Channel surface */}
-
- {channelWidth > 60 ? skeleton(22, 24, channelWidth - 24) : null}
- {/* Thread surface */}
-
- {skeleton(threadX + 12, 24, threadWidth - 24)}
-
- );
-}
-
-function ThreadLayoutPreview({ mode }: { mode: ThreadViewMode }) {
- return (
-
- );
-}
-
-export function ThreadLayoutSetting() {
+export function ThreadLayoutSetting({
+ onPreviewChange,
+}: {
+ onPreviewChange?: (mode: ThreadViewMode | null) => void;
+}) {
const threadViewMode = useThreadViewMode();
const [previewMode, setPreviewMode] = React.useState(
null,
);
+ const handlePreviewChange = React.useCallback(
+ (nextMode: ThreadViewMode | null) => {
+ setPreviewMode(nextMode);
+ onPreviewChange?.(nextMode);
+ },
+ [onPreviewChange],
+ );
const { communities } = useCommunities();
const showCommunityScope = communities.length > 1;
const displayedMode = previewMode ?? threadViewMode;
@@ -637,7 +819,7 @@ export function ThreadLayoutSetting() {
-
);
}
@@ -653,26 +834,18 @@ export function ThreadLayoutSetting() {
/** Accent swatches — shared by the animated and reduced-motion reveal paths. */
export function AccentPickerContent({
accentColor,
- isDark,
setAccentColor,
}: {
accentColor: string;
- isDark: boolean;
setAccentColor: (value: string) => void;
}) {
return (
-
+
Accent color
-
- Choose the highlight color used throughout Buzz.
-
@@ -682,17 +855,11 @@ export function AccentPickerContent({
const swatchColor = isNeutral
? "hsl(var(--foreground))"
: color.value;
- const selectionColor = isNeutral
- ? isDark
- ? "#000000"
- : "#FFFFFF"
- : contrastColorForBackground(color.value);
-
return (
setAccentColor(color.value)}
@@ -700,13 +867,13 @@ export function AccentPickerContent({
title={color.name}
type="button"
>
- {isSelected ? (
-
- ) : null}
+
);
})}
diff --git a/desktop/src/features/settings/ui/BackupTestFlow.tsx b/desktop/src/features/settings/ui/BackupTestFlow.tsx
index 65ef40a98a7..b1b8ea2416a 100644
--- a/desktop/src/features/settings/ui/BackupTestFlow.tsx
+++ b/desktop/src/features/settings/ui/BackupTestFlow.tsx
@@ -6,7 +6,7 @@ import {
verifyNcryptsecBackup,
type BackupVerification,
} from "@/shared/api/tauriIdentity";
-import { Button } from "@/shared/ui/button";
+import { SettingsActionButton as Button } from "./SettingsActionButton";
import { Input } from "@/shared/ui/input";
import { PubKey } from "@/shared/ui/PubKey";
import { Spinner } from "@/shared/ui/spinner";
diff --git a/desktop/src/features/settings/ui/ChannelTemplatesSettingsCard.tsx b/desktop/src/features/settings/ui/ChannelTemplatesSettingsCard.tsx
index 645a0356c29..036b229c79c 100644
--- a/desktop/src/features/settings/ui/ChannelTemplatesSettingsCard.tsx
+++ b/desktop/src/features/settings/ui/ChannelTemplatesSettingsCard.tsx
@@ -47,7 +47,7 @@ import {
AlertDialogTitle,
} from "@/shared/ui/alert-dialog";
import { Badge } from "@/shared/ui/badge";
-import { Button } from "@/shared/ui/button";
+import { SettingsActionButton as Button } from "./SettingsActionButton";
import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content";
import { Dialog } from "@/shared/ui/dialog";
import {
diff --git a/desktop/src/features/settings/ui/CustomHarnessForm.tsx b/desktop/src/features/settings/ui/CustomHarnessForm.tsx
index 52e79062650..13e762d5f8c 100644
--- a/desktop/src/features/settings/ui/CustomHarnessForm.tsx
+++ b/desktop/src/features/settings/ui/CustomHarnessForm.tsx
@@ -11,7 +11,7 @@ import {
PERSONA_LABEL_OPTIONAL_CLASS,
} from "@/features/agents/ui/agentConfigOptions";
import { cn } from "@/shared/lib/cn";
-import { Button } from "@/shared/ui/button";
+import { SettingsActionButton as Button } from "./SettingsActionButton";
import { Input } from "@/shared/ui/input";
import { Spinner } from "@/shared/ui/spinner";
diff --git a/desktop/src/features/settings/ui/EncryptedBackupCreator.tsx b/desktop/src/features/settings/ui/EncryptedBackupCreator.tsx
index fb20eb9c652..75c83b57b7e 100644
--- a/desktop/src/features/settings/ui/EncryptedBackupCreator.tsx
+++ b/desktop/src/features/settings/ui/EncryptedBackupCreator.tsx
@@ -3,7 +3,7 @@ import * as React from "react";
import { generateBackupPassphrase } from "@/shared/api/tauriIdentity";
import { useEncryptedBackup } from "@/features/settings/EncryptedBackupProvider";
-import { Button } from "@/shared/ui/button";
+import { SettingsActionButton as Button } from "./SettingsActionButton";
import {
Dialog,
DialogContent,
diff --git a/desktop/src/features/settings/ui/HarnessCatalogDialog.tsx b/desktop/src/features/settings/ui/HarnessCatalogDialog.tsx
index 8511d26d57d..717e2078bfb 100644
--- a/desktop/src/features/settings/ui/HarnessCatalogDialog.tsx
+++ b/desktop/src/features/settings/ui/HarnessCatalogDialog.tsx
@@ -24,7 +24,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/ui/alert-dialog";
-import { Button } from "@/shared/ui/button";
+import { SettingsActionButton as Button } from "./SettingsActionButton";
import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content";
import { Dialog } from "@/shared/ui/dialog";
import { Input } from "@/shared/ui/input";
diff --git a/desktop/src/features/settings/ui/HarnessRow.tsx b/desktop/src/features/settings/ui/HarnessRow.tsx
index 4e471967d40..cfc2d0a5052 100644
--- a/desktop/src/features/settings/ui/HarnessRow.tsx
+++ b/desktop/src/features/settings/ui/HarnessRow.tsx
@@ -15,7 +15,7 @@ import { RuntimeIcon } from "@/features/onboarding/ui/RuntimeIcon";
import type { AcpAuthMethod, AcpRuntimeCatalogEntry } from "@/shared/api/types";
import { getInstallErrorMessage } from "@/shared/lib/installError";
import { cn } from "@/shared/lib/cn";
-import { Button } from "@/shared/ui/button";
+import { SettingsActionButton as Button } from "./SettingsActionButton";
import {
AlertDialog,
AlertDialogAction,
diff --git a/desktop/src/features/settings/ui/HarnessesSettingsPanel.tsx b/desktop/src/features/settings/ui/HarnessesSettingsPanel.tsx
index e7254c5e05c..b6e2d592038 100644
--- a/desktop/src/features/settings/ui/HarnessesSettingsPanel.tsx
+++ b/desktop/src/features/settings/ui/HarnessesSettingsPanel.tsx
@@ -8,7 +8,7 @@ import {
} from "@/features/agents/hooks";
import type { AcpRuntimeCatalogEntry } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
-import { Button } from "@/shared/ui/button";
+import { SettingsActionButton as Button } from "./SettingsActionButton";
import { HarnessCatalogDialog } from "./HarnessCatalogDialog";
import { HarnessRow } from "./HarnessRow";
diff --git a/desktop/src/features/settings/ui/HostedCommunitiesSettingsCard.tsx b/desktop/src/features/settings/ui/HostedCommunitiesSettingsCard.tsx
index 74c9dcc9ecd..4d4d9f9ff87 100644
--- a/desktop/src/features/settings/ui/HostedCommunitiesSettingsCard.tsx
+++ b/desktop/src/features/settings/ui/HostedCommunitiesSettingsCard.tsx
@@ -42,7 +42,8 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/ui/alert-dialog";
-import { Button, buttonVariants } from "@/shared/ui/button";
+import { buttonVariants } from "@/shared/ui/button";
+import { SettingsActionButton as Button } from "./SettingsActionButton";
import {
Dialog,
DialogContent,
diff --git a/desktop/src/features/settings/ui/MobilePairingCard.tsx b/desktop/src/features/settings/ui/MobilePairingCard.tsx
index 3a25f2edfaf..dbce0314c2b 100644
--- a/desktop/src/features/settings/ui/MobilePairingCard.tsx
+++ b/desktop/src/features/settings/ui/MobilePairingCard.tsx
@@ -16,7 +16,7 @@ import {
startPairing,
} from "@/shared/api/tauri";
import { cn } from "@/shared/lib/cn";
-import { Button } from "@/shared/ui/button";
+import { SettingsActionButton as Button } from "./SettingsActionButton";
import { StyledQrCode } from "@/shared/ui/styled-qr-code";
import { SettingsOptionGroup, SettingsOptionRow } from "./SettingsOptionGroup";
import { SettingsSectionHeader } from "./SettingsSectionHeader";
diff --git a/desktop/src/features/settings/ui/ModerationQueueCard.tsx b/desktop/src/features/settings/ui/ModerationQueueCard.tsx
index de414bbf0d1..434a201caba 100644
--- a/desktop/src/features/settings/ui/ModerationQueueCard.tsx
+++ b/desktop/src/features/settings/ui/ModerationQueueCard.tsx
@@ -32,7 +32,7 @@ import {
} from "@/features/settings/lib/moderationQueue";
import { cn } from "@/shared/lib/cn";
import { truncatePubkey } from "@/shared/lib/pubkey";
-import { Button } from "@/shared/ui/button";
+import { SettingsActionButton as Button } from "./SettingsActionButton";
import {
DropdownMenu,
DropdownMenuContent,
diff --git a/desktop/src/features/settings/ui/NotificationSettingsCard.tsx b/desktop/src/features/settings/ui/NotificationSettingsCard.tsx
index dcab32f0440..1018bc4fc9a 100644
--- a/desktop/src/features/settings/ui/NotificationSettingsCard.tsx
+++ b/desktop/src/features/settings/ui/NotificationSettingsCard.tsx
@@ -15,7 +15,7 @@ import {
type SoundSlot,
} from "@/features/notifications/lib/sound";
import { cn } from "@/shared/lib/cn";
-import { Button } from "@/shared/ui/button";
+import { SettingsActionButton as Button } from "./SettingsActionButton";
import { Switch } from "@/shared/ui/switch";
import {
SettingsOptionGroup,
@@ -92,14 +92,6 @@ export function NotificationSettingsCard({
? "Requesting..."
: "Desktop alerts"}
-
- {notificationSettings.desktopEnabled
- ? "Native desktop alerts are enabled for the categories you have armed below."
- : "Request OS permission and surface new mentions or needs-action items outside the app."}
-
- Sound
+ Play sound for notifications
-
- Alert with a sound for the events below.
-
- Home badge
+ Badge application icon
-
- Show a Home badge for mentions and needs-action items in the
- sidebar.
-
Private key
{backupAvailable ? (
- void downloadBackup()}
type="button"
+ variant="secondary"
>
{availableUntil !== null ? (
) : null}
Download backup
-
+
) : null}
- void handleReveal()}
type="button"
@@ -159,7 +159,7 @@ export function PrivateKeyBackupRow() {
Reveal
>
)}
-
+
{isOpen ? (
diff --git a/desktop/src/features/settings/ui/ProfileSettingsCard.tsx b/desktop/src/features/settings/ui/ProfileSettingsCard.tsx
index 64af0f3410a..d257c54bcc4 100644
--- a/desktop/src/features/settings/ui/ProfileSettingsCard.tsx
+++ b/desktop/src/features/settings/ui/ProfileSettingsCard.tsx
@@ -23,6 +23,7 @@ import { Input } from "@/shared/ui/input";
import { Spinner } from "@/shared/ui/spinner";
import { Textarea } from "@/shared/ui/textarea";
import { PrivateKeyBackupRow } from "./PrivateKeyBackupRow";
+import { SettingsActionButton } from "./SettingsActionButton";
import {
SettingsOptionGroup,
SettingsOptionGroupList,
@@ -74,9 +75,8 @@ function IdentityRow({
{copyValue ? (
- {
await writeTextToClipboard(copyValue);
@@ -84,10 +84,11 @@ function IdentityRow({
}}
title={`Copy ${label}`}
type="button"
+ variant="secondary"
>
Copy
-
+
) : null}
);
@@ -111,23 +112,18 @@ function EditProfileMetadataButton({
const accessibleLabel = isEditing ? `Done editing ${label}` : `Edit ${label}`;
return (
-
{actionLabel}
-
+
);
}
@@ -484,10 +480,7 @@ export function ProfileSettingsCard({
ref={sectionRef}
>
-
+
{profileQuery.error instanceof Error ? (
@@ -760,7 +753,7 @@ export function ProfileSettingsCard({
(({ className, size = "sm", ...props }, ref) => (
+
+));
+
+SettingsActionButton.displayName = "SettingsActionButton";
diff --git a/desktop/src/features/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx
index 6b00fc8f74c..070c0b7a384 100644
--- a/desktop/src/features/settings/ui/SettingsPanels.tsx
+++ b/desktop/src/features/settings/ui/SettingsPanels.tsx
@@ -33,7 +33,9 @@ import { CustomEmojiSettingsCard } from "@/features/custom-emoji/ui/CustomEmojiS
import { LocalArchiveSettingsCard } from "@/features/local-archive/ui/LocalArchiveSettingsCard";
import { cn } from "@/shared/lib/cn";
import { useCommunities } from "@/features/communities/useCommunities";
+import type { ThreadViewMode } from "@/features/channels/lib/threadViewModePreference";
import { Badge } from "@/shared/ui/badge";
+import type { LinkPreviewStyle } from "@/shared/lib/linkPreviewStylePreference";
import { isBuzzTheme, useTheme } from "@/shared/theme/ThemeProvider";
import {
LIGHT_THEMES,
@@ -55,6 +57,7 @@ import {
import { appearanceCommunityLabel } from "../lib/appearanceScopeCopy";
import {
AccentPickerContent,
+ AppearanceWorkspacePreview,
ConversationDisplaySettings,
GlassBackgroundSetting,
LinkPreviewStyleSetting,
@@ -406,18 +409,6 @@ const APPEARANCE_MODE_OPTIONS = [
{ mode: "dark" as const, label: "Dark", Icon: Moon },
] as const;
-// Reveal/hide motion for the accent picker: a small translate + opacity fade.
-// The picker sits below the theme grid and reads as tucking up behind it, so
-// it enters from above (slides *down* into place when a non-Buzz theme reveals
-// it) and exits upward (slides up behind the grid when Buzz hides it). No
-// height/scale — height collapse clipped the swatches behind the grid's bottom
-// fade (the "white bar"). Snappier than the modal 0.2s since this is a small
-// settings control, sharing the modal/ProfileSettingsCard easing curve.
-const ACCENT_PICKER_TRANSITION = {
- duration: 0.16,
- ease: [0.23, 1, 0.32, 1] as const,
-};
-
function ThemeSettingsCard() {
const {
setTheme,
@@ -437,11 +428,7 @@ function ThemeSettingsCard() {
const showCommunityScope = communities.length > 1;
const communityLabel = appearanceCommunityLabel(activeCommunity?.name);
- // Buzz themes pin a neutral accent (GitHub black in light, white in dark),
- // so the accent picker is hidden while a Buzz theme is active. `themeName` is
- // the effective theme, so this also covers System mode resolving to Buzz.
const buzzThemeSelected = isBuzzTheme(themeName);
- const accentPickerHidden = buzzThemeSelected;
const shouldReduceMotion = useReducedMotion();
const previewVarsByTheme = useThemePreviewVars();
@@ -456,6 +443,10 @@ function ThemeSettingsCard() {
const [selectedMode, setSelectedMode] = useState(activeMode);
const [themeStyleExpanded, setThemeStyleExpanded] = useState(false);
+ const [previewLinkStyle, setPreviewLinkStyle] =
+ useState(null);
+ const [previewThreadMode, setPreviewThreadMode] =
+ useState(null);
const getVars = (name: SyntaxThemeName) =>
withAccentPreviewVars(
@@ -575,19 +566,6 @@ function ThemeSettingsCard() {
"linear-gradient(to bottom, hsl(var(--background)), hsl(var(--background) / 0))",
}}
/>
- {/* Bottom fade — hidden while the accent picker is visible so its
- near-white gradient (Buzz light) can't mask the swatches below it
- (the "white bar"). Kept only when the picker is hidden. */}
- {accentPickerHidden ? (
-
- ) : null}
{selectedMode === "system" &&
@@ -636,9 +614,10 @@ function ThemeSettingsCard() {
className="flex min-h-0 flex-1 flex-col overflow-y-auto"
data-testid="settings-theme"
>
-
+
@@ -669,12 +648,6 @@ function ThemeSettingsCard() {
Color mode
-
- Follow your system or choose a light or dark appearance.
-
Theme style
-
- Choose the colors used throughout Buzz.
-
)}
- {/* Accent color picker — hidden for Buzz themes (pinned neutral accent).
- Reveal/hide with the translate-up + opacity fade defined by
- ACCENT_PICKER_TRANSITION above. Reduced motion skips the transition
- and just renders/unrenders. */}
- {shouldReduceMotion ? (
- accentPickerHidden ? null : (
-
- )
- ) : (
-
- {accentPickerHidden ? null : (
-
-
-
- )}
-
- )}
+
{buzzThemeSelected ? : null}
@@ -791,8 +730,8 @@ function ThemeSettingsCard() {
title="Preferences"
>
-
-
+
+
diff --git a/desktop/src/features/settings/ui/SettingsSectionHeader.tsx b/desktop/src/features/settings/ui/SettingsSectionHeader.tsx
index f856a78dbd5..00f010f5093 100644
--- a/desktop/src/features/settings/ui/SettingsSectionHeader.tsx
+++ b/desktop/src/features/settings/ui/SettingsSectionHeader.tsx
@@ -1,6 +1,7 @@
import type { ReactNode } from "react";
import { PageHeader } from "@/shared/ui/PageHeader";
+import { cn } from "@/shared/lib/cn";
/**
* Page title for a Settings card. Thin wrapper over the shared {@link PageHeader}
@@ -8,21 +9,25 @@ import { PageHeader } from "@/shared/ui/PageHeader";
*/
export function SettingsSectionHeader({
action,
+ className,
description,
title,
}: {
action?: ReactNode;
- description: ReactNode;
+ className?: string;
+ description?: ReactNode;
title: ReactNode;
}) {
return (
- {description}
-
+ description ? (
+
+ {description}
+
+ ) : undefined
}
title={title}
/>
diff --git a/desktop/src/features/settings/ui/SettingsView.tsx b/desktop/src/features/settings/ui/SettingsView.tsx
index 2f9b2c36a1a..5fe6593b71b 100644
--- a/desktop/src/features/settings/ui/SettingsView.tsx
+++ b/desktop/src/features/settings/ui/SettingsView.tsx
@@ -339,7 +339,7 @@ export function SettingsView({
data-testid="settings-content-scroll"
>
{renderSettingsSection(section, {
diff --git a/desktop/src/features/settings/ui/SignOutSection.tsx b/desktop/src/features/settings/ui/SignOutSection.tsx
index 82df014f709..94cd88e0dcd 100644
--- a/desktop/src/features/settings/ui/SignOutSection.tsx
+++ b/desktop/src/features/settings/ui/SignOutSection.tsx
@@ -12,7 +12,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/ui/alert-dialog";
-import { Button } from "@/shared/ui/button";
+import { SettingsActionButton as Button } from "./SettingsActionButton";
import { Checkbox } from "@/shared/ui/checkbox";
import { Input } from "@/shared/ui/input";
import { Spinner } from "@/shared/ui/spinner";
diff --git a/desktop/src/features/settings/ui/SoundPicker.tsx b/desktop/src/features/settings/ui/SoundPicker.tsx
index 7f7694cfb77..942576016b4 100644
--- a/desktop/src/features/settings/ui/SoundPicker.tsx
+++ b/desktop/src/features/settings/ui/SoundPicker.tsx
@@ -1,13 +1,13 @@
-import { useRef, useState } from "react";
+import { useEffect, useRef, useState } from "react";
import { ChevronDown, Pause, Play } from "lucide-react";
import {
- playNotificationSound,
+ createNotificationSound,
SOUND_NAMES,
type SoundName,
} from "@/features/notifications/lib/sound";
import { cn } from "@/shared/lib/cn";
-import { Button } from "@/shared/ui/button";
+import { SettingsActionButton as Button } from "./SettingsActionButton";
import {
DropdownMenu,
DropdownMenuContent,
@@ -28,26 +28,54 @@ function sortedSounds(recommended: SoundName): SoundName[] {
function Waveform({
name,
className,
+ progress,
}: {
name: SoundName;
className?: string;
+ progress?: number;
}) {
const maskImage = `url(/sounds/${name}.svg)`;
+ const maskStyle = {
+ maskImage,
+ maskPosition: "center",
+ maskRepeat: "no-repeat",
+ maskSize: "contain",
+ WebkitMaskImage: maskImage,
+ WebkitMaskPosition: "center",
+ WebkitMaskRepeat: "no-repeat",
+ WebkitMaskSize: "contain",
+ } as const;
+
+ if (progress === undefined) {
+ return (
+
+ );
+ }
+
+ const clampedProgress = Math.max(0, Math.min(1, progress));
return (
+ className={cn("relative inline-block shrink-0", className)}
+ data-playback-progress={clampedProgress.toFixed(3)}
+ data-testid="sound-picker-waveform"
+ >
+
+
+
);
}
@@ -64,25 +92,91 @@ export function SoundPicker({
}) {
const items = sortedSounds(recommended);
const [isPlaying, setIsPlaying] = useState(false);
+ const [playbackProgress, setPlaybackProgress] = useState(0);
const audioRef = useRef
(null);
+ const animationFrameRef = useRef(null);
+
+ useEffect(() => {
+ return () => {
+ if (animationFrameRef.current !== null) {
+ cancelAnimationFrame(animationFrameRef.current);
+ }
+ audioRef.current?.pause();
+ audioRef.current = null;
+ };
+ }, []);
+
+ function stopPreview() {
+ if (animationFrameRef.current !== null) {
+ cancelAnimationFrame(animationFrameRef.current);
+ animationFrameRef.current = null;
+ }
+ const audio = audioRef.current;
+ audioRef.current = null;
+ audio?.pause();
+ if (audio) audio.currentTime = 0;
+ setIsPlaying(false);
+ setPlaybackProgress(0);
+ }
+
+ function handleChange(next: SoundName) {
+ stopPreview();
+ onChange(next);
+ }
function togglePreview() {
if (isPlaying) {
- audioRef.current?.pause();
- setIsPlaying(false);
+ stopPreview();
return;
}
- const audio = playNotificationSound(value);
- if (!audio) return;
+
+ const audio = createNotificationSound(value);
+ audio.preload = "auto";
audioRef.current = audio;
+ setPlaybackProgress(0);
setIsPlaying(true);
- const stop = () => setIsPlaying(false);
- audio.addEventListener("ended", stop, { once: true });
- audio.addEventListener("pause", stop, { once: true });
+
+ const updateProgress = () => {
+ if (audioRef.current !== audio) return;
+ const duration = audio.duration;
+ if (Number.isFinite(duration) && duration > 0) {
+ setPlaybackProgress(Math.min(1, audio.currentTime / duration));
+ }
+ if (audioRef.current === audio && !audio.ended) {
+ animationFrameRef.current = requestAnimationFrame(updateProgress);
+ }
+ };
+ const finish = () => {
+ if (audioRef.current !== audio) return;
+ if (animationFrameRef.current !== null) {
+ cancelAnimationFrame(animationFrameRef.current);
+ animationFrameRef.current = null;
+ }
+ audioRef.current = null;
+ setPlaybackProgress(1);
+ setIsPlaying(false);
+ };
+ const fail = () => {
+ if (audioRef.current !== audio) return;
+ audioRef.current = null;
+ setIsPlaying(false);
+ setPlaybackProgress(0);
+ };
+
+ audio.addEventListener("ended", finish, { once: true });
+ audio.addEventListener("error", fail, { once: true });
+ void audio.play().then(() => {
+ if (audioRef.current === audio) {
+ animationFrameRef.current = requestAnimationFrame(updateProgress);
+ }
+ }, fail);
}
return (
-
+
{value}
-
+ 0
+ ? playbackProgress
+ : undefined
+ }
+ />
@@ -104,7 +206,7 @@ export function SoundPicker({
className="max-h-80 min-w-72 overflow-y-auto"
>
onChange(next as SoundName)}
+ onValueChange={(next) => handleChange(next as SoundName)}
value={value}
>
{items.map((name) => (
diff --git a/desktop/src/features/settings/ui/VoiceSettingsCard.tsx b/desktop/src/features/settings/ui/VoiceSettingsCard.tsx
index 9eced5bd429..a0d29bc0654 100644
--- a/desktop/src/features/settings/ui/VoiceSettingsCard.tsx
+++ b/desktop/src/features/settings/ui/VoiceSettingsCard.tsx
@@ -3,7 +3,7 @@ import { ChevronDown, Play, Trash2, Upload, Volume2 } from "lucide-react";
import { invokeTauri } from "@/shared/api/tauri";
import { cn } from "@/shared/lib/cn";
-import { Button } from "@/shared/ui/button";
+import { SettingsActionButton as Button } from "./SettingsActionButton";
import {
AlertDialog,
AlertDialogAction,
diff --git a/desktop/src/shared/styles/globals/motion.css b/desktop/src/shared/styles/globals/motion.css
index 25288fb1a6f..0d152f8fb46 100644
--- a/desktop/src/shared/styles/globals/motion.css
+++ b/desktop/src/shared/styles/globals/motion.css
@@ -15,12 +15,77 @@
/* Easing: direct for feedback, confident deceleration for entrances. */
--motion-ease-standard: cubic-bezier(0.25, 1, 0.5, 1);
--motion-ease-arrival: cubic-bezier(0.16, 1, 0.3, 1);
+ --motion-ease-out: cubic-bezier(0.23, 1, 0.32, 1);
+ --motion-ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
/* Distances stay restrained so motion supports hierarchy without spectacle. */
--motion-distance-arrival: 0.75rem;
--motion-blur-arrival: 2px;
}
+/* Settings switches keep their track fixed while the thumb stretches inward.
+ Anchoring to the resting edge prevents the hover morph from kicking sideways. */
+.settings-switch-thumb {
+ clip-path: inset(0 0.5rem 0 0 round 999px);
+ transform: translateX(0);
+ transition:
+ clip-path var(--motion-duration-instant) var(--motion-ease-in-out),
+ transform var(--motion-duration-instant) var(--motion-ease-in-out);
+}
+
+.settings-switch-thumb[data-state="checked"] {
+ clip-path: inset(0 0 0 0.5rem round 999px);
+ transform: translateX(0.5rem);
+}
+
+@media (hover: hover) and (pointer: fine) {
+ .settings-switch:not([data-hover-stretch-suppressed]):hover
+ .settings-switch-thumb[data-state="unchecked"] {
+ clip-path: inset(0 round 999px);
+ }
+
+ .settings-switch:not([data-hover-stretch-suppressed]):hover
+ .settings-switch-thumb[data-state="checked"] {
+ clip-path: inset(0 round 999px);
+ }
+}
+
+/* Accent selection is a compact state cue, not an extra ring around the chip. */
+.accent-color-selection-dot {
+ opacity: 0;
+ transform: translate(-50%, -50%) scale(0.5);
+ transition:
+ opacity var(--motion-duration-instant) var(--motion-ease-out),
+ transform var(--motion-duration-instant) var(--motion-ease-out);
+}
+
+.accent-color-selection-dot[data-selected="true"] {
+ opacity: 1;
+ transform: translate(-50%, -50%) scale(1);
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .settings-switch-thumb {
+ transition: none;
+ }
+
+ .settings-switch:not([data-hover-stretch-suppressed]):hover
+ .settings-switch-thumb[data-state="unchecked"] {
+ clip-path: inset(0 0.5rem 0 0 round 999px);
+ }
+
+ .settings-switch:not([data-hover-stretch-suppressed]):hover
+ .settings-switch-thumb[data-state="checked"] {
+ clip-path: inset(0 0 0 0.5rem round 999px);
+ }
+
+ .accent-color-selection-dot,
+ .accent-color-selection-dot[data-selected="true"] {
+ transform: translate(-50%, -50%) scale(1);
+ transition: opacity var(--motion-duration-instant) linear;
+ }
+}
+
/* A newly-created conversational object settling into the timeline. */
.motion-enter-conversation {
animation: motion-enter-conversation var(--motion-duration-arrival)
diff --git a/desktop/src/shared/theme/ThemeProvider.tsx b/desktop/src/shared/theme/ThemeProvider.tsx
index 63fd3adb4de..5f0388d69ea 100644
--- a/desktop/src/shared/theme/ThemeProvider.tsx
+++ b/desktop/src/shared/theme/ThemeProvider.tsx
@@ -236,26 +236,17 @@ function applyAccentColor(value: string) {
root.style.setProperty("--sidebar-active-foreground", fgHsl);
}
-/**
- * The Buzz themes ship with a fixed neutral accent (the GitHub black/white
- * foreground) rather than a user-selectable accent color. When a Buzz theme is
- * active we force `NEUTRAL_ACCENT` regardless of the stored preference, and the
- * appearance panel hides the accent picker. The user's chosen accent is left
- * untouched in storage so it returns when they switch back to another theme.
- */
+/** Whether the active syntax theme uses Buzz's first-party visual treatment. */
export function isBuzzTheme(themeName: string): boolean {
return themeName === "buzz" || themeName === "buzz-dark";
}
-/**
- * Resolve the accent to actually apply for a theme: Buzz themes are pinned to
- * the neutral accent; every other theme uses the stored/selected accent.
- */
+/** Resolve the user-selected accent applied across every theme. */
function resolveEffectiveAccent(
- themeName: string,
+ _themeName: string,
accentColor: string,
): string {
- return isBuzzTheme(themeName) ? NEUTRAL_ACCENT : accentColor;
+ return accentColor;
}
/** Toggle the Buzz-specific gradient marker independently from glass. */
diff --git a/desktop/src/shared/ui/switch.tsx b/desktop/src/shared/ui/switch.tsx
index c22ae90316c..385059e085f 100644
--- a/desktop/src/shared/ui/switch.tsx
+++ b/desktop/src/shared/ui/switch.tsx
@@ -6,22 +6,44 @@ import { cn } from "@/shared/lib/cn";
const Switch = React.forwardRef<
React.ComponentRef,
React.ComponentPropsWithoutRef
->(({ className, ...props }, ref) => (
-
-
-
-));
+>(
+ (
+ { className, onPointerDown, onPointerEnter, onPointerLeave, ...props },
+ ref,
+ ) => {
+ const [suppressHoverStretch, setSuppressHoverStretch] =
+ React.useState(false);
+
+ return (
+ {
+ setSuppressHoverStretch(true);
+ onPointerDown?.(event);
+ }}
+ onPointerEnter={(event) => {
+ setSuppressHoverStretch(false);
+ onPointerEnter?.(event);
+ }}
+ onPointerLeave={(event) => {
+ setSuppressHoverStretch(false);
+ onPointerLeave?.(event);
+ }}
+ {...props}
+ ref={ref}
+ >
+
+
+ );
+ },
+);
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };
diff --git a/desktop/tests/e2e/appearance-previews.spec.ts b/desktop/tests/e2e/appearance-previews.spec.ts
index a03a51bd82b..56fe78b6e06 100644
--- a/desktop/tests/e2e/appearance-previews.spec.ts
+++ b/desktop/tests/e2e/appearance-previews.spec.ts
@@ -73,28 +73,36 @@ async function scrubTo(control: Locator, option: Locator) {
);
}
-test("appearance samples preview locally and commit only on selection", async ({
+test("workspace preview updates locally and commits only on selection", async ({
page,
}) => {
await openAppearance(page);
+ const workspace = page.getByTestId("appearance-workspace-preview-surface");
+ const linkSample = page.getByTestId("appearance-workspace-link-preview");
+ const preview = page.getByTestId("appearance-workspace-preview");
+ await expect(preview.getByText("Preview", { exact: true })).toBeVisible();
+ await expect(preview.getByText("Try the channel and thread")).toHaveCount(0);
+ await expect(preview.getByText("Settings", { exact: true })).toHaveCount(0);
+ await expect(workspace).toHaveCSS("border-radius", "12px");
+ await expect(workspace).toHaveCSS("box-shadow", "none");
+ await expect(workspace).toHaveAttribute("data-link-style", "compact");
+ await expect(workspace).toHaveAttribute("data-thread-mode", "split");
+ await expect(linkSample).toHaveAttribute("inert", "");
+ await expect(linkSample.locator("[data-link-preview-inline]")).toHaveCount(0);
+
const linkControl = page.getByTestId("link-preview-style-control");
const richOption = page.getByTestId("link-preview-style-rich");
- const linkSample = page.getByTestId("link-preview-sample");
- await expect(linkSample.locator("[data-link-preview-inline]")).toHaveCount(0);
- await expect(page.getByTestId("link-preview-sample-surface")).toHaveAttribute(
- "inert",
- "",
- );
- const sampleLink = linkSample.locator("a").first();
- await sampleLink.evaluate((element) => element.focus());
- await expect(sampleLink).not.toBeFocused();
await scrubTo(linkControl, richOption);
+ await expect(workspace).toHaveAttribute("data-link-style", "rich");
+ await expect(
+ page.getByTestId("appearance-workspace-preview-channel").last(),
+ ).toHaveAttribute("data-motion-mode", "direct");
await expect(linkSample.locator("[data-link-preview-inline]")).toBeVisible();
await expect(linkSample.getByText("Show less")).toHaveCount(0);
await expect(
page.getByText("Large previews with images and descriptions"),
- ).toBeVisible();
+ ).toHaveCount(0);
await expect
.poll(() =>
page.evaluate(
@@ -104,6 +112,7 @@ test("appearance samples preview locally and commit only on selection", async ({
)
.toBe("compact");
await page.evaluate(() => window.dispatchEvent(new Event("blur")));
+ await expect(workspace).toHaveAttribute("data-link-style", "compact");
await expect(linkSample.locator("[data-link-preview-inline]")).toHaveCount(0);
await expect(page.getByTestId("link-preview-style-compact")).toHaveAttribute(
"aria-pressed",
@@ -111,6 +120,7 @@ test("appearance samples preview locally and commit only on selection", async ({
);
await richOption.click();
+ await expect(workspace).toHaveAttribute("data-link-style", "rich");
await expect(linkSample.locator("[data-link-preview-inline]")).toBeVisible();
await expect
.poll(() =>
@@ -123,9 +133,15 @@ test("appearance samples preview locally and commit only on selection", async ({
const threadControl = page.getByTestId("thread-layout-control");
const focusOption = page.getByTestId("thread-layout-focus");
- await expect(page.getByTestId("thread-layout-diagram-split")).toBeVisible();
await scrubTo(threadControl, focusOption);
- await expect(page.getByTestId("thread-layout-diagram-focus")).toBeVisible();
+ await expect(workspace).toHaveAttribute("data-thread-mode", "focus");
+ const directThread = page
+ .getByTestId("appearance-workspace-preview-thread")
+ .last();
+ await expect(directThread).toHaveAttribute("data-motion-mode", "direct");
+ expect(await directThread.evaluate((element) => element.style.width)).toBe(
+ "",
+ );
await expect(page.getByText("Threads open over the channel")).toBeVisible();
await expect
.poll(() =>
@@ -136,10 +152,10 @@ test("appearance samples preview locally and commit only on selection", async ({
)
.toBe("split");
await page.evaluate(() => window.dispatchEvent(new Event("blur")));
- await expect(page.getByTestId("thread-layout-diagram-split")).toBeVisible();
+ await expect(workspace).toHaveAttribute("data-thread-mode", "split");
await focusOption.click();
- await expect(page.getByTestId("thread-layout-diagram-focus")).toBeVisible();
+ await expect(workspace).toHaveAttribute("data-thread-mode", "focus");
await expect
.poll(() =>
page.evaluate(
@@ -148,9 +164,64 @@ test("appearance samples preview locally and commit only on selection", async ({
),
)
.toBe("focus");
+
+ await page.getByTestId("appearance-preview-close-thread").last().click();
+ await expect(
+ page.getByTestId("appearance-workspace-preview-thread"),
+ ).toHaveCount(0);
+ await page.getByTestId("appearance-preview-open-thread").click();
+ await expect(
+ page.getByTestId("appearance-workspace-preview-thread"),
+ ).toBeVisible();
});
-test("appearance previews stay grouped and responsive", async ({ page }) => {
+test("appearance controls keep polish aligned with reduced motion", async ({
+ page,
+}) => {
+ await page.emulateMedia({ reducedMotion: "reduce" });
+ await openAppearance(page);
+
+ const thread = page.getByTestId("appearance-workspace-preview-thread");
+ await expect(thread).toHaveAttribute("data-motion-mode", "reduced");
+ await expect(thread).toHaveCSS("filter", "blur(0px)");
+
+ const accent = page.getByTestId("accent-color-blue");
+ await accent.hover();
+ await expect(accent).toHaveCSS("transform", "none");
+ await expect(accent).toHaveCSS("border-top-width", "0px");
+
+ const selectedAccentDot = page.getByTestId("accent-color-selection");
+ await expect(selectedAccentDot).toHaveCSS("width", "8px");
+ await expect(selectedAccentDot).toHaveCSS("height", "8px");
+ await expect(selectedAccentDot).toHaveCSS(
+ "background-color",
+ "rgb(255, 255, 255)",
+ );
+ await expect(selectedAccentDot).toHaveCSS("opacity", "1");
+
+ const accentRow = page.getByTestId("accent-color-row");
+ const accentLabel = accentRow.getByText("Accent color", { exact: true });
+ const accentOptions = page.getByTestId("accent-color-options");
+ const [labelBox, optionsBox] = await Promise.all([
+ accentLabel.boundingBox(),
+ accentOptions.boundingBox(),
+ ]);
+ if (!labelBox || !optionsBox) throw new Error("Accent geometry is missing");
+ expect(
+ Math.abs(
+ labelBox.y + labelBox.height / 2 - (optionsBox.y + optionsBox.height / 2),
+ ),
+ ).toBeLessThanOrEqual(1);
+
+ await expect(page.getByTestId("conversation-density-row")).toHaveCSS(
+ "border-top-width",
+ "1px",
+ );
+});
+
+test("workspace preview stays responsive with stacked controls", async ({
+ page,
+}) => {
await page.setViewportSize({ width: 840, height: 900 });
await openAppearance(page, {
linkStyle: "rich",
@@ -159,43 +230,44 @@ test("appearance previews stay grouped and responsive", async ({ page }) => {
});
const preferencesCard = page.getByTestId("appearance-preferences-card");
- const linkGroup = page.getByTestId("link-preview-style-group");
- const threadGroup = page.getByTestId("thread-layout-group");
+ const workspace = page.getByTestId("appearance-workspace-preview-surface");
const linkControl = page.getByTestId("link-preview-style-control");
const threadControl = page.getByTestId("thread-layout-control");
- await expect(linkGroup.getByText("Preview", { exact: true })).toBeVisible();
- await expect(threadGroup.getByText("Preview", { exact: true })).toBeVisible();
+ await expect(workspace).toBeVisible();
+ await expect(workspace).toHaveAttribute("data-link-style", "rich");
+ await expect(workspace).toHaveAttribute("data-thread-mode", "focus");
- const [cardBox, linkControlBox, threadControlBox] = await Promise.all([
- preferencesCard.boundingBox(),
- linkControl.boundingBox(),
- threadControl.boundingBox(),
- ]);
- if (!cardBox || !linkControlBox || !threadControlBox) {
+ const [cardBox, workspaceBox, linkControlBox, threadControlBox] =
+ await Promise.all([
+ preferencesCard.boundingBox(),
+ workspace.boundingBox(),
+ linkControl.boundingBox(),
+ threadControl.boundingBox(),
+ ]);
+ if (!cardBox || !workspaceBox || !linkControlBox || !threadControlBox) {
throw new Error("Responsive Appearance geometry is missing");
}
- expect(linkControlBox.width).toBeGreaterThan(cardBox.width - 40);
- expect(threadControlBox.width).toBeGreaterThan(cardBox.width - 40);
+ expect(workspaceBox.width).toBeLessThanOrEqual(640);
+ expect(linkControlBox.x + linkControlBox.width).toBeLessThanOrEqual(
+ cardBox.x + cardBox.width,
+ );
+ expect(threadControlBox.x + threadControlBox.width).toBeLessThanOrEqual(
+ cardBox.x + cardBox.width,
+ );
await waitForAnimations(page);
- await linkGroup.screenshot({
- path: `${SHOTS}/01-link-preview-rich-dark-narrow.png`,
- });
- await threadGroup.screenshot({
- path: `${SHOTS}/02-thread-focus-dark-narrow.png`,
+ await page.getByTestId("appearance-workspace-preview").screenshot({
+ path: `${SHOTS}/01-workspace-rich-focus-dark-narrow.png`,
});
});
-test("appearance previews render compact and split samples at wide width", async ({
+test("workspace preview renders compact and split at wide width", async ({
page,
}) => {
await openAppearance(page);
await waitForAnimations(page);
- await page.getByTestId("link-preview-style-group").screenshot({
- path: `${SHOTS}/03-link-preview-compact-light-wide.png`,
- });
- await page.getByTestId("thread-layout-group").screenshot({
- path: `${SHOTS}/04-thread-split-light-wide.png`,
+ await page.getByTestId("appearance-workspace-preview").screenshot({
+ path: `${SHOTS}/02-workspace-compact-split-light-wide.png`,
});
});
diff --git a/desktop/tests/e2e/buzz-theme-screenshots.spec.ts b/desktop/tests/e2e/buzz-theme-screenshots.spec.ts
index 408fa851d96..f0d5d3cbf79 100644
--- a/desktop/tests/e2e/buzz-theme-screenshots.spec.ts
+++ b/desktop/tests/e2e/buzz-theme-screenshots.spec.ts
@@ -661,21 +661,18 @@ test("app font size and conversation density apply independently", async ({
const defaultSize = page.getByTestId("font-size-default");
const larger = page.getByTestId("font-size-larger");
const fontSizeIndicator = page.getByTestId("font-size-control-indicator");
- const preview = page.getByTestId("conversation-preview");
- const previewSurface = page.getByTestId("conversation-preview-surface");
- const previewContent = page.getByTestId("conversation-preview-content");
- const previewChip = preview.getByText("Preview");
+ const preview = page.getByTestId("appearance-workspace-preview");
+ const previewSurface = page.getByTestId(
+ "appearance-workspace-preview-surface",
+ );
+ const previewContent = page.getByTestId(
+ "appearance-workspace-preview-messages",
+ );
const firstPreviewMessage = previewSurface.locator("article").first();
- const previewMessage = preview.getByText(
+ const previewMessage = previewContent.getByText(
"The revised conversation layout is ready to review.",
);
- const previewTimestamp = preview.getByText("9:41");
- const densityDescription = page
- .getByTestId("conversation-density-row")
- .locator("[data-settings-subcopy]");
- const fontSizeDescription = page
- .getByTestId("font-size-row")
- .locator("[data-settings-subcopy]");
+ const previewTimestamp = previewContent.getByText("9:41");
const readScale = () =>
root.evaluate((element) => {
const style = window.getComputedStyle(element);
@@ -743,16 +740,18 @@ test("app font size and conversation density apply independently", async ({
await expect(fontSizeControl).toHaveAccessibleName("Font size");
await expect(comfortable).toHaveText("Comfy");
await expect(defaultSize).toHaveText("Default");
- await expect(preview).toContainText("Preview");
- await expect(preview).not.toContainText("Message #design");
+ await expect(preview.getByText("Preview", { exact: true })).toBeVisible();
+ await expect(preview).toContainText("Message #design");
await expect(comfortable).toHaveAttribute("aria-pressed", "true");
await expect(defaultSize).toHaveAttribute("aria-pressed", "true");
- await expect(densityDescription).toHaveText(
- "Spacing in conversations and Markdown content across Buzz",
- );
- await expect(fontSizeDescription).toHaveText(
- "Applies across conversations and interface text",
- );
+ await expect(
+ page
+ .getByTestId("conversation-density-row")
+ .locator("[data-settings-subcopy]"),
+ ).toHaveCount(0);
+ await expect(
+ page.getByTestId("font-size-row").locator("[data-settings-subcopy]"),
+ ).toHaveCount(0);
await expect.poll(readScale).toEqual({
authorLineHeight: 16,
bodyGap: 0.125,
@@ -798,62 +797,20 @@ test("app font size and conversation density apply independently", async ({
await expect(densityIndicator).toHaveCSS("transition-property", /transform/);
await expect(fontSizeIndicator).toHaveCSS("transition-duration", "0.2s");
await expect(fontSizeIndicator).toHaveCSS("transition-property", /transform/);
- await expect
- .poll(async () => {
- const [previewBackground, labelBackground, controlBackground] =
- await Promise.all([
- previewSurface.evaluate(
- (element) => window.getComputedStyle(element).backgroundColor,
- ),
- previewChip.evaluate(
- (element) => window.getComputedStyle(element).backgroundColor,
- ),
- densityControl.evaluate(
- (element) => window.getComputedStyle(element).backgroundColor,
- ),
- ]);
- return {
- labelIsAnnotation: labelBackground !== controlBackground,
- previewBackground,
- };
- })
- .toEqual({
- labelIsAnnotation: true,
- previewBackground: "rgba(0, 0, 0, 0)",
- });
const previewSurfaceBox = await previewSurface.boundingBox();
- const previewChipBox = await previewChip.boundingBox();
+ const previewContentBox = await previewContent.boundingBox();
const firstPreviewMessageBox = await firstPreviewMessage.boundingBox();
expect(previewSurfaceBox).not.toBeNull();
- expect(previewChipBox).not.toBeNull();
+ expect(previewContentBox).not.toBeNull();
expect(firstPreviewMessageBox).not.toBeNull();
- if (!previewSurfaceBox || !previewChipBox || !firstPreviewMessageBox) {
+ if (!previewSurfaceBox || !previewContentBox || !firstPreviewMessageBox) {
throw new Error("Conversation preview geometry is missing");
}
- const previewChipRightInset =
- previewSurfaceBox.x +
- previewSurfaceBox.width -
- (previewChipBox.x + previewChipBox.width);
- expect(previewChipRightInset).toBeGreaterThanOrEqual(13);
- expect(previewChipRightInset).toBeLessThanOrEqual(15);
- const previewChipTopInset = previewChipBox.y - previewSurfaceBox.y;
- expect(previewChipTopInset).toBeGreaterThanOrEqual(13);
- expect(previewChipTopInset).toBeLessThanOrEqual(15);
- await expect(previewContent).toHaveCSS("padding-top", "16px");
- await expect(previewContent).toHaveCSS("padding-right", "16px");
- await expect(previewContent).toHaveCSS("padding-bottom", "16px");
- await expect(previewContent).toHaveCSS("padding-left", "16px");
- expect(firstPreviewMessageBox.x - previewSurfaceBox.x).toBeGreaterThanOrEqual(
- 15,
- );
- expect(firstPreviewMessageBox.x - previewSurfaceBox.x).toBeLessThanOrEqual(
- 17,
- );
- expect(firstPreviewMessageBox.y - previewSurfaceBox.y).toBeGreaterThanOrEqual(
- 15,
- );
- expect(firstPreviewMessageBox.y - previewSurfaceBox.y).toBeLessThanOrEqual(
- 17,
+ expect(previewSurfaceBox.height).toBe(360);
+ await expect(previewContent).toHaveCSS("padding-top", "4px");
+ await expect(previewContent).toHaveCSS("padding-left", "12px");
+ expect(firstPreviewMessageBox.x - previewContentBox.x).toBeGreaterThanOrEqual(
+ 0,
);
await densityIndicator.evaluate((element) => {
element.addEventListener(
@@ -1508,14 +1465,36 @@ test("settings content uses the same inset surface as the main app", async ({
});
});
-test("appearance hides accent picker under Buzz", async ({ page }) => {
+test("appearance applies accent colors under Buzz", async ({ page }) => {
await seedTheme(page, "buzz");
await installMockBridge(page);
const panel = await openAppearance(page, "light");
- // The accent picker is hidden while a Buzz theme is active. Its neutral
- // swatch testid must not be present.
- await expect(page.getByTestId("accent-color-neutral")).toHaveCount(0);
- await panel.screenshot({ path: `${SHOTS}/10-appearance-no-accent.png` });
+ await expect(page.getByTestId("accent-color-neutral")).toBeVisible();
+ const initialPrimary = await page
+ .locator("html")
+ .evaluate((element) =>
+ getComputedStyle(element).getPropertyValue("--primary").trim(),
+ );
+ await page.getByTestId("accent-color-pink").click();
+ await expect(page.getByTestId("accent-color-pink")).toHaveAttribute(
+ "aria-pressed",
+ "true",
+ );
+ await expect
+ .poll(() =>
+ page
+ .locator("html")
+ .evaluate((element) =>
+ getComputedStyle(element).getPropertyValue("--primary").trim(),
+ ),
+ )
+ .not.toBe(initialPrimary);
+ await expect
+ .poll(() =>
+ page.evaluate(() => window.localStorage.getItem("buzz-accent-color")),
+ )
+ .toBe("#ec4899");
+ await panel.screenshot({ path: `${SHOTS}/10-appearance-buzz-accent.png` });
});
test("glass background keeps the content panel solid", async ({ page }) => {
@@ -1740,11 +1719,7 @@ test("non-Buzz glass preserves the selected theme sidebar tint", async ({
expect(tint.actual).toBe(tint.expected);
});
-test("accent picker reveals/hides when toggling Buzz", async ({ page }) => {
- // Start on a non-Buzz theme so the accent picker is present, then select the
- // Buzz tile — the picker should animate out and unmount. Reselecting a
- // non-Buzz tile brings it back. Asserts the presence toggle (the motion
- // wrapper) works end to end.
+test("accent picker stays available when toggling Buzz", async ({ page }) => {
await seedTheme(page, "github-light");
await page.addInitScript(() => {
Object.defineProperty(navigator, "platform", {
@@ -1768,16 +1743,16 @@ test("accent picker reveals/hides when toggling Buzz", async ({ page }) => {
"glass-background-row",
]);
- // Switch to Buzz — picker should leave (allow the exit animation to settle).
+ // Accent selection remains available on first-party Buzz themes.
await page.getByTestId("theme-style-trigger").click();
await page.getByTestId("theme-option-buzz").click();
await expect(page.getByTestId("theme-style-trigger")).toHaveAttribute(
"aria-expanded",
"true",
);
- await expect(page.getByTestId("accent-color-neutral")).toHaveCount(0);
+ await expect(page.getByTestId("accent-color-neutral")).toBeVisible();
- // Back to a non-Buzz theme — picker returns.
+ // It also remains available after returning to a syntax theme.
await page.getByTestId("theme-option-github-light").click();
await expect(page.getByTestId("accent-color-neutral")).toBeVisible();
await expect(page.getByTestId("theme-style-trigger")).toHaveAttribute(
diff --git a/desktop/tests/e2e/global-agent-config-screenshots.spec.ts b/desktop/tests/e2e/global-agent-config-screenshots.spec.ts
index fef87bb8b20..83dfbac1828 100644
--- a/desktop/tests/e2e/global-agent-config-screenshots.spec.ts
+++ b/desktop/tests/e2e/global-agent-config-screenshots.spec.ts
@@ -192,6 +192,10 @@ test.describe("global agent config screenshots", () => {
await card.scrollIntoViewIfNeeded();
await waitForAnimations(page);
+ const saveButton = card.getByRole("button", { name: "Save defaults" });
+ await expect(saveButton).toHaveClass(/rounded-full/);
+ await expect(saveButton).toHaveClass(/!shadow-none/);
+
await card.screenshot({
path: `${SHOTS}/01-global-agent-config-card-populated.png`,
});
diff --git a/desktop/tests/e2e/observer-archive-policy.spec.ts b/desktop/tests/e2e/observer-archive-policy.spec.ts
index ff4bbaa1503..e5dda426adf 100644
--- a/desktop/tests/e2e/observer-archive-policy.spec.ts
+++ b/desktop/tests/e2e/observer-archive-policy.spec.ts
@@ -50,13 +50,54 @@ test.describe("observer archive policy — Settings toggle", () => {
const card = await openLocalArchiveSettings(page);
const toggle = card.getByTestId("local-archive-observer-toggle");
+ const thumb = toggle.locator('[data-slot="switch-thumb"]');
await expect(toggle).toBeVisible({ timeout: 5_000 });
await expect(toggle).toBeChecked();
+ await expect(thumb).toHaveCSS(
+ "clip-path",
+ "inset(0px 0px 0px 8px round 999px)",
+ );
+
+ const restingCheckedBox = await thumb.boundingBox();
+ await toggle.hover();
+ await expect(thumb).toHaveCSS("clip-path", "inset(0px round 999px)");
+ const stretchedCheckedBox = await thumb.boundingBox();
+ if (!restingCheckedBox || !stretchedCheckedBox) {
+ throw new Error("Observer switch thumb geometry is missing");
+ }
+ expect(
+ Math.abs(
+ restingCheckedBox.x +
+ restingCheckedBox.width -
+ (stretchedCheckedBox.x + stretchedCheckedBox.width),
+ ),
+ ).toBeLessThanOrEqual(0.5);
// OFF: removes kind 24200.
await toggle.click();
await expect(toggle).not.toBeChecked();
+ await page.mouse.move(0, 0);
+ await thumb.evaluate(async (element) => {
+ await Promise.all(
+ element.getAnimations().map((animation) => animation.finished),
+ );
+ });
+ await expect(thumb).toHaveCSS(
+ "clip-path",
+ "inset(0px 8px 0px 0px round 999px)",
+ );
+ const restingUncheckedBox = await thumb.boundingBox();
+ await toggle.hover();
+ await expect(thumb).toHaveCSS("clip-path", "inset(0px round 999px)");
+ const stretchedUncheckedBox = await thumb.boundingBox();
+ if (!restingUncheckedBox || !stretchedUncheckedBox) {
+ throw new Error("Observer switch thumb geometry is missing");
+ }
+ expect(
+ Math.abs(restingUncheckedBox.x - stretchedUncheckedBox.x),
+ ).toBeLessThanOrEqual(0.5);
+
// ON again: re-creates the row from empty.
await toggle.click();
await expect(toggle).toBeChecked();
diff --git a/desktop/tests/e2e/profile.spec.ts b/desktop/tests/e2e/profile.spec.ts
index 04411720acb..832d66472b9 100644
--- a/desktop/tests/e2e/profile.spec.ts
+++ b/desktop/tests/e2e/profile.spec.ts
@@ -2319,6 +2319,94 @@ test("notification settings drive the Inbox badge and desktop alerts", async ({
await expect.poll(getAppBadgeCount).toBe(baseline);
});
+test("notification settings simplify copy and show sound progress", async ({
+ page,
+}) => {
+ await page.goto("/");
+ await openSettings(page, "notifications");
+
+ const settings = page.getByTestId("settings-notifications");
+ await expect(
+ settings.getByText("Play sound for notifications", { exact: true }),
+ ).toBeVisible();
+ await expect(
+ settings.getByText("Badge application icon", { exact: true }),
+ ).toBeVisible();
+ await expect(
+ settings.getByText("Native desktop alerts are enabled", { exact: false }),
+ ).toHaveCount(0);
+ await expect(
+ settings.getByText("Alert with a sound for the events below."),
+ ).toHaveCount(0);
+ await expect(
+ settings.getByText("Show a Home badge", { exact: false }),
+ ).toHaveCount(0);
+
+ const viewAll = settings.getByTestId("notifications-toggle-coming-soon");
+ await expect(viewAll).toHaveClass(/!shadow-none/);
+
+ const badgeToggle = settings.getByTestId("notifications-home-badge-toggle");
+ const badgeThumb = badgeToggle.locator('[data-slot="switch-thumb"]');
+ const restingCapsuleWidth = await badgeToggle.evaluate(
+ (element) => element.getBoundingClientRect().width,
+ );
+ const restingThumbWidth = await badgeThumb.evaluate(
+ (element) => element.getBoundingClientRect().width,
+ );
+ const badgeStartsChecked = await badgeToggle.isChecked();
+ await expect(badgeThumb).toHaveCSS(
+ "clip-path",
+ badgeStartsChecked
+ ? "inset(0px 0px 0px 8px round 999px)"
+ : "inset(0px 8px 0px 0px round 999px)",
+ );
+ const restingThumbBox = await badgeThumb.boundingBox();
+ await badgeToggle.hover();
+ await expect
+ .poll(() =>
+ badgeToggle.evaluate((element) => element.getBoundingClientRect().width),
+ )
+ .toBe(restingCapsuleWidth);
+ await expect
+ .poll(() =>
+ badgeThumb.evaluate((element) => element.getBoundingClientRect().width),
+ )
+ .toBe(restingThumbWidth);
+ await expect(badgeThumb).toHaveCSS("clip-path", "inset(0px round 999px)");
+ const stretchedThumbBox = await badgeThumb.boundingBox();
+ if (!restingThumbBox || !stretchedThumbBox) {
+ throw new Error("Switch thumb geometry is missing");
+ }
+ const restingEdge = badgeStartsChecked
+ ? restingThumbBox.x + restingThumbBox.width
+ : restingThumbBox.x;
+ const stretchedEdge = badgeStartsChecked
+ ? stretchedThumbBox.x + stretchedThumbBox.width
+ : stretchedThumbBox.x;
+ expect(Math.abs(restingEdge - stretchedEdge)).toBeLessThanOrEqual(0.5);
+ await badgeToggle.click();
+ await expect
+ .poll(() =>
+ badgeThumb.evaluate((element) => element.getBoundingClientRect().width),
+ )
+ .toBe(restingThumbWidth);
+
+ const soundPicker = settings.getByTestId("sound-picker").first();
+ await soundPicker.getByRole("button", { name: /^Preview / }).click();
+ const waveform = soundPicker.getByTestId("sound-picker-waveform");
+ await expect(waveform).toBeVisible();
+ await expect
+ .poll(async () =>
+ Number.parseFloat(
+ (await waveform.getAttribute("data-playback-progress")) ?? "0",
+ ),
+ )
+ .toBeGreaterThan(0.9);
+ await expect(
+ soundPicker.getByRole("button", { name: /^Preview / }),
+ ).toBeVisible({ timeout: 3_000 });
+});
+
test("desktop notification clicks open the matching forum thread", async ({
page,
}) => {
diff --git a/docs/assets/screenshots/desktop-settings-copy-layout-cleanup/agent-defaults-after.png b/docs/assets/screenshots/desktop-settings-copy-layout-cleanup/agent-defaults-after.png
new file mode 100644
index 0000000000000000000000000000000000000000..d44b7176c98cbd9f2226e1b83de71ba08dd8d83a
GIT binary patch
literal 39331
zcmd43XH?VO+ck)K3rbb0fPf0pn^dI*>AeQ&Aiaj(J1QW(cLAxP*U+1YfPkUb(0d2z
z9p-Sq^PhR1dEXE3nlCe1%cX?mm*nJJ=h}N;dmF5zAc=!XhKYuTh9fN{rhh^7W=AjP3H5JBR?I9`Y{E3;8kF|!OGy4-W|L5)Ny@~v<
zeonaPMYpcsb|*8QmESvz?!WAiwXH2$S!-#X+leSsqJIXi``bka9sG54j}#jX?K{=)
zkKl*vc}~7ZSXcxdoutnm!5r62j~Ky?gAvt<>iPX!s@+(n;AkwZ5ps1EO3a{B>%v0&
z*#{kc2>)OO*$bY&E(gcftMVb}h^eU9Kj^@Ect4FeK83D7T~b1V$7)F9&=`+WNXYT#
z=hw{7y@^ak29@3zco6rCKhx9GvttR}brg*!XBb!02peu3->ajI-f`Q}BDLyDgfX}{
zhr(se`d$l!-VckI{s}qx*2YSQaX7#0UWR~oLN^U1W%`E?A9_jH>?{Vu2E*awjjCpS
z0bjmwfTwCLAd@+R278WEYnxiVC^*(LBo)(pj4KP(7>jm2jxUhu*BA=lgb4m}Miowk
zYuBvU+m%^y*p><>lp7s$3KpSV1MEC@ne*~e_NMgMggM^pEiEhS?oE4;NM_UtWz%f)RJ|uv$yjb^gOm94?Y({3#A&V`A@eYE?3J)e%Gt32O&gE
zjD(NC*X=iAp^3pnh*?XrJ2o~JfJKzk%1fDEQDMKWJLlz9Lsv8Tp;&2Qt^WsBVyT#f
zgmjJy?VD5vtB%F9z~e0$lS-V_TAz!n`AY?H%8<^OCtbaT@$;^RXPe
zOc7q2Ss~wj{23QC!87w&9}@lpwRhy))iwhF~F
zeu(N_2~A*)3<)t@u50>H+0=QUY4daMK_726$)j{t%jCDqYM8A$Rc&lgQ3<~ICu=DG)|u}w{v>{l98l;2k`)!iG(^xiiVr+)RSAdFQr
z%WW%xWqnqLs8@C75BNytIVR{3@0(9IEE+E$CU~Q5TvvYwjd(t{`kv+z&p4R$4zT0I
zv8bjjh6EDO;LlO@_|IJ8mSbh+=Q(Ir%UQPO`o4Rv5dAZt0OuVaUndI1SdH`d_&U0d
zc;%^v0A-onh6;2%&y0m}WK15pq?l3v@apP(DTr!^^=pTr1QRiFeF!nb)lGYsrXz_G
zzG|%7?lccLAp9gy#_XeMr}$Gorxb5<$fUj77oI+a`t__--e!DjzoCnln!HCqK;Yun
zV}7il8ZxB+U>c$Qn?aMIq+UlM#(h{PGz31bZJC$XEtANsTqRMBm8c@rz?cRRZq=tx6#9GY3H+hl4Y?ZaZW)|
z@Ynlt*ZbVTq@WHeUtG-ZxR$qA?U|@B{VVSH6z?P6hztR@lImh%WOpJP8&QjXix1NW
z#G`C$^4plfY5|XfI3C-JxZn|D`O(8&=
z2>~^}@*%(TV^@+PvCmy=bKDQeHXB(xu(dp+vN?%o_Vc41;vcU
zo$0E`-Rtv1GdHn1%T84FH^sDfjaL0amL<7oO-?J8pFNx#oi}m}bk8Xcm3`Tr_lJg_
zA6|PpgcZg!3A$fcpSE0wla0VT*KFrnOq^Io{N!#M8e17P%75%Mp5_ecKjbP2=wEB~
zS*mcoegELWXFMvQL8*tai065;CKso>*BC0vBd8vajSw~W=g(0!7DP-T;UQsJI+AD)
z(ELnEg;-N8Thz