diff --git a/desktop/src/features/agents/ui/AgentDefaultsEditor.tsx b/desktop/src/features/agents/ui/AgentDefaultsEditor.tsx index 69e8b3a5b4..b99b7b43d0 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} - +
)} diff --git a/desktop/src/features/community-members/ui/CommunityMembersSettingsCard.tsx b/desktop/src/features/community-members/ui/CommunityMembersSettingsCard.tsx index 06b19d6167..9a0f2d1197 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 37199297f2..bfda700813 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 c03faa0948..65379cadd7 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 1e9ccb3839..b522ed82a3 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 cb1180994f..563085fc15 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 003e2894ea..718db6274c 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 ( -
- -
- ); -} - /** 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,140 @@ 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; + React.useEffect(() => { + if (previewThreadMode !== null && previewThreadMode !== undefined) { + setThreadOpen(true); + } else if (previewLinkStyle !== null && previewLinkStyle !== undefined) { + setThreadOpen(false); + } + }, [previewLinkStyle, previewThreadMode]); + const previousThreadOpenRef = React.useRef(threadOpen); + const threadVisibilityChanged = previousThreadOpenRef.current !== threadOpen; + React.useEffect(() => { + previousThreadOpenRef.current = threadOpen; + }, [threadOpen]); + const directManipulationActive = + !threadVisibilityChanged && + ((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 +395,276 @@ 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

+
+ +
+
+ + 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 = ( + +
+
+ + + {activeChannel} + +
+ {!threadOpen ? ( + + ) : null} +
+
+ Message #{activeChannel} +
+
+ ); + + return ( +
+
+

Preview - -

- +

+
+
+ + +
+ + {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 ( -
+
{ + if (!event.currentTarget.contains(event.relatedTarget)) { + handlePreviewChange(null); + } + }} + onFocusCapture={() => handlePreviewChange(style)} + onPointerDownCapture={() => handlePreviewChange(style)} + >

Link previews

-

- {activeOption.description} -

-
); } @@ -494,119 +795,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 ( - - ); -} - -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; @@ -615,7 +819,16 @@ export function ThreadLayoutSetting() { THREAD_VIEW_MODE_OPTIONS[0]; return ( -
+
{ + if (!event.currentTarget.contains(event.relatedTarget)) { + handlePreviewChange(null); + } + }} + onFocusCapture={() => handlePreviewChange(threadViewMode)} + onPointerDownCapture={() => handlePreviewChange(threadViewMode)} + >

@@ -637,7 +850,7 @@ export function ThreadLayoutSetting() { -

); } @@ -653,26 +865,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 +886,11 @@ export function AccentPickerContent({ const swatchColor = isNeutral ? "hsl(var(--foreground))" : color.value; - const selectionColor = isNeutral - ? isDark - ? "#000000" - : "#FFFFFF" - : contrastColorForBackground(color.value); - return ( ); })} diff --git a/desktop/src/features/settings/ui/BackupTestFlow.tsx b/desktop/src/features/settings/ui/BackupTestFlow.tsx index 65ef40a98a..b1b8ea2416 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 645a0356c2..036b229c79 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 52e7906265..13e762d5f8 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 fb20eb9c65..75c83b57b7 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 229941ec50..f8cdd990ed 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 4e471967d4..cfc2d0a505 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 2ce769e646..92ffad7bbd 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 74c9dcc9ec..4d4d9f9ff8 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 3a25f2edfa..dbce0314c2 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 de414bbf0d..434a201cab 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 dcab32f044..1018bc4fc9 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 ? ( - + ) : null} - +
{isOpen ? ( diff --git a/desktop/src/features/settings/ui/ProfileSettingsCard.tsx b/desktop/src/features/settings/ui/ProfileSettingsCard.tsx index 64af0f3410..d257c54bcc 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 ? ( - + ) : null}
); @@ -111,23 +112,18 @@ function EditProfileMetadataButton({ const accessibleLabel = isEditing ? `Done editing ${label}` : `Edit ${label}`; return ( - + ); } @@ -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) => ( + @@ -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 9eced5bd42..a0d29bc065 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 25288fb1a6..0d152f8fb4 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 63fd3adb4d..5f0388d69e 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 c22ae90316..385059e085 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 a03a51bd82..e24b8c2ee7 100644 --- a/desktop/tests/e2e/appearance-previews.spec.ts +++ b/desktop/tests/e2e/appearance-previews.spec.ts @@ -73,28 +73,42 @@ 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( + page.getByTestId("appearance-workspace-preview-thread"), + ).toHaveCount(0); + await expect( + page.getByTestId("appearance-preview-open-thread"), + ).toBeVisible(); + 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 +118,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 +126,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 +139,18 @@ 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( + page.getByTestId("appearance-workspace-preview-thread").last(), + ).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 +161,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 +173,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 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("appearance previews stay grouped and responsive", async ({ page }) => { +test("workspace preview stays responsive with stacked controls", async ({ + page, +}) => { await page.setViewportSize({ width: 840, height: 900 }); await openAppearance(page, { linkStyle: "rich", @@ -159,43 +239,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 408fa851d9..f0d5d3cbf7 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 fef87bb8b2..83dfbac182 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 ff4bbaa150..e5dda426ad 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 04411720ac..832d66472b 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 0000000000..d44b7176c9 Binary files /dev/null and b/docs/assets/screenshots/desktop-settings-copy-layout-cleanup/agent-defaults-after.png differ diff --git a/docs/assets/screenshots/desktop-settings-copy-layout-cleanup/agent-defaults-before.png b/docs/assets/screenshots/desktop-settings-copy-layout-cleanup/agent-defaults-before.png new file mode 100644 index 0000000000..9e8d8b17f0 Binary files /dev/null and b/docs/assets/screenshots/desktop-settings-copy-layout-cleanup/agent-defaults-before.png differ diff --git a/docs/assets/screenshots/desktop-settings-copy-layout-cleanup/appearance-after.png b/docs/assets/screenshots/desktop-settings-copy-layout-cleanup/appearance-after.png new file mode 100644 index 0000000000..cdd0cf8f0b Binary files /dev/null and b/docs/assets/screenshots/desktop-settings-copy-layout-cleanup/appearance-after.png differ diff --git a/docs/assets/screenshots/desktop-settings-copy-layout-cleanup/appearance-before.png b/docs/assets/screenshots/desktop-settings-copy-layout-cleanup/appearance-before.png new file mode 100644 index 0000000000..159e331a9a Binary files /dev/null and b/docs/assets/screenshots/desktop-settings-copy-layout-cleanup/appearance-before.png differ