From d4d3555d6c3e124d6361424ec8d93d677e01eb32 Mon Sep 17 00:00:00 2001 From: morgmart <98432065+morgmart@users.noreply.github.com> Date: Wed, 12 Aug 2026 23:16:52 -0700 Subject: [PATCH] Refine appearance preferences: 13/14/15 type ramp, link and thread layout previews - Font size ramp is now 13/14/15px (Smaller/Default/Larger) so Default matches production's 14px standard text instead of bumping everyone up - Links and Thread layout switch from dropdowns to the same segmented control as Font size and Conversation density, with scrub-to-preview - SettingsSegmentedControl derives its columns from the option count (was hardcoded 3); legend moved off the grid so it can't skew layout - New live previews: a sample link card (Buzz-gradient SVG thumbnail baked from theme tokens) and a thread layout diagram (inline SVG on theme tokens) that follow the displayed/scrubbed option - Each setting row + its preview grouped as one container so the shared divider no longer separates a preview from its controls - Preview tag restyled as a quiet uppercase annotation, not a control - previewLinkPreviewStyle/previewThreadViewMode override hooks added, matching the existing previewFontSize pattern Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com> --- .../channels/lib/threadViewModePreference.ts | 13 +- .../ui/AppearanceSettingsControls.tsx | 373 ++++++++++++------ .../settings/ui/SettingsSegmentedControl.tsx | 54 +-- desktop/src/shared/lib/fontSizePreference.ts | 6 +- .../shared/lib/linkPreviewStylePreference.ts | 11 +- 5 files changed, 307 insertions(+), 150 deletions(-) diff --git a/desktop/src/features/channels/lib/threadViewModePreference.ts b/desktop/src/features/channels/lib/threadViewModePreference.ts index 684b73b11..efda042da 100644 --- a/desktop/src/features/channels/lib/threadViewModePreference.ts +++ b/desktop/src/features/channels/lib/threadViewModePreference.ts @@ -23,6 +23,7 @@ const DEFAULT_THREAD_VIEW_MODE: ThreadViewMode = "split"; const listeners = new Set<() => void>(); let threadViewMode = readStoredThreadViewMode(); +let previewOverride: ThreadViewMode | null = null; function parseThreadViewMode(value: string | null | undefined): ThreadViewMode { return value === "focus" || value === "split" @@ -46,7 +47,7 @@ function subscribe(listener: () => void): () => void { } function getSnapshot(): ThreadViewMode { - return threadViewMode; + return previewOverride ?? threadViewMode; } function getServerSnapshot(): ThreadViewMode { @@ -60,6 +61,7 @@ export function getThreadViewMode(): ThreadViewMode { /** Update the thread layout preference and notify all subscribed components. */ export function setThreadViewMode(mode: ThreadViewMode): void { + previewOverride = null; threadViewMode = mode; try { @@ -73,6 +75,15 @@ export function setThreadViewMode(mode: ThreadViewMode): void { } } +/** Temporarily apply a layout without changing the saved preference. */ +export function previewThreadViewMode(mode: ThreadViewMode | null): void { + if (previewOverride === mode) return; + previewOverride = mode; + for (const listener of listeners) { + listener(); + } +} + /** How threads should open in a channel: as a focus drawer or a split pane. */ export function useThreadViewMode(): ThreadViewMode { return React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); diff --git a/desktop/src/features/settings/ui/AppearanceSettingsControls.tsx b/desktop/src/features/settings/ui/AppearanceSettingsControls.tsx index 0f171c5b5..3fbc8fbb5 100644 --- a/desktop/src/features/settings/ui/AppearanceSettingsControls.tsx +++ b/desktop/src/features/settings/ui/AppearanceSettingsControls.tsx @@ -1,7 +1,8 @@ +import * as React from "react"; import type { ReactNode } from "react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; -import { ChevronDown } from "lucide-react"; import { + previewThreadViewMode, setThreadViewMode, useThreadViewMode, type ThreadViewMode, @@ -10,10 +11,14 @@ import { useCommunities } from "@/features/communities/useCommunities"; import { AvatarFramingSlider } from "@/features/profile/ui/AnimatedAvatarControls"; import { contrastColorForBackground } from "@/features/profile/ui/ProfileAvatarEditor.utils"; import { + previewLinkPreviewStyle, setLinkPreviewStyle, useLinkPreviewStyle, type LinkPreviewStyle, } from "@/shared/lib/linkPreviewStylePreference"; +import type { ResolvedLinkPreview } from "@/shared/lib/useResolvedLinkPreviews"; +import { LinkPreviewAttachment } from "@/shared/ui/link-preview-attachment"; +import type { LinkPreviewImageLightboxProps } from "@/shared/ui/rich-link-preview-attachment"; import { previewConversationDensity, setConversationDensity, @@ -34,14 +39,7 @@ import { NEUTRAL_ACCENT, useTheme, } from "@/shared/theme/ThemeProvider"; -import { Button } from "@/shared/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuTrigger, -} from "@/shared/ui/dropdown-menu"; + import { Switch } from "@/shared/ui/switch"; import { SettingsOptionRow } from "./SettingsOptionGroup"; import { SettingsSegmentedControl } from "./SettingsSegmentedControl"; @@ -170,7 +168,7 @@ function ConversationPreview() { className="relative overflow-hidden rounded-xl border border-border/65 bg-transparent" data-testid="conversation-preview-surface" > - + Preview
@@ -206,7 +204,7 @@ export function ConversationDisplaySettings() { const fontSize = useFontSize(); return ( - <> +

Font size

@@ -250,7 +248,84 @@ export function ConversationDisplaySettings() { /> - +
+ ); +} + +/** + * Static sample used by the settings preview card. The thumbnail is an inline + * SVG data URL so the preview needs no network fetch or native image pipeline. + */ +const LINK_PREVIEW_SAMPLE_BASE: Omit = { + kind: "generic-link", + href: "https://example.com/product-updates", + provider: "example.com", + title: "Product updates — a fresh look at conversations", + typeLabel: "link", + description: + "Highlights from this release: refreshed conversation layout, quicker link handling, and readability improvements.", + imageState: "image", + imageDomain: "example.com", +}; + +/** + * Build the sample thumbnail as an SVG data URL from the Buzz gradient + * tokens. Data-URL images cannot resolve CSS variables, so the token values + * are read from the live stylesheet and baked in per render — if the Buzz + * gradient ever changes in `theme.css`, this preview follows automatically. + */ +function buzzGradientSampleImage(isDark: boolean): string { + const styles = globalThis.document + ? getComputedStyle(document.documentElement) + : null; + const readToken = (token: string, fallback: string): string => + styles?.getPropertyValue(token).trim() || fallback; + const top = isDark + ? readToken("--buzz-gradient-dark-top", "#4a4616") + : readToken("--buzz-gradient-light-top", "#e6e6b6"); + const bottom = isDark + ? readToken("--buzz-gradient-dark-bottom", "#0a1423") + : readToken("--buzz-gradient-light-bottom", "#c4d0da"); + const shape = isDark ? "#ffffff" : "#fcfcf9"; + const svg = ``; + return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`; +} + +/** Lightbox stand-in for the settings sample — renders the image inert. */ +function SampleImageLightbox({ + children, + className, +}: LinkPreviewImageLightboxProps) { + return
{children}
; +} + +function LinkPreviewSample() { + const { isDark } = useTheme(); + const preview = React.useMemo( + () => ({ + ...LINK_PREVIEW_SAMPLE_BASE, + imageDataUrl: buzzGradientSampleImage(isDark), + }), + [isDark], + ); + return ( +
+ +
); } @@ -261,58 +336,30 @@ export function LinkPreviewStyleSetting() { LINK_PREVIEW_STYLE_OPTIONS[0]; return ( - -
-

Links

-

- {activeOption.description} -

-
- - - - - - - setLinkPreviewStyle(next as LinkPreviewStyle) - } - value={style} - > - {LINK_PREVIEW_STYLE_OPTIONS.map((option) => ( - - - {option.label} - - {option.description} - - - - ))} - - - -
+ {activeOption.description} +

+
+ + + +
); } @@ -432,6 +479,118 @@ 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 shape = isDark ? "#ffffff" : "#fcfcf9"; + const channel = shape; + const channelOpacity = isDark ? 0.3 : 0.45; + const thread = shape; + const threadOpacity = isDark ? 0.85 : 0.92; + const bar = "hsl(var(--muted-foreground) / 0.18)"; + const barSoft = "hsl(var(--muted-foreground) / 0.11)"; + + 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 thread surface dominates, leaving a narrow channel sliver. + const gap = 6; + const threadX = isFocus ? 42 : 124; + const channelWidth = 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() { + const mode = useThreadViewMode(); + return ( +
+ +
+ ); +} + export function ThreadLayoutSetting() { const threadViewMode = useThreadViewMode(); const { communities } = useCommunities(); @@ -442,64 +601,38 @@ export function ThreadLayoutSetting() { ) ?? THREAD_VIEW_MODE_OPTIONS[0]; return ( - -
-

- Thread layout - {showCommunityScope ? ( - - {" "} - (all communities) - - ) : null} -

-

- {activeOption.description} -

-
- - - - - - setThreadViewMode(next as ThreadViewMode)} - value={threadViewMode} - > - {THREAD_VIEW_MODE_OPTIONS.map((option) => ( - - - {option.label} - - {option.description} - - - - ))} - - - -
+ {activeOption.description} +

+ + + + + ); } diff --git a/desktop/src/features/settings/ui/SettingsSegmentedControl.tsx b/desktop/src/features/settings/ui/SettingsSegmentedControl.tsx index 097f3cba3..5bb13e5f5 100644 --- a/desktop/src/features/settings/ui/SettingsSegmentedControl.tsx +++ b/desktop/src/features/settings/ui/SettingsSegmentedControl.tsx @@ -133,7 +133,7 @@ export function SettingsSegmentedControl({ return (
({ width: `calc((100% - 0.25rem) / ${options.length})`, }} /> - {options.map(({ value: optionValue, label, Icon }) => ( - - ))} + {/* Legends escape grid/flex layout on a fieldset, so the columns live + on an inner wrapper the legend is not part of. */} +
+ {options.map(({ value: optionValue, label, Icon }) => ( + + ))} +
); } diff --git a/desktop/src/shared/lib/fontSizePreference.ts b/desktop/src/shared/lib/fontSizePreference.ts index 22b0d1644..172d7da45 100644 --- a/desktop/src/shared/lib/fontSizePreference.ts +++ b/desktop/src/shared/lib/fontSizePreference.ts @@ -11,9 +11,9 @@ export const DEFAULT_FONT_SIZE: FontSize = "default"; * prevents a text preference from also resizing rem-based layout geometry. */ const TYPE_REM_SIZE_PX: Record = { - smaller: 14 / 0.875, - default: 15 / 0.875, - larger: 16 / 0.875, + smaller: 13 / 0.875, + default: 14 / 0.875, + larger: 15 / 0.875, }; const TYPE_REM_PROPERTY = "--buzz-type-rem"; diff --git a/desktop/src/shared/lib/linkPreviewStylePreference.ts b/desktop/src/shared/lib/linkPreviewStylePreference.ts index 3a3c5d789..374f0cd74 100644 --- a/desktop/src/shared/lib/linkPreviewStylePreference.ts +++ b/desktop/src/shared/lib/linkPreviewStylePreference.ts @@ -9,6 +9,7 @@ export const DEFAULT_LINK_PREVIEW_STYLE: LinkPreviewStyle = "compact"; const listeners = new Set<() => void>(); let linkPreviewStyle = readStoredLinkPreviewStyle(); +let previewOverride: LinkPreviewStyle | null = null; export function parseLinkPreviewStyle( value: string | null | undefined, @@ -34,10 +35,11 @@ function subscribe(listener: () => void): () => void { } export function getLinkPreviewStyle(): LinkPreviewStyle { - return linkPreviewStyle; + return previewOverride ?? linkPreviewStyle; } export function setLinkPreviewStyle(style: LinkPreviewStyle): void { + previewOverride = null; linkPreviewStyle = style; try { globalThis.localStorage?.setItem(LINK_PREVIEW_STYLE_STORAGE_KEY, style); @@ -47,6 +49,13 @@ export function setLinkPreviewStyle(style: LinkPreviewStyle): void { for (const listener of listeners) listener(); } +/** Temporarily apply a style without changing the saved preference. */ +export function previewLinkPreviewStyle(style: LinkPreviewStyle | null): void { + if (previewOverride === style) return; + previewOverride = style; + for (const listener of listeners) listener(); +} + export function useLinkPreviewStyle(): LinkPreviewStyle { return React.useSyncExternalStore( subscribe,