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>
This commit is contained in:
morgmart
2026-08-12 23:16:52 -07:00
parent d914d31f73
commit d4d3555d6c
5 changed files with 307 additions and 150 deletions
@@ -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);
@@ -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"
>
<span className="absolute right-3 top-3 inline-flex rounded-full border border-border/65 bg-muted/45 px-2.5 py-1 text-xs font-medium text-muted-foreground">
<span className="absolute right-3.5 top-3 text-2xs font-medium uppercase tracking-wider text-muted-foreground/55">
Preview
</span>
<div className="p-4" data-testid="conversation-preview-content">
@@ -206,7 +204,7 @@ export function ConversationDisplaySettings() {
const fontSize = useFontSize();
return (
<>
<div data-testid="conversation-display-group">
<SettingsOptionRow data-testid="font-size-row">
<div className="min-w-0">
<p className="text-sm font-medium">Font size</p>
@@ -250,7 +248,84 @@ export function ConversationDisplaySettings() {
/>
</SettingsOptionRow>
<ConversationPreview />
</>
</div>
);
}
/**
* 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<ResolvedLinkPreview, "imageDataUrl"> = {
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 = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 382 200"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${top}"/><stop offset="1" stop-color="${bottom}"/></linearGradient></defs><rect width="382" height="200" fill="url(#g)"/><rect x="76" y="64" width="72" height="72" rx="22" fill="${shape}" opacity="0.75"/><rect x="168" y="76" width="96" height="18" rx="9" fill="${shape}" opacity="0.55"/><rect x="168" y="106" width="138" height="18" rx="9" fill="${shape}" opacity="0.4"/></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 <div className={className}>{children}</div>;
}
function LinkPreviewSample() {
const { isDark } = useTheme();
const preview = React.useMemo<ResolvedLinkPreview>(
() => ({
...LINK_PREVIEW_SAMPLE_BASE,
imageDataUrl: buzzGradientSampleImage(isDark),
}),
[isDark],
);
return (
<div className="px-4 py-3" data-testid="link-preview-sample">
<div
aria-hidden="true"
className="relative overflow-hidden rounded-xl border border-border/65 bg-transparent"
data-testid="link-preview-sample-surface"
>
<span className="absolute right-3.5 top-3 text-2xs font-medium uppercase tracking-wider text-muted-foreground/55">
Preview
</span>
<div className="p-4 pr-24">
<LinkPreviewAttachment
ImageLightbox={SampleImageLightbox}
preview={preview}
/>
</div>
</div>
</div>
);
}
@@ -261,58 +336,30 @@ export function LinkPreviewStyleSetting() {
LINK_PREVIEW_STYLE_OPTIONS[0];
return (
<SettingsOptionRow>
<div className="min-w-0">
<p className="text-sm font-medium">Links</p>
<p
className="text-sm font-normal text-muted-foreground/70"
data-settings-subcopy
>
{activeOption.description}
</p>
</div>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
className="h-7 min-w-28 justify-between gap-1.5 rounded-md border border-border/50 bg-muted/45 px-2.5 text-xs font-medium text-foreground shadow-none hover:bg-muted/70"
data-testid="link-preview-style-trigger"
size="sm"
type="button"
variant="ghost"
<div data-testid="link-preview-style-group">
<SettingsOptionRow>
<div className="min-w-0">
<p className="text-sm font-medium">Links</p>
<p
className="text-sm font-normal text-muted-foreground/70"
data-settings-subcopy
>
<span className="truncate">{activeOption.label}</span>
<ChevronDown className="h-4 w-4 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="min-w-72 rounded-md"
data-testid="link-preview-style-menu"
>
<DropdownMenuRadioGroup
onValueChange={(next) =>
setLinkPreviewStyle(next as LinkPreviewStyle)
}
value={style}
>
{LINK_PREVIEW_STYLE_OPTIONS.map((option) => (
<DropdownMenuRadioItem
data-testid={`link-preview-style-${option.value}`}
key={option.value}
value={option.value}
>
<span className="flex min-w-0 flex-col">
<span className="font-medium">{option.label}</span>
<span className="text-2xs text-muted-foreground">
{option.description}
</span>
</span>
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</SettingsOptionRow>
{activeOption.description}
</p>
</div>
<SettingsSegmentedControl
className="w-48"
legend="Links"
onPreviewChange={previewLinkPreviewStyle}
onValueChange={setLinkPreviewStyle}
optionTestIdPrefix="link-preview-style"
options={LINK_PREVIEW_STYLE_OPTIONS}
testId="link-preview-style-control"
value={style}
/>
</SettingsOptionRow>
<LinkPreviewSample />
</div>
);
}
@@ -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) => (
<>
<rect fill={bar} height={7} rx={3.5} width={width * 0.62} x={x} y={y} />
<rect
fill={barSoft}
height={7}
rx={3.5}
width={width * 0.86}
x={x}
y={y + 13}
/>
</>
);
return (
<svg
aria-hidden="true"
className="block w-full max-w-60"
data-testid={`thread-layout-diagram-${mode}`}
role="img"
viewBox="0 0 240 132"
>
<defs>
<linearGradient id={gradientId} x1="0" x2="0" y1="0" y2="1">
<stop offset="0" stopColor={gradientTop} />
<stop offset="1" stopColor={gradientBottom} />
</linearGradient>
</defs>
{/* Frame */}
<rect fill={`url(#${gradientId})`} height={132} rx={18} width={240} />
{/* Channel surface */}
<rect
fill={channel}
height={112}
opacity={channelOpacity}
rx={10}
width={channelWidth}
x={10}
y={10}
/>
{channelWidth > 60 ? skeleton(22, 24, channelWidth - 24) : null}
{/* Thread surface */}
<rect
fill={thread}
height={112}
opacity={threadOpacity}
rx={10}
width={threadWidth}
x={threadX}
y={10}
/>
{skeleton(threadX + 12, 24, threadWidth - 24)}
</svg>
);
}
function ThreadLayoutPreview() {
const mode = useThreadViewMode();
return (
<div className="px-4 py-3" data-testid="thread-layout-preview">
<div
aria-hidden="true"
className="relative overflow-hidden rounded-xl border border-border/65 bg-transparent"
data-testid="thread-layout-preview-surface"
>
<span className="absolute right-3.5 top-3 text-2xs font-medium uppercase tracking-wider text-muted-foreground/55">
Preview
</span>
<div className="p-4 pr-24">
<ThreadLayoutDiagram mode={mode} />
</div>
</div>
</div>
);
}
export function ThreadLayoutSetting() {
const threadViewMode = useThreadViewMode();
const { communities } = useCommunities();
@@ -442,64 +601,38 @@ export function ThreadLayoutSetting() {
) ?? THREAD_VIEW_MODE_OPTIONS[0];
return (
<SettingsOptionRow>
<div className="min-w-0">
<p className="text-sm font-medium">
Thread layout
{showCommunityScope ? (
<span className="font-normal text-muted-foreground">
{" "}
(all communities)
</span>
) : null}
</p>
<p
className="text-sm font-normal text-muted-foreground/70"
data-settings-subcopy
>
{activeOption.description}
</p>
</div>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
className="h-7 min-w-28 justify-between gap-1.5 rounded-md border border-border/50 bg-muted/45 px-2.5 text-xs font-medium text-foreground shadow-none hover:bg-muted/70"
data-testid="thread-layout-trigger"
size="sm"
type="button"
variant="ghost"
<div data-testid="thread-layout-group">
<SettingsOptionRow>
<div className="min-w-0">
<p className="text-sm font-medium">
Thread layout
{showCommunityScope ? (
<span className="font-normal text-muted-foreground">
{" "}
(all communities)
</span>
) : null}
</p>
<p
className="text-sm font-normal text-muted-foreground/70"
data-settings-subcopy
>
<span className="truncate">{activeOption.label}</span>
<ChevronDown className="h-4 w-4 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="min-w-72 rounded-md"
data-testid="thread-layout-menu"
>
<DropdownMenuRadioGroup
onValueChange={(next) => setThreadViewMode(next as ThreadViewMode)}
value={threadViewMode}
>
{THREAD_VIEW_MODE_OPTIONS.map((option) => (
<DropdownMenuRadioItem
data-testid={`thread-layout-${option.value}`}
key={option.value}
value={option.value}
>
<span className="flex min-w-0 flex-col">
<span className="font-medium">{option.label}</span>
<span className="text-2xs text-muted-foreground">
{option.description}
</span>
</span>
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</SettingsOptionRow>
{activeOption.description}
</p>
</div>
<SettingsSegmentedControl
className="w-48"
legend="Thread layout"
onPreviewChange={previewThreadViewMode}
onValueChange={setThreadViewMode}
optionTestIdPrefix="thread-layout"
options={THREAD_VIEW_MODE_OPTIONS}
testId="thread-layout-control"
value={threadViewMode}
/>
</SettingsOptionRow>
<ThreadLayoutPreview />
</div>
);
}
@@ -133,7 +133,7 @@ export function SettingsSegmentedControl<Value extends string>({
return (
<fieldset
className={cn(
"relative isolate grid h-8 w-60 shrink-0 grid-cols-3 overflow-hidden rounded-md bg-muted/45 p-0.5",
"relative isolate h-8 w-60 shrink-0 overflow-hidden rounded-md bg-muted/45 p-0.5",
onPreviewChange && "touch-none select-none cursor-ew-resize",
className,
)}
@@ -156,30 +156,34 @@ export function SettingsSegmentedControl<Value extends string>({
width: `calc((100% - 0.25rem) / ${options.length})`,
}}
/>
{options.map(({ value: optionValue, label, Icon }) => (
<button
aria-pressed={value === optionValue}
className={cn(
"relative z-10 flex h-full items-center justify-center gap-1.5 rounded-md bg-transparent px-2.5 text-xs font-medium transition-colors duration-150 ease-out focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none",
displayedValue === optionValue
? "text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
data-testid={`${optionTestIdPrefix}-${optionValue}`}
key={optionValue}
onClick={(event) => {
if (event.detail > 0 && skipPointerClickRef.current) {
skipPointerClickRef.current = false;
return;
}
onValueChange(optionValue);
}}
type="button"
>
{Icon ? <Icon className="h-3.5 w-3.5" /> : null}
{label}
</button>
))}
{/* Legends escape grid/flex layout on a fieldset, so the columns live
on an inner wrapper the legend is not part of. */}
<div className="grid h-full auto-cols-fr grid-flow-col">
{options.map(({ value: optionValue, label, Icon }) => (
<button
aria-pressed={value === optionValue}
className={cn(
"relative z-10 flex h-full items-center justify-center gap-1.5 rounded-md bg-transparent px-2.5 text-xs font-medium transition-colors duration-150 ease-out focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none",
displayedValue === optionValue
? "text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
data-testid={`${optionTestIdPrefix}-${optionValue}`}
key={optionValue}
onClick={(event) => {
if (event.detail > 0 && skipPointerClickRef.current) {
skipPointerClickRef.current = false;
return;
}
onValueChange(optionValue);
}}
type="button"
>
{Icon ? <Icon className="h-3.5 w-3.5" /> : null}
{label}
</button>
))}
</div>
</fieldset>
);
}
+3 -3
View File
@@ -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<FontSize, number> = {
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";
@@ -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,