Add appearance preference previews (#6193)

## Summary

- Replace the Link previews and Thread layout dropdowns with the shared
segmented control from #5644.
- Pair each setting with a real, inert preview: production Compact/Rich
link cards and a simplified Focus/Split thread diagram.
- Keep scrub previews local to Settings so only a completed selection
updates the saved preference and real app behavior.
- Keep each setting and preview responsive as one grouped unit, with
focused light/dark and narrow/wide coverage.

## Dependency

- Stacked on #5644, which provides the typography/density foundation,
shared `SegmentedControl`, and responsive `SettingsOptionRow`.
- After #5644 merges, this PR should be retargeted/rebased onto `main`.

## Validation

- `pnpm typecheck`
- `pnpm check:px-text`
- `pnpm check:file-sizes`
- `pnpm exec playwright test tests/e2e/appearance-previews.spec.ts
--project=smoke` (3 passed)
- Focused existing Appearance coverage (2 passed)
- Pre-push desktop checks, typecheck, tests, and Tauri checks

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
This commit is contained in:
morgmart
2026-08-19 14:32:17 -07:00
committed by GitHub
parent f96f1883c5
commit 9a1e861ab8
8 changed files with 558 additions and 182 deletions

View File

@@ -118,6 +118,7 @@ export default defineConfig({
"**/drafts-all-fix-screenshots.spec.ts",
"**/inbox-refactor-screenshots.spec.ts",
"**/buzz-theme-screenshots.spec.ts",
"**/appearance-previews.spec.ts",
"**/channel-sort.spec.ts",
"**/identity-lost.spec.ts",
"**/deep-link-invite.spec.ts",

View File

@@ -1,6 +1,7 @@
import * as React from "react";
import type { ReactNode } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ChevronDown, Eye } from "lucide-react";
import { Eye } from "lucide-react";
import {
setThreadViewMode,
useThreadViewMode,
@@ -15,6 +16,9 @@ import {
type LinkPreviewStyle,
} from "@/shared/lib/linkPreviewStylePreference";
import { isLinuxPlatform } from "@/shared/lib/platform";
import type { ResolvedLinkPreview } from "@/shared/lib/useResolvedLinkPreviews";
import { LinkPreviewAttachmentPresentation } from "@/shared/ui/link-preview-attachment";
import type { LinkPreviewImageLightboxProps } from "@/shared/ui/rich-link-preview-attachment";
import {
previewConversationDensity,
setConversationDensity,
@@ -36,14 +40,6 @@ 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";
@@ -257,65 +253,125 @@ 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 shapeToken = isDark ? "--foreground" : "--background";
const shapeFallback = isDark ? "0 0% 98%" : "0 0% 100%";
const shape = `hsl(${readToken(shapeToken, shapeFallback)})`;
const shapeOpacities = isDark ? [0.5, 0.38, 0.28] : [0.82, 0.68, 0.52];
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="${shapeOpacities[0]}"/><rect x="168" y="76" width="96" height="18" rx="9" fill="${shape}" opacity="${shapeOpacities[1]}"/><rect x="168" y="106" width="138" height="18" rx="9" fill="${shape}" opacity="${shapeOpacities[2]}"/></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({ style }: { style: LinkPreviewStyle }) {
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"
inert
>
<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">
<LinkPreviewAttachmentPresentation
ImageLightbox={SampleImageLightbox}
preview={preview}
showExpandControl={false}
style={style}
/>
</div>
</div>
</div>
);
}
export function LinkPreviewStyleSetting() {
const style = useLinkPreviewStyle();
const [previewStyle, setPreviewStyle] =
React.useState<LinkPreviewStyle | null>(null);
const displayedStyle = previewStyle ?? style;
const activeOption =
LINK_PREVIEW_STYLE_OPTIONS.find((option) => option.value === style) ??
LINK_PREVIEW_STYLE_OPTIONS[0];
LINK_PREVIEW_STYLE_OPTIONS.find(
(option) => option.value === displayedStyle,
) ?? 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">Link previews</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>
<SegmentedControl
size="compact"
legend="Link previews"
onPreviewChange={setPreviewStyle}
onValueChange={setLinkPreviewStyle}
optionTestIdPrefix="link-preview-style"
options={LINK_PREVIEW_STYLE_OPTIONS}
testId="link-preview-style-control"
value={style}
/>
</SettingsOptionRow>
<LinkPreviewSample style={displayedStyle} />
</div>
);
}
@@ -438,74 +494,159 @@ export function GlassBackgroundSetting() {
}
/** Compact thread preference row in the Appearance preferences card. */
export function ThreadLayoutSetting() {
const threadViewMode = useThreadViewMode();
const { communities } = useCommunities();
const showCommunityScope = communities.length > 1;
const activeOption =
THREAD_VIEW_MODE_OPTIONS.find(
(option) => option.value === threadViewMode,
) ?? THREAD_VIEW_MODE_OPTIONS[0];
/**
* 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) => (
<>
<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 (
<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>
<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={channelSurface}
height={112}
opacity={channelOpacity}
rx={10}
width={channelWidth}
x={10}
y={10}
/>
{channelWidth > 60 ? skeleton(22, 24, channelWidth - 24) : null}
{/* Thread surface */}
<path
d={`M ${threadX + 10} 10 H 220 Q 230 10 230 20 V 112 Q 230 122 220 122 H ${threadX + 10} Q ${threadX} 122 ${threadX} 112 V 20 Q ${threadX} 10 ${threadX + 10} 10 Z`}
fill={threadSurface}
opacity={threadOpacity}
/>
{skeleton(threadX + 12, 24, threadWidth - 24)}
</svg>
);
}
function ThreadLayoutPreview({ mode }: { mode: ThreadViewMode }) {
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>
<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>
);
}
export function ThreadLayoutSetting() {
const threadViewMode = useThreadViewMode();
const [previewMode, setPreviewMode] = React.useState<ThreadViewMode | null>(
null,
);
const { communities } = useCommunities();
const showCommunityScope = communities.length > 1;
const displayedMode = previewMode ?? threadViewMode;
const activeOption =
THREAD_VIEW_MODE_OPTIONS.find((option) => option.value === displayedMode) ??
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
>
<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>
<SegmentedControl
size="compact"
legend="Thread layout"
onPreviewChange={setPreviewMode}
onValueChange={setThreadViewMode}
optionTestIdPrefix="thread-layout"
options={THREAD_VIEW_MODE_OPTIONS}
testId="thread-layout-control"
value={threadViewMode}
/>
</SettingsOptionRow>
<ThreadLayoutPreview mode={displayedMode} />
</div>
);
}

