Keep typography PR focused on text preferences

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
This commit is contained in:
morgmart
2026-08-14 19:19:29 -05:00
parent 4b02612472
commit 803382c821
4 changed files with 134 additions and 279 deletions
@@ -23,7 +23,6 @@ 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"
@@ -47,7 +46,7 @@ function subscribe(listener: () => void): () => void {
}
function getSnapshot(): ThreadViewMode {
return previewOverride ?? threadViewMode;
return threadViewMode;
}
function getServerSnapshot(): ThreadViewMode {
@@ -61,7 +60,6 @@ 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 {
@@ -75,15 +73,6 @@ 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,9 +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 { ChevronDown, Eye } from "lucide-react";
import {
previewThreadViewMode,
setThreadViewMode,
useThreadViewMode,
type ThreadViewMode,
@@ -12,14 +10,10 @@ 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,
@@ -41,6 +35,14 @@ import {
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 { SegmentedControl } from "@/shared/ui/segmented-control";
@@ -254,84 +256,6 @@ 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<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 inline-flex items-center gap-1 text-2xs font-medium text-muted-foreground/55">
<Eye aria-hidden="true" className="size-3" />
Preview
</span>
<div className="p-4 pr-24">
<LinkPreviewAttachment
ImageLightbox={SampleImageLightbox}
preview={preview}
/>
</div>
</div>
</div>
);
}
export function LinkPreviewStyleSetting() {
const style = useLinkPreviewStyle();
const activeOption =
@@ -339,30 +263,58 @@ export function LinkPreviewStyleSetting() {
LINK_PREVIEW_STYLE_OPTIONS[0];
return (
<div data-testid="link-preview-style-group">
<SettingsOptionRow>
<div className="min-w-0">
<p className="text-sm font-medium">Link previews</p>
<p
className="text-sm font-normal text-muted-foreground/70"
data-settings-subcopy
<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"
>
{activeOption.description}
</p>
</div>
<SegmentedControl
size="compact"
legend="Link previews"
onPreviewChange={previewLinkPreviewStyle}
onValueChange={setLinkPreviewStyle}
optionTestIdPrefix="link-preview-style"
options={LINK_PREVIEW_STYLE_OPTIONS}
testId="link-preview-style-control"
value={style}
/>
</SettingsOptionRow>
<LinkPreviewSample />
</div>
<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>
);
}
@@ -482,119 +434,6 @@ 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 inline-flex items-center gap-1 text-2xs font-medium text-muted-foreground/55">
<Eye aria-hidden="true" className="size-3" />
Preview
</span>
<div className="p-4 pr-24">
<ThreadLayoutDiagram mode={mode} />
</div>
</div>
</div>
);
}
export function ThreadLayoutSetting() {
const threadViewMode = useThreadViewMode();
const { communities } = useCommunities();
@@ -605,38 +444,64 @@ export function ThreadLayoutSetting() {
) ?? THREAD_VIEW_MODE_OPTIONS[0];
return (
<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
<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"
>
{activeOption.description}
</p>
</div>
<SegmentedControl
size="compact"
legend="Thread layout"
onPreviewChange={previewThreadViewMode}
onValueChange={setThreadViewMode}
optionTestIdPrefix="thread-layout"
options={THREAD_VIEW_MODE_OPTIONS}
testId="thread-layout-control"
value={threadViewMode}
/>
</SettingsOptionRow>
<ThreadLayoutPreview />
</div>
<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>
);
}
@@ -9,7 +9,6 @@ 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,
@@ -35,11 +34,10 @@ function subscribe(listener: () => void): () => void {
}
export function getLinkPreviewStyle(): LinkPreviewStyle {
return previewOverride ?? linkPreviewStyle;
return linkPreviewStyle;
}
export function setLinkPreviewStyle(style: LinkPreviewStyle): void {
previewOverride = null;
linkPreviewStyle = style;
try {
globalThis.localStorage?.setItem(LINK_PREVIEW_STYLE_STORAGE_KEY, style);
@@ -49,13 +47,6 @@ 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,
@@ -505,7 +505,7 @@ test("appearance groups theme and preferences into labeled rows", async ({
preferencesCard.getByTestId("prominent-active-tab-toggle"),
).toHaveCount(0);
await expect(
preferencesCard.getByRole("group", { name: "Thread layout" }),
preferencesCard.getByTestId("thread-layout-trigger"),
).toBeVisible();
const themeStyleTrigger = themeCard.getByTestId("theme-style-trigger");
const themeStyleOptions = themeCard.getByTestId("theme-style-options");
@@ -1541,14 +1541,24 @@ test("glass background keeps the content panel solid", async ({ page }) => {
page.getByTestId("conversation-density-control"),
page.getByTestId("conversation-density-control-indicator"),
page.getByTestId("theme-style-trigger"),
page.getByTestId("link-preview-style-control"),
page.getByTestId("link-preview-style-control-indicator"),
page.getByTestId("thread-layout-control"),
page.getByTestId("thread-layout-control-indicator"),
page.getByTestId("link-preview-style-trigger"),
page.getByTestId("thread-layout-trigger"),
];
for (const control of matchingRadiusControls) {
await expect(control).toHaveCSS("border-radius", "8px");
}
await page.getByTestId("link-preview-style-trigger").click();
await expect(page.getByTestId("link-preview-style-menu")).toHaveCSS(
"border-radius",
"8px",
);
await page.keyboard.press("Escape");
await page.getByTestId("thread-layout-trigger").click();
await expect(page.getByTestId("thread-layout-menu")).toHaveCSS(
"border-radius",
"8px",
);
await page.keyboard.press("Escape");
await expect(page.getByTestId("glass-opacity-value")).toHaveCount(0);
await expect(
opacitySlider.locator(".buzz-avatar-framing-slider-handle"),