mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
201
desktop/tests/e2e/appearance-previews.spec.ts
Normal file
201
desktop/tests/e2e/appearance-previews.spec.ts
Normal 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`,
|
||||
});
|
||||
});
|
||||
@@ -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"),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user