View File

@@ -1,5 +1,8 @@
import type { ResolvedLinkPreview } from "@/shared/lib/useResolvedLinkPreviews";
import { useLinkPreviewStyle } from "@/shared/lib/linkPreviewStylePreference";
import {
type LinkPreviewStyle,
useLinkPreviewStyle,
} from "@/shared/lib/linkPreviewStylePreference";
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
import { useMediaProxyPort } from "@/shared/lib/useMediaProxyPort";
import { CompactLinkPreviewAttachment } from "@/shared/ui/compact-link-preview-attachment";
@@ -8,21 +11,59 @@ import {
RichLinkPreviewAttachment,
} from "@/shared/ui/rich-link-preview-attachment";
export function LinkPreviewAttachment({
className,
ImageLightbox,
onOpen,
onRemove,
preview,
showControls,
}: {
type LinkPreviewAttachmentProps = {
className?: string;
ImageLightbox: LinkPreviewImageLightboxComponent;
onOpen?: () => void;
onRemove?: () => void;
preview: ResolvedLinkPreview;
showControls?: boolean;
showExpandControl?: boolean;
};
/** Renders a link preview with an explicit presentation style. */
export function LinkPreviewAttachmentPresentation({
className,
ImageLightbox,
onOpen,
onRemove,
preview,
showControls,
showExpandControl,
style,
}: LinkPreviewAttachmentProps & {
style: LinkPreviewStyle;
}) {
if (style === "rich") {
return (
<RichLinkPreviewAttachment
className={className}
ImageLightbox={ImageLightbox}
onOpen={onOpen}
onRemove={onRemove}
preview={preview}
showControls={showControls}
showExpandControl={showExpandControl}
/>
);
}
return (
<CompactLinkPreviewAttachment
className={className}
onOpen={onOpen}
onRemove={onRemove}
preview={preview}
showControls={showControls}
/>
);
}
/** Renders a link preview using the user's saved presentation preference. */
export function LinkPreviewAttachment({
preview,
...props
}: LinkPreviewAttachmentProps) {
useMediaProxyPort();
const renderedPreview = {
...preview,
@@ -34,26 +75,12 @@ export function LinkPreviewAttachment({
: null,
};
const style = useLinkPreviewStyle();
if (style === "rich") {
return (
<RichLinkPreviewAttachment
className={className}
ImageLightbox={ImageLightbox}
onOpen={onOpen}
onRemove={onRemove}
preview={renderedPreview}
showControls={showControls}
/>
);
}
return (
<CompactLinkPreviewAttachment
className={className}
onOpen={onOpen}
onRemove={onRemove}
<LinkPreviewAttachmentPresentation
{...props}
preview={renderedPreview}
showControls={showControls}
style={style}
/>
);
}

View File

@@ -155,12 +155,14 @@ function TweetPreview({
onRemove,
preview,
showControls,
showExpandControl,
}: {
className?: string;
ImageLightbox: LinkPreviewImageLightboxComponent;
onRemove?: () => void;
preview: ResolvedLinkPreview;
showControls: boolean;
showExpandControl: boolean;
}) {
const [contentExpanded, setContentExpanded] = useState(true);
const reserveImage = preview.imageState !== "none";
@@ -208,7 +210,7 @@ function TweetPreview({
preview={preview}
/>
) : null}
{hasExpandableContent ? (
{showExpandControl && hasExpandableContent ? (
<button
aria-expanded={contentExpanded}
className="mt-1 flex items-center gap-1 text-xs leading-4 text-muted-foreground hover:text-foreground"
@@ -237,6 +239,7 @@ export function RichLinkPreviewAttachment({
onRemove,
preview,
showControls = false,
showExpandControl = true,
}: {
className?: string;
ImageLightbox: LinkPreviewImageLightboxComponent;
@@ -244,6 +247,7 @@ export function RichLinkPreviewAttachment({
onRemove?: () => void;
preview: ResolvedLinkPreview;
showControls?: boolean;
showExpandControl?: boolean;
}) {
const [contentExpanded, setContentExpanded] = useState(true);
@@ -255,6 +259,7 @@ export function RichLinkPreviewAttachment({
onRemove={onRemove}
preview={preview}
showControls={showControls}
showExpandControl={showExpandControl}
/>
);
}
@@ -327,7 +332,7 @@ export function RichLinkPreviewAttachment({
preview={preview}
/>
) : null}
{hasExpandableContent ? (
{showExpandControl && hasExpandableContent ? (
<button
aria-expanded={contentExpanded}
className="mt-1 flex items-center gap-1 text-xs leading-4 text-muted-foreground hover:text-foreground"

View File

@@ -0,0 +1,201 @@
import { expect, test, type Locator, type Page } from "@playwright/test";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge } from "../helpers/bridge";
const SHOTS = "test-results/appearance-previews";
const THEME_STORAGE_KEY = "buzz-theme";
const LINK_PREVIEW_STYLE_STORAGE_KEY = "buzz.appearance.linkPreviewStyle";
const THREAD_VIEW_MODE_STORAGE_KEY = "buzz.channels.threadViewMode";
async function openAppearance(
page: Page,
{
linkStyle = "compact",
theme = "buzz",
threadMode = "split",
}: {
linkStyle?: "compact" | "rich";
theme?: "buzz" | "buzz-dark";
threadMode?: "focus" | "split";
} = {},
) {
await page.addInitScript(
({ linkKey, linkStyle, theme, themeKey, threadKey, threadMode }) => {
window.localStorage.setItem(themeKey, theme);
window.localStorage.setItem(linkKey, linkStyle);
window.localStorage.setItem(threadKey, threadMode);
},
{
linkKey: LINK_PREVIEW_STYLE_STORAGE_KEY,
linkStyle,
theme,
themeKey: THEME_STORAGE_KEY,
threadKey: THREAD_VIEW_MODE_STORAGE_KEY,
threadMode,
},
);
await installMockBridge(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByTestId("open-settings").click();
await page.getByTestId("profile-popover-settings").click();
await page.getByTestId("settings-nav-appearance").click();
await expect(page.getByTestId("settings-theme")).toBeVisible({
timeout: 10_000,
});
await waitForAnimations(page);
}
async function scrubTo(control: Locator, option: Locator) {
await control.scrollIntoViewIfNeeded();
const controlBox = await control.boundingBox();
const optionBox = await option.boundingBox();
if (!controlBox || !optionBox) throw new Error("Segment geometry is missing");
const selectedOption = control.locator('button[aria-pressed="true"]');
const selectedOptionBox = await selectedOption.boundingBox();
if (!selectedOptionBox)
throw new Error("Starting segment geometry is missing");
await control
.page()
.mouse.move(
selectedOptionBox.x + selectedOptionBox.width / 2,
controlBox.y + controlBox.height / 2,
);
await control.page().mouse.down();
await control
.page()
.mouse.move(
optionBox.x + optionBox.width / 2,
controlBox.y + controlBox.height / 2,
{ steps: 5 },
);
}
test("appearance samples preview locally and commit only on selection", async ({
page,
}) => {
await openAppearance(page);
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(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();
await expect
.poll(() =>
page.evaluate(
(key) => window.localStorage.getItem(key),
LINK_PREVIEW_STYLE_STORAGE_KEY,
),
)
.toBe("compact");
await page.evaluate(() => window.dispatchEvent(new Event("blur")));
await expect(linkSample.locator("[data-link-preview-inline]")).toHaveCount(0);
await expect(page.getByTestId("link-preview-style-compact")).toHaveAttribute(
"aria-pressed",
"true",
);
await richOption.click();
await expect(linkSample.locator("[data-link-preview-inline]")).toBeVisible();
await expect
.poll(() =>
page.evaluate(
(key) => window.localStorage.getItem(key),
LINK_PREVIEW_STYLE_STORAGE_KEY,
),
)
.toBe("rich");
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.getByText("Threads open over the channel")).toBeVisible();
await expect
.poll(() =>
page.evaluate(
(key) => window.localStorage.getItem(key),
THREAD_VIEW_MODE_STORAGE_KEY,
),
)
.toBe("split");
await page.evaluate(() => window.dispatchEvent(new Event("blur")));
await expect(page.getByTestId("thread-layout-diagram-split")).toBeVisible();
await focusOption.click();
await expect(page.getByTestId("thread-layout-diagram-focus")).toBeVisible();
await expect
.poll(() =>
page.evaluate(
(key) => window.localStorage.getItem(key),
THREAD_VIEW_MODE_STORAGE_KEY,
),
)
.toBe("focus");
});
test("appearance previews stay grouped and responsive", async ({ page }) => {
await page.setViewportSize({ width: 840, height: 900 });
await openAppearance(page, {
linkStyle: "rich",
theme: "buzz-dark",
threadMode: "focus",
});
const preferencesCard = page.getByTestId("appearance-preferences-card");
const linkGroup = page.getByTestId("link-preview-style-group");
const threadGroup = page.getByTestId("thread-layout-group");
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();
const [cardBox, linkControlBox, threadControlBox] = await Promise.all([
preferencesCard.boundingBox(),
linkControl.boundingBox(),
threadControl.boundingBox(),
]);
if (!cardBox || !linkControlBox || !threadControlBox) {
throw new Error("Responsive Appearance geometry is missing");
}
expect(linkControlBox.width).toBeGreaterThan(cardBox.width - 40);
expect(threadControlBox.width).toBeGreaterThan(cardBox.width - 40);
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`,
});
});
test("appearance previews render compact and split samples 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`,
});
});

View File

@@ -512,7 +512,7 @@ test("appearance groups theme and preferences into labeled rows", async ({
preferencesCard.getByTestId("prominent-active-tab-toggle"),
).toHaveCount(0);
await expect(
preferencesCard.getByTestId("thread-layout-trigger"),
preferencesCard.getByRole("group", { name: "Thread layout" }),
).toBeVisible();
const themeStyleTrigger = themeCard.getByTestId("theme-style-trigger");
const themeStyleOptions = themeCard.getByTestId("theme-style-options");
@@ -1564,24 +1564,14 @@ 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-trigger"),
page.getByTestId("thread-layout-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"),
];
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"),

View File

@@ -573,8 +573,11 @@ test("sent link preview media uses the authenticated proxy in compact and rich c
await expectSmoothCorners(compactThumbnailFrame);
await openSettings(page, "appearance");
await page.getByTestId("link-preview-style-trigger").click();
await page.getByTestId("link-preview-style-rich").click();
await expect(page.getByTestId("link-preview-style-rich")).toHaveAttribute(
"aria-pressed",
"true",
);
await page.getByTestId("settings-back-to-app").click();
const richPreview = row.locator(
@@ -636,13 +639,14 @@ test("link preview style defaults to compact and Rich unfurls descriptions", asy
}
await openSettings(page, "appearance");
await expect(page.getByTestId("link-preview-style-trigger")).toHaveText(
"Compact",
await expect(page.getByTestId("link-preview-style-compact")).toHaveAttribute(
"aria-pressed",
"true",
);
await page.getByTestId("link-preview-style-trigger").click();
await page.getByTestId("link-preview-style-rich").click();
await expect(page.getByTestId("link-preview-style-trigger")).toHaveText(
"Rich",
await expect(page.getByTestId("link-preview-style-rich")).toHaveAttribute(
"aria-pressed",
"true",
);
await expect
.poll(() =>
@@ -694,8 +698,11 @@ test("link preview style defaults to compact and Rich unfurls descriptions", asy
}
await openSettings(page, "appearance");
await page.getByTestId("link-preview-style-trigger").click();
await page.getByTestId("link-preview-style-compact").click();
await expect(page.getByTestId("link-preview-style-compact")).toHaveAttribute(
"aria-pressed",
"true",
);
});
for (const [pasteShape, wrapUrl] of [
@@ -1561,8 +1568,11 @@ test("mixed link preview image outcomes keep Compact and Rich fallbacks stable",
).toHaveCount(0);
await openSettings(page, "appearance");
await page.getByTestId("link-preview-style-trigger").click();
await page.getByTestId("link-preview-style-rich").click();
await expect(page.getByTestId("link-preview-style-rich")).toHaveAttribute(
"aria-pressed",
"true",
);
await page.getByTestId("settings-back-to-app").click();
const richCards = row.locator(
@@ -1631,8 +1641,11 @@ test("link preview browser image errors render a fallback", async ({
).toHaveCount(0);
await openSettings(page, "appearance");
await page.getByTestId("link-preview-style-trigger").click();
await page.getByTestId("link-preview-style-rich").click();
await expect(page.getByTestId("link-preview-style-rich")).toHaveAttribute(
"aria-pressed",
"true",
);
await page.getByTestId("settings-back-to-app").click();
const richCard = row.locator(

View File

@@ -2687,10 +2687,8 @@ test("shows agent runtimes in agent settings", async ({ page }) => {
.evaluate((element) => getComputedStyle(element).color);
await page.getByTestId("settings-nav-appearance").click();
const appearanceSecondaryColor = await page
.getByTestId("link-preview-style-trigger")
.locator("..")
.locator("p")
.nth(1)
.getByTestId("link-preview-style-group")
.locator("[data-settings-subcopy]")
.evaluate((element) => getComputedStyle(element).color);
expect(agentsSecondaryColor).toBe(appearanceSecondaryColor);
});