Add font size and conversation density preferences (#5644)

## Summary

- Add separate **Font size** and **Conversation density** controls in
Appearance.
- Use a 13 / 14 / 15px conversation text ramp for Smaller / Default /
Larger while scaling interface typography through a shared virtual-rem
system.
- Keep layout geometry fixed while typography changes, and compose Cmd
+/- text zoom on top of the selected preference.
- Let Compact, Comfy, and Spacious control conversation row spacing plus
paragraph and list rhythm in Markdown content across Buzz.
- Preview font size and density together with click-and-drag comparison;
interrupted scrubbing restores the saved preference.
- Synchronize preferences across open Buzz windows, including full
local-storage clears during sign-out or dev reset.
- Promote the segmented control to shared UI and make Settings rows
adapt to their card width.

## Validation

- `just ci`
- 4,965+ desktop unit tests across the final merged branch
- 1,465 mobile tests through the full repository gate
- Focused Playwright coverage for font-size/density independence, drag
preview cancellation, Inbox geometry, thread rendering, keyboard zoom,
and cross-surface typography
- Fresh E2E build against the final merged branch

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
Signed-off-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
Co-authored-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
Co-authored-by: morgmart <98432065+morgmart@users.noreply.github.com>
This commit is contained in:
klopez4212
2026-08-18 23:24:09 +01:00
committed by GitHub
parent 6e8d078ffe
commit 7e2651791d
34 changed files with 1891 additions and 130 deletions

View File

@@ -476,11 +476,18 @@ are frozen.**
So for any readable text, reach for rem-based Tailwind tokens, never arbitrary
px:
- ✅ Stock rem tokens (`text-base`, `text-sm`, `text-xs`, …). **Chat body/author
text === `text-base` (16px) — chat is the app's base type size**, and the
surrounding timeline elements (timestamps, system rows, code, reactions) are
deliberate steps on that same stock ramp.
- ✅ The `text-2xs` (0.6875rem / 11px) and `text-3xs` (0.5rem / 8px) meta-text
- ✅ Stock rem tokens (`text-base`, `text-sm`, `text-xs`, …) for general
interface text. All of these derive from the virtual typography rem and
therefore follow the user's font-size preference and Cmd +/- zoom.
- ✅ Conversation text uses the named `text-message` token. Its
**Smaller / Default / Larger contract is 13 / 14 / 15px** before keyboard
zoom. Author names use the same conversation-size step; timestamps, system
rows, code, and reactions are deliberate neighboring steps on the shared
virtual-rem ramp. Keep those relationships tokenized rather than restoring a
fixed 16px chat baseline or hardcoding preference-specific values in
components.
- ✅ The `text-2xs` (0.6875rem / 11px at a 16px virtual rem) and `text-3xs`
(0.5rem / 8px at a 16px virtual rem) meta-text
tokens (in `desktop/tailwind.config.js` under `theme.extend.fontSize`) for the
sub-`text-xs` ramp — timestamps, count badges, tracking labels, tiny glyphs.
These replaced the dozens of arbitrary `text-[…rem]` literals that had drifted

View File

@@ -1,13 +1,13 @@
import * as React from "react";
import { getCurrentWebview } from "@tauri-apps/api/webview";
import { applyTextZoomFactor } from "@/shared/lib/fontSizePreference";
import { hasPrimaryShortcutModifier } from "@/shared/lib/platform";
const DEFAULT_ZOOM_FACTOR = 1;
const MIN_ZOOM_FACTOR = 0.75;
const MAX_ZOOM_FACTOR = 1.5;
const ZOOM_STEP = 0.1;
const BASE_FONT_SIZE_PX = 16;
const TEXT_SCALE_STORAGE_KEY = "buzz:text-scale";
type ZoomAction = "increase" | "decrease" | "reset";
@@ -76,13 +76,12 @@ function readStoredZoomFactor() {
}
function applyTextScale(zoomFactor: number) {
applyTextZoomFactor(zoomFactor);
if (zoomFactor === DEFAULT_ZOOM_FACTOR) {
document.documentElement.style.fontSize = "";
window.localStorage.removeItem(TEXT_SCALE_STORAGE_KEY);
return;
}
document.documentElement.style.fontSize = `${BASE_FONT_SIZE_PX * zoomFactor}px`;
window.localStorage.setItem(TEXT_SCALE_STORAGE_KEY, String(zoomFactor));
}
@@ -120,9 +119,21 @@ export function useWebviewZoomShortcuts() {
applyTextScale(nextZoomFactor);
}
function handleStorage(event: StorageEvent) {
if (event.key !== TEXT_SCALE_STORAGE_KEY && event.key !== null) {
return;
}
const storedZoomFactor = readStoredZoomFactor();
zoomFactorRef.current = storedZoomFactor;
applyTextZoomFactor(storedZoomFactor);
}
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("storage", handleStorage);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("storage", handleStorage);
};
}, []);
}

View File

@@ -433,14 +433,14 @@ export function InboxListPane({
<div
className={cn(
"mt-1.5 text-sm leading-5 [&_a]:font-medium [&_a]:text-current",
"mt-1.5 text-message [&_a]:font-medium [&_a]:text-current",
isDone
? "font-normal text-muted-foreground"
: "font-semibold text-foreground",
)}
>
<VideoReviewCommentMarkdown
className="inbox-preview-markdown text-inherit leading-5"
className="inbox-preview-markdown text-inherit"
content={item.preview}
interactive={false}
mentionNames={item.mentionNames}

View File

@@ -115,7 +115,8 @@ export function InboxMessageRow({
});
const timestampNode = (
<p
className="shrink-0 text-xs font-normal tabular-nums text-muted-foreground/55"
className="shrink-0 text-message-timestamp font-normal tabular-nums text-muted-foreground/55"
data-testid="inbox-message-timestamp"
title={message.fullTimestampLabel}
>
{timestampLabel}
@@ -138,7 +139,7 @@ export function InboxMessageRow({
) : null}
<article
className={cn(
"group/message relative z-10 mx-1 flex gap-2.5 rounded-2xl px-2 py-1 transition-colors hover:bg-muted/50 focus-within:bg-muted/50",
"group/message relative z-10 mx-1 flex gap-2.5 rounded-2xl px-2 py-conversation-row transition-colors hover:bg-muted/50 focus-within:bg-muted/50",
isContinuation ? "items-center" : "items-start",
)}
data-message-id={message.id}
@@ -181,7 +182,7 @@ export function InboxMessageRow({
className="flex w-9 shrink-0 self-stretch items-start justify-end pt-0.5"
title={message.fullTimestampLabel}
>
<p className="shrink-0 cursor-default whitespace-nowrap text-xs font-normal leading-4 tabular-nums text-muted-foreground/55 opacity-0 transition-opacity group-hover/message:opacity-100 group-focus-within/message:opacity-100">
<p className="shrink-0 cursor-default whitespace-nowrap text-message-timestamp font-normal tabular-nums text-muted-foreground/55 opacity-0 transition-opacity group-hover/message:opacity-100 group-focus-within/message:opacity-100">
{hoverTimestampLabel}
</p>
</div>
@@ -207,14 +208,20 @@ export function InboxMessageRow({
<div className="min-w-0 flex-1">
{isContinuation ? null : (
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-0">
<div
className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-0"
data-testid="message-header"
>
<UserProfilePopover
botIdenticonValue={message.authorLabel}
pubkey={message.authorPubkey}
role={profileRole}
triggerElement="span"
>
<span className="block max-w-full truncate rounded text-sm font-semibold text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring">
<span
className="block max-w-full truncate rounded text-message font-semibold leading-message-author text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
data-testid="message-author"
>
{message.authorLabel}
</span>
</UserProfilePopover>
@@ -240,10 +247,13 @@ export function InboxMessageRow({
</div>
)}
<div className={isContinuation ? "mt-0" : "mt-0.5"}>
<div
className={isContinuation ? "mt-0" : "mt-conversation-body"}
data-testid="message-body"
>
<VideoReviewCommentMarkdown
className={cn(
"max-w-full text-left text-sm text-foreground",
"max-w-full text-left text-message text-foreground",
emojiOnly &&
"text-4xl leading-tight [&_p]:leading-tight [&_img[data-custom-emoji]]:h-[1.45em] [&_img[data-custom-emoji]]:align-middle [&_button:has(img[data-custom-emoji])]:align-middle",
)}

View File

@@ -510,7 +510,7 @@ export function useRichTextEditor({
attributes: {
autocapitalize: "none",
autocorrect: "off",
class: `${MESSAGE_MARKDOWN_CLASS} min-h-0 resize-none overflow-y-hidden border-0 bg-transparent px-0 py-0 text-sm leading-5 text-foreground shadow-none focus-visible:ring-0 caret-foreground outline-hidden max-w-none`,
class: `${MESSAGE_MARKDOWN_CLASS} min-h-0 resize-none overflow-y-hidden border-0 bg-transparent px-0 py-0 text-message font-normal tracking-normal text-foreground shadow-none focus-visible:ring-0 caret-foreground outline-hidden max-w-none`,
"data-testid": "message-input",
spellcheck: "true",
},

View File

@@ -52,7 +52,7 @@
}
.buzz-diff-theme .diff {
font-size: 0.75rem;
font-size: calc(var(--buzz-type-rem) * 0.75);
}
.buzz-diff-theme .diff td {
@@ -68,7 +68,7 @@
padding: 0.125rem 0.5rem;
border-right: 1px solid hsl(var(--border) / 0.65);
color: hsl(var(--muted-foreground));
font-size: 0.6875rem;
font-size: calc(var(--buzz-type-rem) * 0.6875);
}
.buzz-diff-theme .buzz-diff-code {
@@ -86,7 +86,7 @@
padding: 0.2rem 0.75rem;
background: hsl(var(--muted) / 0.35);
color: hsl(var(--muted-foreground));
font-size: 0.6875rem;
font-size: calc(var(--buzz-type-rem) * 0.6875);
}
.buzz-diff-theme .diff-gutter-omit::before {

View File

@@ -14,9 +14,10 @@ export function MessageHeaderRow({
return (
<div
className={cn(
"flex min-w-0 flex-wrap items-baseline gap-x-1.5 gap-y-0 leading-4",
"flex min-w-0 flex-wrap items-baseline gap-x-1.5 gap-y-0 leading-message-author",
className,
)}
data-testid="message-header"
>
{children}
</div>
@@ -93,7 +94,7 @@ export function MessageAuthorText({
return (
<Component
className={cn(
"truncate text-sm font-bold leading-4 tracking-tight",
"truncate text-message font-semibold leading-message-author tracking-normal",
hoverUnderline && "hover:underline",
className,
)}

View File

@@ -295,7 +295,7 @@ export const MessageRow = React.memo(
message.body,
message.tags,
);
const bodyOffsetClass = emojiOnly ? "mt-1" : "-mt-0.5";
const bodyOffsetClass = emojiOnly ? "mt-1" : "mt-conversation-body";
const { nonDmChannelNames: channelNames } = useChannelNavigation();
@@ -411,7 +411,7 @@ export const MessageRow = React.memo(
<VideoReviewCommentMarkdown
channelNames={channelNames}
className={cn(
"max-w-full text-sm",
"max-w-full text-message",
emojiOnly &&
"text-4xl leading-tight [&_p]:leading-tight [&_img[data-custom-emoji]]:h-[1.45em] [&_img[data-custom-emoji]]:align-middle [&_button:has(img[data-custom-emoji])]:align-middle",
)}
@@ -635,7 +635,7 @@ export const MessageRow = React.memo(
botIdenticonValue={message.author}
>
<button
className="truncate rounded leading-4 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
className="truncate rounded leading-message-author focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
type="button"
>
{authorNode}
@@ -868,7 +868,7 @@ export const MessageRow = React.memo(
className={cn(
"group/message relative z-10 rounded-2xl transition-colors",
playEntrance && "motion-enter-conversation",
"py-1",
"py-conversation-row",
hoverBackground
? "mx-1 px-2 hover:bg-muted/50 focus-within:bg-muted/50"
: isThreadReplyLayout
@@ -888,17 +888,21 @@ export const MessageRow = React.memo(
{isThreadReplyLayout ? (
<>
{avatarGutterNode}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex min-w-0 flex-1 flex-col">
{headerNode}
<div className={bodyContainerClass}>{messageBodyNode}</div>
<div className={bodyContainerClass} data-testid="message-body">
{messageBodyNode}
</div>
</div>
</>
) : (
<>
{avatarGutterNode}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex min-w-0 flex-1 flex-col">
{headerNode}
<div className={bodyContainerClass}>{messageBodyNode}</div>
<div className={bodyContainerClass} data-testid="message-body">
{messageBodyNode}
</div>
</div>
</>
)}

View File

@@ -5,7 +5,14 @@ import {
} from "@/features/messages/lib/dateFormatters";
import { cn } from "@/shared/lib/cn";
import { formatItemTimestamp } from "@/shared/lib/datetime";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/shared/ui/tooltip";
const TIMESTAMP_TOOLTIP_DELAY_MS = 500;
/**
* The timestamp beside a message author, and the clock that fades in over the
@@ -43,21 +50,26 @@ export function MessageTimestamp({
: formatItemTimestamp(createdAt, { withTime: true });
return (
<Tooltip>
<TooltipTrigger asChild>
<p
className={cn(
"shrink-0 cursor-default whitespace-nowrap text-xs font-normal leading-4 tabular-nums text-muted-foreground/55",
className,
)}
data-testid="message-timestamp"
>
{displayTime}
</p>
</TooltipTrigger>
<TooltipContent side="top">
{formatFullDateTime(createdAt)}
</TooltipContent>
</Tooltip>
<TooltipProvider
delayDuration={TIMESTAMP_TOOLTIP_DELAY_MS}
skipDelayDuration={0}
>
<Tooltip>
<TooltipTrigger asChild>
<p
className={cn(
"shrink-0 cursor-default whitespace-nowrap text-message-timestamp font-normal tabular-nums text-muted-foreground/55",
className,
)}
data-testid="message-timestamp"
>
{displayTime}
</p>
</TooltipTrigger>
<TooltipContent side="top">
{formatFullDateTime(createdAt)}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}

View File

@@ -1,5 +1,6 @@
import type { ReactNode } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ChevronDown } from "lucide-react";
import { ChevronDown, Eye } from "lucide-react";
import {
setThreadViewMode,
useThreadViewMode,
@@ -14,6 +15,18 @@ import {
type LinkPreviewStyle,
} from "@/shared/lib/linkPreviewStylePreference";
import { isLinuxPlatform } from "@/shared/lib/platform";
import {
previewConversationDensity,
setConversationDensity,
useConversationDensity,
type ConversationDensity,
} from "@/shared/lib/conversationDensityPreference";
import {
previewFontSize,
setFontSize,
useFontSize,
type FontSize,
} from "@/shared/lib/fontSizePreference";
import {
ACCENT_COLORS,
DEFAULT_GLASS_OPACITY,
@@ -22,6 +35,7 @@ import {
NEUTRAL_ACCENT,
useTheme,
} from "@/shared/theme/ThemeProvider";
import { Button } from "@/shared/ui/button";
import {
DropdownMenu,
@@ -32,6 +46,7 @@ import {
} from "@/shared/ui/dropdown-menu";
import { Switch } from "@/shared/ui/switch";
import { SettingsOptionRow } from "./SettingsOptionGroup";
import { SegmentedControl } from "@/shared/ui/segmented-control";
/** Buzz navigation can use either its production tint or a stronger tab. */
export function ProminentActiveTabSetting() {
@@ -71,15 +86,177 @@ const LINK_PREVIEW_STYLE_OPTIONS: {
{
value: "compact",
label: "Compact",
description: "Show links as compact horizontal cards",
description: "Small cards with a thumbnail",
},
{
value: "rich",
label: "Rich",
description: "Unfurl links with larger images and descriptions",
description: "Large previews with images and descriptions",
},
];
const CONVERSATION_DENSITY_OPTIONS: readonly {
value: ConversationDensity;
label: string;
}[] = [
{
value: "compact",
label: "Compact",
},
{
value: "comfortable",
label: "Comfy",
},
{
value: "spacious",
label: "Spacious",
},
];
const FONT_SIZE_OPTIONS: readonly {
value: FontSize;
label: string;
}[] = [
{
value: "smaller",
label: "Smaller",
},
{
value: "default",
label: "Default",
},
{
value: "larger",
label: "Larger",
},
];
function ConversationDensityPreviewMessage({
avatar,
author,
children,
timestamp,
}: {
avatar: string;
author: string;
children: ReactNode;
timestamp: string;
}) {
return (
<article className="flex gap-2.5 py-conversation-row">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold text-muted-foreground">
{avatar}
</div>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 flex-wrap items-baseline gap-x-1.5 leading-message-author">
<span className="text-message font-semibold leading-message-author tracking-normal text-foreground">
{author}
</span>
<span className="text-message-timestamp font-normal text-muted-foreground/65">
{timestamp}
</span>
</div>
<div className="mt-conversation-body text-message font-normal tracking-normal text-foreground">
{children}
</div>
</div>
</article>
);
}
function ConversationPreview() {
return (
<div className="px-4 py-3" data-testid="conversation-preview">
<div
aria-hidden="true"
className="relative overflow-hidden rounded-xl border border-border/65 bg-transparent"
data-testid="conversation-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" data-testid="conversation-preview-content">
<ConversationDensityPreviewMessage
avatar="M"
author="Maya"
timestamp="9:41"
>
The revised conversation layout is ready to review.
</ConversationDensityPreviewMessage>
<ConversationDensityPreviewMessage
avatar="T"
author="Theo"
timestamp="9:43"
>
<p>
I added a longer message so you can compare line height and text
spacing.
</p>
<p className="mt-conversation-paragraph">
The same rhythm carries through channels, threads, DMs, and Inbox.
</p>
</ConversationDensityPreviewMessage>
</div>
</div>
</div>
);
}
/** App-wide type sizing and conversation-specific spacing controls. */
export function ConversationDisplaySettings() {
const density = useConversationDensity();
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>
<p
className="text-sm font-normal text-muted-foreground/70"
data-settings-subcopy
>
Applies across conversations and interface text
</p>
</div>
<SegmentedControl
size="wide"
legend="Font size"
onPreviewChange={previewFontSize}
onValueChange={setFontSize}
optionTestIdPrefix="font-size"
options={FONT_SIZE_OPTIONS}
testId="font-size-control"
value={fontSize}
/>
</SettingsOptionRow>
<SettingsOptionRow data-testid="conversation-density-row">
<div className="min-w-0">
<p className="text-sm font-medium">Conversation density</p>
<p
className="text-sm font-normal text-muted-foreground/70"
data-settings-subcopy
>
Spacing in conversations and Markdown content across Buzz
</p>
</div>
<SegmentedControl
size="wide"
legend="Conversation density"
onPreviewChange={previewConversationDensity}
onValueChange={setConversationDensity}
optionTestIdPrefix="conversation-density"
options={CONVERSATION_DENSITY_OPTIONS}
testId="conversation-density-control"
value={density}
/>
</SettingsOptionRow>
<ConversationPreview />
</div>
);
}
export function LinkPreviewStyleSetting() {
const style = useLinkPreviewStyle();
const activeOption =
@@ -150,7 +327,7 @@ const THREAD_VIEW_MODE_OPTIONS: {
{
value: "focus",
label: "Focus",
description: "Threads open over the channel, full width",
description: "Threads open over the channel",
},
{
value: "split",

View File

@@ -45,6 +45,7 @@ export function SettingsOptionGroup({
) : null}
<div
className={cn(
"[container-type:inline-size]",
surface === "soft"
? "overflow-hidden rounded-2xl bg-muted/20"
: "divide-y divide-border/55 overflow-hidden rounded-xl border border-border/70 bg-background/70",
@@ -77,7 +78,7 @@ export function SettingsOptionRow({
return (
<div
className={cn(
"flex min-h-16 items-center justify-between gap-4 px-4 py-3 text-sm",
"flex min-h-16 items-center justify-between gap-4 px-4 py-3 text-sm [@container(max-width:34rem)]:flex-col [@container(max-width:34rem)]:items-start [@container(max-width:34rem)]:[&>[data-slot=segmented-control]]:w-full",
className,
)}
{...props}

View File

@@ -55,6 +55,7 @@ import {
import { appearanceCommunityLabel } from "../lib/appearanceScopeCopy";
import {
AccentPickerContent,
ConversationDisplaySettings,
GlassBackgroundSetting,
LinkPreviewStyleSetting,
ProminentActiveTabSetting,
@@ -74,6 +75,7 @@ import {
SettingsOptionGroupList,
SettingsOptionRow,
} from "./SettingsOptionGroup";
import { SegmentedControl } from "@/shared/ui/segmented-control";
import { ProfileSettingsCard } from "./ProfileSettingsCard";
import { UpdateChecker } from "../UpdateChecker";
import { SettingsSectionHeader } from "./SettingsSectionHeader";
@@ -674,39 +676,19 @@ function ThemeSettingsCard() {
Follow your system or choose a light or dark appearance.
</p>
</div>
<fieldset
className="relative isolate grid h-8 w-[15rem] shrink-0 grid-cols-3 overflow-hidden rounded-md bg-muted/45 p-0.5"
data-testid="appearance-color-mode-control"
>
<legend className="sr-only">Color mode</legend>
<div
aria-hidden="true"
className="absolute bottom-0.5 left-0.5 top-0.5 z-0 rounded-md bg-background shadow-sm transition-transform duration-[250ms] ease-out motion-reduce:transition-none"
data-testid="appearance-color-mode-indicator"
style={{
transform: `translateX(${APPEARANCE_MODE_OPTIONS.findIndex((option) => option.mode === selectedMode) * 100}%)`,
width: "calc((100% - 4px) / 3)",
}}
/>
{APPEARANCE_MODE_OPTIONS.map(({ mode, label, Icon }) => (
<button
aria-pressed={selectedMode === mode}
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-[250ms] ease-out focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none",
selectedMode === mode
? "text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
data-testid={`appearance-mode-${mode}`}
key={mode}
onClick={() => handleModeSelect(mode)}
type="button"
>
<Icon className="h-3.5 w-3.5" />
{label}
</button>
))}
</fieldset>
<SegmentedControl
indicatorTestId="appearance-color-mode-indicator"
legend="Color mode"
onValueChange={handleModeSelect}
optionTestIdPrefix="appearance-mode"
options={APPEARANCE_MODE_OPTIONS.map(({ mode, label, Icon }) => ({
value: mode,
label,
Icon,
}))}
testId="appearance-color-mode-control"
value={selectedMode}
/>
</SettingsOptionRow>
<SettingsOptionRow data-testid="theme-style-row">
@@ -808,6 +790,7 @@ function ThemeSettingsCard() {
data-testid="appearance-preferences-card"
title="Preferences"
>
<ConversationDisplaySettings />
<LinkPreviewStyleSetting />
<ThreadLayoutSetting />
</SettingsOptionGroup>

View File

@@ -3,7 +3,8 @@ import ReactDOM from "react-dom/client";
import { App } from "@/app/App";
import { RootErrorBoundary } from "@/app/RootErrorBoundary";
import { NostrBindConsentDialog } from "@/features/profile/ui/NostrBindConsentDialog";
import "@fontsource-variable/inter/wght.css";
import "@fontsource-variable/inter/opsz.css";
import "@fontsource-variable/inter/opsz-italic.css";
import "@fontsource/jetbrains-mono/400.css";
import "@fontsource/jetbrains-mono/700.css";
import "@/shared/styles/globals.css";
@@ -19,6 +20,8 @@ import { Toaster } from "@/shared/ui/sonner";
import { TooltipProvider } from "@/shared/ui/tooltip";
import { recoverLocalStorageQuotaOnStartup } from "@/shared/lib/localStorageQuota";
import { startLocalStorageSweep } from "@/shared/lib/localStorageSweep";
import { initializeConversationDensityPreference } from "@/shared/lib/conversationDensityPreference";
import { initializeFontSizePreference } from "@/shared/lib/fontSizePreference";
type E2eWindow = Window & {
__BUZZ_E2E__?: unknown;
@@ -123,6 +126,8 @@ async function bootstrap() {
resetDevWebviewStateFromUrl();
configureDevE2eBridgeFromUrl();
recoverLocalStorageQuotaOnStartup();
initializeConversationDensityPreference();
initializeFontSizePreference();
startLocalStorageSweep();
await installE2eBridgeIfConfigured();
await migrateLegacyCommunityStorageBeforeRender();

View File

@@ -1,6 +1,18 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
import { extendTailwindMerge } from "tailwind-merge";
const mergeClassNames = extendTailwindMerge({
extend: {
classGroups: {
"font-size": [
{
text: ["message", "message-timestamp"],
},
],
},
},
});
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
return mergeClassNames(clsx(inputs));
}

View File

@@ -0,0 +1,80 @@
import assert from "node:assert/strict";
import test from "node:test";
const values = new Map();
const attributes = new Map();
const windowListeners = new Map();
globalThis.window = {
addEventListener: (type, listener) => windowListeners.set(type, listener),
};
globalThis.localStorage = {
getItem: (key) => values.get(key) ?? null,
setItem: (key, value) => values.set(key, String(value)),
};
globalThis.document = {
documentElement: {
setAttribute: (name, value) => attributes.set(name, value),
},
};
const preference = await import("./conversationDensityPreference.ts");
test("defaults invalid and missing conversation densities to comfortable", () => {
assert.equal(preference.parseConversationDensity(null), "comfortable");
assert.equal(preference.parseConversationDensity("dense"), "comfortable");
assert.equal(preference.parseConversationDensity("compact"), "compact");
assert.equal(
preference.parseConversationDensity("comfortable"),
"comfortable",
);
assert.equal(preference.parseConversationDensity("spacious"), "spacious");
});
test("persists and applies the selected conversation density", () => {
preference.setConversationDensity("compact");
assert.equal(preference.getConversationDensity(), "compact");
assert.equal(
values.get(preference.CONVERSATION_DENSITY_STORAGE_KEY),
"compact",
);
assert.equal(attributes.get("data-conversation-density"), "compact");
});
test("previews a density without changing the saved preference", () => {
preference.setConversationDensity("compact");
preference.previewConversationDensity("spacious");
assert.equal(preference.getConversationDensity(), "compact");
assert.equal(
values.get(preference.CONVERSATION_DENSITY_STORAGE_KEY),
"compact",
);
assert.equal(attributes.get("data-conversation-density"), "spacious");
preference.previewConversationDensity(null);
assert.equal(attributes.get("data-conversation-density"), "compact");
});
test("initializes from the persisted conversation density", () => {
values.set(preference.CONVERSATION_DENSITY_STORAGE_KEY, "spacious");
preference.initializeConversationDensityPreference();
assert.equal(preference.getConversationDensity(), "spacious");
assert.equal(attributes.get("data-conversation-density"), "spacious");
});
test("applies conversation density changes from another window", () => {
values.set(preference.CONVERSATION_DENSITY_STORAGE_KEY, "compact");
windowListeners.get("storage")({
key: preference.CONVERSATION_DENSITY_STORAGE_KEY,
});
assert.equal(preference.getConversationDensity(), "compact");
assert.equal(attributes.get("data-conversation-density"), "compact");
});
test("returns to comfortable when another window clears storage", () => {
preference.setConversationDensity("spacious");
values.clear();
windowListeners.get("storage")({ key: null });
assert.equal(preference.getConversationDensity(), "comfortable");
assert.equal(attributes.get("data-conversation-density"), "comfortable");
});

View File

@@ -0,0 +1,101 @@
import * as React from "react";
/** Device-level spacing used across conversation surfaces. */
export type ConversationDensity = "compact" | "comfortable" | "spacious";
export const CONVERSATION_DENSITY_STORAGE_KEY =
"buzz.appearance.conversationDensity";
export const DEFAULT_CONVERSATION_DENSITY: ConversationDensity = "comfortable";
const listeners = new Set<() => void>();
let conversationDensity: ConversationDensity = DEFAULT_CONVERSATION_DENSITY;
let listeningForStorageChanges = false;
export function parseConversationDensity(
value: string | null | undefined,
): ConversationDensity {
return value === "compact" || value === "comfortable" || value === "spacious"
? value
: DEFAULT_CONVERSATION_DENSITY;
}
function readStoredConversationDensity(): ConversationDensity {
try {
return parseConversationDensity(
globalThis.localStorage?.getItem(CONVERSATION_DENSITY_STORAGE_KEY),
);
} catch {
return DEFAULT_CONVERSATION_DENSITY;
}
}
function applyConversationDensity(density: ConversationDensity): void {
globalThis.document?.documentElement?.setAttribute(
"data-conversation-density",
density,
);
}
function notifyListeners(): void {
for (const listener of listeners) listener();
}
function applyStoredConversationDensity(): void {
const nextDensity = readStoredConversationDensity();
const changed = nextDensity !== conversationDensity;
conversationDensity = nextDensity;
applyConversationDensity(nextDensity);
if (changed) notifyListeners();
}
function listenForStorageChanges(): void {
if (listeningForStorageChanges || !globalThis.window?.addEventListener)
return;
globalThis.window.addEventListener("storage", (event) => {
if (event.key === CONVERSATION_DENSITY_STORAGE_KEY || event.key === null) {
applyStoredConversationDensity();
}
});
listeningForStorageChanges = true;
}
/** Apply the persisted preference before React renders to avoid a layout jump. */
export function initializeConversationDensityPreference(): void {
applyStoredConversationDensity();
listenForStorageChanges();
}
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
export function getConversationDensity(): ConversationDensity {
return conversationDensity;
}
export function setConversationDensity(density: ConversationDensity): void {
conversationDensity = density;
applyConversationDensity(density);
try {
globalThis.localStorage?.setItem(CONVERSATION_DENSITY_STORAGE_KEY, density);
} catch {
// Persistence is best-effort; the live preference still applies.
}
notifyListeners();
}
/** Temporarily apply a density without changing the saved preference. */
export function previewConversationDensity(
density: ConversationDensity | null,
): void {
applyConversationDensity(density ?? conversationDensity);
}
export function useConversationDensity(): ConversationDensity {
return React.useSyncExternalStore(
subscribe,
getConversationDensity,
() => DEFAULT_CONVERSATION_DENSITY,
);
}

View File

@@ -0,0 +1,97 @@
import assert from "node:assert/strict";
import test from "node:test";
import config from "../../../tailwind.config.js";
const values = new Map();
const attributes = new Map();
const styleValues = new Map();
const windowListeners = new Map();
const style = {
setProperty: (name, value) => styleValues.set(name, value),
};
globalThis.window = {
addEventListener: (type, listener) => windowListeners.set(type, listener),
};
globalThis.localStorage = {
getItem: (key) => values.get(key) ?? null,
setItem: (key, value) => values.set(key, String(value)),
};
globalThis.document = {
documentElement: {
setAttribute: (name, value) => attributes.set(name, value),
style,
},
};
const preference = await import("./fontSizePreference.ts");
test("scales fixed line-height utilities with the typography rem", () => {
assert.deepEqual(config.theme.extend.lineHeight, {
3: "calc(var(--buzz-type-rem) * 0.75)",
4: "var(--buzz-type-rem)",
5: "calc(var(--buzz-type-rem) * 1.25)",
6: "calc(var(--buzz-type-rem) * 1.5)",
7: "calc(var(--buzz-type-rem) * 1.75)",
8: "calc(var(--buzz-type-rem) * 2)",
"message-author": "var(--conversation-author-line-height)",
});
});
test("defaults invalid and missing font sizes to default", () => {
assert.equal(preference.parseFontSize(null), "default");
assert.equal(preference.parseFontSize("medium"), "default");
assert.equal(preference.parseFontSize("smaller"), "smaller");
assert.equal(preference.parseFontSize("default"), "default");
assert.equal(preference.parseFontSize("larger"), "larger");
});
test("persists and applies the selected font size across the app", () => {
preference.applyTextZoomFactor(1);
preference.setFontSize("smaller");
assert.equal(preference.getFontSize(), "smaller");
assert.equal(values.get(preference.FONT_SIZE_STORAGE_KEY), "smaller");
assert.equal(attributes.get("data-font-size"), "smaller");
assert.equal(styleValues.get("--buzz-type-rem"), "14.857143px");
});
test("previews a font size without changing the saved preference", () => {
preference.applyTextZoomFactor(1.1);
preference.setFontSize("smaller");
preference.previewFontSize("larger");
assert.equal(preference.getFontSize(), "smaller");
assert.equal(values.get(preference.FONT_SIZE_STORAGE_KEY), "smaller");
assert.equal(attributes.get("data-font-size"), "larger");
assert.equal(styleValues.get("--buzz-type-rem"), "18.857143px");
preference.previewFontSize(null);
assert.equal(attributes.get("data-font-size"), "smaller");
assert.equal(styleValues.get("--buzz-type-rem"), "16.342857px");
});
test("initializes from the stored font size", () => {
preference.applyTextZoomFactor(1);
values.set(preference.FONT_SIZE_STORAGE_KEY, "larger");
preference.initializeFontSizePreference();
assert.equal(preference.getFontSize(), "larger");
assert.equal(attributes.get("data-font-size"), "larger");
assert.equal(styleValues.get("--buzz-type-rem"), "17.142857px");
});
test("applies font size changes from another window", () => {
values.set(preference.FONT_SIZE_STORAGE_KEY, "smaller");
windowListeners.get("storage")({ key: preference.FONT_SIZE_STORAGE_KEY });
assert.equal(preference.getFontSize(), "smaller");
assert.equal(attributes.get("data-font-size"), "smaller");
assert.equal(styleValues.get("--buzz-type-rem"), "14.857143px");
});
test("returns to the default when another window clears storage", () => {
preference.setFontSize("larger");
values.clear();
windowListeners.get("storage")({ key: null });
assert.equal(preference.getFontSize(), "default");
assert.equal(attributes.get("data-font-size"), "default");
assert.equal(styleValues.get("--buzz-type-rem"), "16px");
});

View File

@@ -0,0 +1,121 @@
import * as React from "react";
/** Device-level type scale applied throughout the desktop interface. */
export type FontSize = "smaller" | "default" | "larger";
export const FONT_SIZE_STORAGE_KEY = "buzz.appearance.fontSize";
export const DEFAULT_FONT_SIZE: FontSize = "default";
/**
* Virtual rem sizes used by typography tokens. Keeping the real root at 16px
* prevents a text preference from also resizing rem-based layout geometry.
*/
const TYPE_REM_SIZE_PX: Record<FontSize, number> = {
smaller: 13 / 0.875,
default: 14 / 0.875,
larger: 15 / 0.875,
};
const TYPE_REM_PROPERTY = "--buzz-type-rem";
const listeners = new Set<() => void>();
let fontSize: FontSize = DEFAULT_FONT_SIZE;
let textZoomFactor = 1;
let listeningForStorageChanges = false;
export function parseFontSize(value: string | null | undefined): FontSize {
return value === "smaller" || value === "default" || value === "larger"
? value
: DEFAULT_FONT_SIZE;
}
function readStoredFontSize(): FontSize {
try {
return parseFontSize(
globalThis.localStorage?.getItem(FONT_SIZE_STORAGE_KEY),
);
} catch {
return DEFAULT_FONT_SIZE;
}
}
function typeRemSizePx(size: FontSize): number {
return (
Math.round(TYPE_REM_SIZE_PX[size] * textZoomFactor * 1_000_000) / 1_000_000
);
}
function applyFontSize(size: FontSize): void {
const root = globalThis.document?.documentElement;
root?.setAttribute("data-font-size", size);
root?.style.setProperty(TYPE_REM_PROPERTY, `${typeRemSizePx(size)}px`);
}
function notifyListeners(): void {
for (const listener of listeners) listener();
}
function applyStoredFontSize(): void {
const nextSize = readStoredFontSize();
const changed = nextSize !== fontSize;
fontSize = nextSize;
applyFontSize(nextSize);
if (changed) notifyListeners();
}
function listenForStorageChanges(): void {
if (listeningForStorageChanges || !globalThis.window?.addEventListener)
return;
globalThis.window.addEventListener("storage", (event) => {
if (event.key === FONT_SIZE_STORAGE_KEY || event.key === null) {
applyStoredFontSize();
}
});
listeningForStorageChanges = true;
}
/** Apply the persisted preference before React renders to avoid a layout jump. */
export function initializeFontSizePreference(): void {
applyStoredFontSize();
listenForStorageChanges();
}
/** Combine Cmd +/- zoom with the selected app-wide type scale. */
export function applyTextZoomFactor(zoomFactor: number): void {
if (!Number.isFinite(zoomFactor) || zoomFactor <= 0) return;
textZoomFactor = zoomFactor;
applyFontSize(fontSize);
}
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
export function getFontSize(): FontSize {
return fontSize;
}
export function setFontSize(size: FontSize): void {
fontSize = size;
applyFontSize(size);
try {
globalThis.localStorage?.setItem(FONT_SIZE_STORAGE_KEY, size);
} catch {
// Persistence is best-effort; the live preference still applies.
}
notifyListeners();
}
/** Temporarily apply a size without changing the saved preference. */
export function previewFontSize(size: FontSize | null): void {
applyFontSize(size ?? fontSize);
}
export function useFontSize(): FontSize {
return React.useSyncExternalStore(
subscribe,
getFontSize,
() => DEFAULT_FONT_SIZE,
);
}

View File

@@ -7,6 +7,7 @@
@import "./globals/composer.css";
@import "./globals/markdown.css";
@import "./globals/theme.css";
@import "./globals/typography.css";
@import "./globals/skeleton.css";
@import "./globals/spoilers.css";
@import "./globals/components.css";

View File

@@ -132,8 +132,8 @@
width: 100%;
max-width: none;
color: hsl(var(--muted-foreground) / 0.72);
font-size: 0.875rem;
line-height: 1.25rem;
font-size: calc(var(--buzz-type-rem) * 0.875);
line-height: calc(var(--buzz-type-rem) * 1.25);
text-align: center;
opacity: 0;
pointer-events: none;

View File

@@ -558,7 +558,7 @@
.buzz-onboarding-runtime-pill {
animation: buzz-onboarding-runtime-pill-in 180ms
cubic-bezier(0.22, 1, 0.36, 1) both;
font-size: 0.625rem;
font-size: calc(var(--buzz-type-rem) * 0.625);
letter-spacing: 0;
line-height: 1;
text-transform: uppercase;

View File

@@ -93,10 +93,9 @@
}
.rich-text-composer .tiptap {
@apply text-message font-normal tracking-normal;
outline: none;
min-height: 1lh;
font-size: var(--text-sm);
line-height: var(--text-sm--line-height);
}
.rich-text-composer .tiptap p {

View File

@@ -45,7 +45,7 @@
color: hsl(var(--muted-foreground));
display: flex;
font: inherit;
font-size: 0.75rem;
font-size: calc(var(--buzz-type-rem) * 0.75);
font-weight: 500;
gap: 6px;
height: 30px;
@@ -130,7 +130,7 @@
.buzz-terminal-designator {
align-items: center;
display: inline-flex;
font-size: 0.75rem;
font-size: calc(var(--buzz-type-rem) * 0.75);
gap: 2px;
letter-spacing: 0;
}

View File

@@ -0,0 +1,56 @@
@layer base {
:root {
/*
* A virtual typography rem. Font preferences and Cmd +/- change this
* token while the browser root remains 16px, so text scales without also
* resizing rem-based widths, gaps, radii, and controls.
*/
--buzz-type-rem: 1rem;
--text-xs: calc(var(--buzz-type-rem) * 0.75);
--text-sm: calc(var(--buzz-type-rem) * 0.875);
--text-base: var(--buzz-type-rem);
--text-lg: calc(var(--buzz-type-rem) * 1.125);
--text-xl: calc(var(--buzz-type-rem) * 1.25);
--text-2xl: calc(var(--buzz-type-rem) * 1.5);
--text-3xl: calc(var(--buzz-type-rem) * 1.875);
--text-4xl: calc(var(--buzz-type-rem) * 2.25);
--text-5xl: calc(var(--buzz-type-rem) * 3);
--text-6xl: calc(var(--buzz-type-rem) * 3.75);
/*
* Default conversation type and comfy spacing for channels, DMs, threads,
* Inbox, and the composer. Font size changes the type tokens only;
* Conversation density overrides only spacing.
*/
--conversation-message-font-size: calc(var(--buzz-type-rem) * 0.875);
--conversation-message-line-height: calc(var(--buzz-type-rem) * 1.25);
--conversation-author-line-height: var(--buzz-type-rem);
--conversation-body-gap: 0.125rem;
--conversation-row-padding-block: 0.25rem;
--conversation-paragraph-gap: 0.5rem;
--conversation-list-item-gap: 0.375rem;
--conversation-timestamp-font-size: calc(var(--buzz-type-rem) * 0.75);
--conversation-timestamp-line-height: var(--buzz-type-rem);
}
:root[data-conversation-density="compact"] {
--conversation-body-gap: 0rem;
--conversation-row-padding-block: 0.25rem;
--conversation-paragraph-gap: 0.375rem;
--conversation-list-item-gap: 0.25rem;
}
:root[data-conversation-density="spacious"] {
--conversation-body-gap: 0.25rem;
--conversation-row-padding-block: 0.5rem;
--conversation-paragraph-gap: 0.625rem;
--conversation-list-item-gap: 0.5rem;
}
body {
font-synthesis-weight: none;
font-variant-emoji: unicode;
font-variant-ligatures: no-contextual;
text-rendering: optimizeLegibility;
}
}

View File

@@ -1877,10 +1877,10 @@ function MarkdownInner({
className={cn(
MESSAGE_MARKDOWN_CLASS,
[
"max-w-none wrap-anywhere text-sm leading-5 text-foreground",
"max-w-none wrap-anywhere text-message font-normal tracking-normal text-foreground",
"[&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
"[&>*+*]:mt-3",
"[&>p+p]:mt-1.5",
"[&>p+p]:mt-conversation-paragraph [&>ol]:space-y-conversation-list [&>ul]:space-y-conversation-list",
"[&>*+h1]:mt-3.5 [&>*+h2]:mt-3.5 [&>*+h3]:mt-3.5 [&>*+h4]:mt-3.5 [&>*+h5]:mt-3.5 [&>*+h6]:mt-3.5",
"[&>h1+*]:mt-0.5 [&>h2+*]:mt-0.5 [&>h3+*]:mt-0.5 [&>h4+*]:mt-0.5 [&>h5+*]:mt-0.5 [&>h6+*]:mt-0.5",
"[&>h1+h2]:mt-1.5! [&>h2+h3]:mt-1.5! [&>h3+h4]:mt-1.5! [&>h4+h5]:mt-1.5! [&>h5+h6]:mt-1.5!",

View File

@@ -0,0 +1,218 @@
import * as React from "react";
import { cn } from "@/shared/lib/cn";
type SegmentOption<Value extends string> = {
value: Value;
label: string;
Icon?: React.ComponentType<{ className?: string }>;
};
type SegmentedControlSize = "compact" | "default" | "wide";
const SIZE_CLASSES: Record<SegmentedControlSize, string> = {
compact: "w-48",
default: "w-60",
wide: "w-72",
};
/** A mutually exclusive control with equal-width, optionally scrubbable options. */
export function SegmentedControl<Value extends string>({
className,
indicatorTestId,
legend,
onPreviewChange,
onValueChange,
optionTestIdPrefix,
options,
size = "default",
testId,
value,
}: {
className?: string;
indicatorTestId?: string;
legend: string;
onPreviewChange?: (value: Value | null) => void;
onValueChange: (value: Value) => void;
optionTestIdPrefix: string;
options: readonly SegmentOption<Value>[];
size?: SegmentedControlSize;
testId: string;
value: Value;
}) {
const [previewValue, setPreviewValue] = React.useState<Value | null>(null);
const controlRef = React.useRef<HTMLFieldSetElement | null>(null);
const activePointerIdRef = React.useRef<number | null>(null);
const pointerStartXRef = React.useRef<number | null>(null);
const pointerStartValueRef = React.useRef<Value | null>(null);
const scrubValueRef = React.useRef<Value | null>(null);
const skipPointerClickRef = React.useRef(false);
const displayedValue = previewValue ?? value;
const selectedIndex = Math.max(
0,
options.findIndex((option) => option.value === displayedValue),
);
const getValueAtPointer = React.useCallback(
(element: HTMLFieldSetElement, clientX: number): Value => {
const bounds = element.getBoundingClientRect();
const position = Math.max(
0,
Math.min(bounds.width - 1, clientX - bounds.left),
);
const index = Math.min(
options.length - 1,
Math.floor((position / bounds.width) * options.length),
);
return options[index]?.value ?? value;
},
[options, value],
);
const preview = React.useCallback(
(nextValue: Value | null) => {
scrubValueRef.current = nextValue;
setPreviewValue(nextValue);
onPreviewChange?.(nextValue);
},
[onPreviewChange],
);
const cancelScrub = React.useCallback(() => {
const control = controlRef.current;
const pointerId = activePointerIdRef.current;
activePointerIdRef.current = null;
if (control && pointerId != null && control.hasPointerCapture(pointerId)) {
control.releasePointerCapture(pointerId);
}
pointerStartXRef.current = null;
pointerStartValueRef.current = null;
skipPointerClickRef.current = false;
preview(null);
}, [preview]);
React.useEffect(() => {
const handleWindowBlur = () => cancelScrub();
globalThis.addEventListener?.("blur", handleWindowBlur);
return () => {
globalThis.removeEventListener?.("blur", handleWindowBlur);
cancelScrub();
};
}, [cancelScrub]);
const handlePointerDown = (
event: React.PointerEvent<HTMLFieldSetElement>,
) => {
if (!onPreviewChange || event.button !== 0) return;
event.currentTarget.setPointerCapture(event.pointerId);
activePointerIdRef.current = event.pointerId;
pointerStartXRef.current = event.clientX;
pointerStartValueRef.current = getValueAtPointer(
event.currentTarget,
event.clientX,
);
scrubValueRef.current = null;
skipPointerClickRef.current = true;
event.preventDefault();
};
const handlePointerMove = (
event: React.PointerEvent<HTMLFieldSetElement>,
) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return;
const nextValue = getValueAtPointer(event.currentTarget, event.clientX);
const pointerStartX = pointerStartXRef.current;
const pointerStartValue = pointerStartValueRef.current;
const crossedDragThreshold =
pointerStartX != null && Math.abs(event.clientX - pointerStartX) >= 4;
if (
scrubValueRef.current == null &&
!crossedDragThreshold &&
nextValue === pointerStartValue
) {
return;
}
if (nextValue !== scrubValueRef.current) preview(nextValue);
};
const handlePointerUp = (event: React.PointerEvent<HTMLFieldSetElement>) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return;
const nextValue = getValueAtPointer(event.currentTarget, event.clientX);
activePointerIdRef.current = null;
event.currentTarget.releasePointerCapture(event.pointerId);
pointerStartXRef.current = null;
pointerStartValueRef.current = null;
onValueChange(nextValue);
preview(null);
globalThis.setTimeout(() => {
skipPointerClickRef.current = false;
}, 0);
};
const handlePointerCancel = () => cancelScrub();
const handleLostPointerCapture = () => {
if (activePointerIdRef.current != null) cancelScrub();
};
return (
<fieldset
className={cn(
"relative isolate h-8 max-w-full shrink-0 overflow-hidden rounded-md bg-muted/45 p-0.5",
SIZE_CLASSES[size],
onPreviewChange && "touch-none select-none cursor-ew-resize",
className,
)}
data-slot="segmented-control"
data-testid={testId}
onLostPointerCapture={handleLostPointerCapture}
onPointerCancel={handlePointerCancel}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
ref={controlRef}
>
<legend className="sr-only">{legend}</legend>
<div
aria-hidden="true"
className={cn(
"absolute bottom-0.5 left-0.5 top-0.5 z-0 rounded-md bg-background shadow-sm transition-transform duration-200 ease-in-out motion-reduce:transition-none",
previewValue && "duration-0",
)}
data-testid={indicatorTestId ?? `${testId}-indicator`}
style={{
transform: `translateX(${selectedIndex * 100}%)`,
width: `calc((100% - 0.25rem) / ${options.length})`,
}}
/>
{/* 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>
);
}

View File

@@ -3,18 +3,46 @@ export default {
theme: {
extend: {
// Sub-`text-xs` ramp for meta text (timestamps, count badges, tracking
// labels) and tiny glyphs. Defined in rem so Cmd +/- zoom — which scales
// the root <html> font-size — keeps scaling them. Do NOT reintroduce
// arbitrary `text-[…rem]` / `text-[…px]` literals; the px-text guard
// rejects them. Stock scale picks up from here: xs (12px), sm (14px)…
// labels) and tiny glyphs. These follow the virtual typography rem so
// preferences and Cmd +/- scale text without changing layout geometry.
// Do NOT reintroduce arbitrary `text-[…rem]` / `text-[…px]` literals;
// the px-text guard rejects them. Stock scale picks up from xs.
fontSize: {
"2xs": "0.6875rem", // 11px — meta-text workhorse (timestamps, badges)
"3xs": "0.5rem", // 8px — tiny glyphs / micro labels
badge: "0.625rem", // 10px — compact status badges
// 40px — onboarding page titles (tightened tracking for large display type)
title: ["2.5rem", { lineHeight: "1.15", letterSpacing: "-0.02em" }],
// 36px — the backup-step private key, shown large in monospace
"nsec-key": ["2.25rem", { lineHeight: "1.3" }],
"2xs": "calc(var(--buzz-type-rem) * 0.6875)", // 11px at 16px type rem
"3xs": "calc(var(--buzz-type-rem) * 0.5)", // 8px at 16px type rem
badge: "calc(var(--buzz-type-rem) * 0.625)", // 10px at 16px type rem
// Shared channel, DM, thread, and composer type. Variables keep app-wide
// font size and keyboard zoom consistent without branching components.
message: [
"var(--conversation-message-font-size)",
{ lineHeight: "var(--conversation-message-line-height)" },
],
"message-timestamp": [
"var(--conversation-timestamp-font-size)",
{ lineHeight: "var(--conversation-timestamp-line-height)" },
],
// 40px at the 16px type rem — onboarding page titles.
title: [
"calc(var(--buzz-type-rem) * 2.5)",
{ lineHeight: "1.15", letterSpacing: "-0.02em" },
],
// 36px at the 16px type rem — backup-step private key.
"nsec-key": [
"calc(var(--buzz-type-rem) * 2.25)",
{ lineHeight: "1.3" },
],
},
lineHeight: {
// Keep fixed Tailwind line-height utilities in the typography scale so
// Cmd +/- cannot enlarge glyphs inside an unchanged line box. Single-
// line surfaces keep their existing truncate/overflow behavior.
3: "calc(var(--buzz-type-rem) * 0.75)",
4: "var(--buzz-type-rem)",
5: "calc(var(--buzz-type-rem) * 1.25)",
6: "calc(var(--buzz-type-rem) * 1.5)",
7: "calc(var(--buzz-type-rem) * 1.75)",
8: "calc(var(--buzz-type-rem) * 2)",
"message-author": "var(--conversation-author-line-height)",
},
boxShadow: {
"content-edge": "-1px -1px 0 0 hsl(var(--sidebar-border) / 0.45)",
@@ -36,6 +64,10 @@ export default {
},
spacing: {
4.5: "1.125rem",
"conversation-body": "var(--conversation-body-gap)",
"conversation-list": "var(--conversation-list-item-gap)",
"conversation-paragraph": "var(--conversation-paragraph-gap)",
"conversation-row": "var(--conversation-row-padding-block)",
},
fontFamily: {
sans: [

View File

@@ -747,6 +747,8 @@ test("moves agent actions into an overflow menu in a narrow view", async ({
});
await expect(page.getByTestId("agent-defaults-button")).toBeVisible();
// The app-wide default renders text-base at 16px with Tailwind's 1.5
// line-height ratio, producing a 24px one-line scroll height.
await expect(
page.getByText("Set up and manage your agents.", { exact: true }),
).toHaveJSProperty("scrollHeight", 24);

View File

@@ -8,6 +8,8 @@ const THEME_STORAGE_KEY = "buzz-theme";
const GLASS_BACKGROUND_STORAGE_KEY = "buzz-glass-background";
const GLASS_OPACITY_STORAGE_KEY = "buzz-glass-opacity";
const PROMINENT_ACTIVE_TAB_STORAGE_KEY = "buzz-prominent-active-tab";
const CONVERSATION_DENSITY_STORAGE_KEY = "buzz.appearance.conversationDensity";
const FONT_SIZE_STORAGE_KEY = "buzz.appearance.fontSize";
const MOCK_PUBKEY = "deadbeef".repeat(8);
const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
@@ -617,7 +619,7 @@ test("appearance groups theme and preferences into labeled rows", async ({
(lightModeButtonBox.x + lightModeButtonBox.width / 2),
),
).toBeLessThanOrEqual(0.5);
await expect(colorModeIndicator).toHaveCSS("transition-duration", "0.25s");
await expect(colorModeIndicator).toHaveCSS("transition-duration", "0.2s");
await themeCard.getByTestId("appearance-mode-dark").click();
await waitForAnimations(page);
@@ -639,6 +641,512 @@ test("appearance groups theme and preferences into labeled rows", async ({
).toBeLessThanOrEqual(0.5);
});
test("app font size and conversation density apply independently", async ({
page,
}) => {
await seedTheme(page, "buzz");
await installMockBridge(page);
await openAppearance(page, "light");
const root = page.locator("html");
const densityControl = page.getByTestId("conversation-density-control");
const compact = page.getByTestId("conversation-density-compact");
const comfortable = page.getByTestId("conversation-density-comfortable");
const spacious = page.getByTestId("conversation-density-spacious");
const densityIndicator = page.getByTestId(
"conversation-density-control-indicator",
);
const fontSizeControl = page.getByTestId("font-size-control");
const smaller = page.getByTestId("font-size-smaller");
const defaultSize = page.getByTestId("font-size-default");
const larger = page.getByTestId("font-size-larger");
const fontSizeIndicator = page.getByTestId("font-size-control-indicator");
const preview = page.getByTestId("conversation-preview");
const previewSurface = page.getByTestId("conversation-preview-surface");
const previewContent = page.getByTestId("conversation-preview-content");
const previewChip = preview.getByText("Preview");
const firstPreviewMessage = previewSurface.locator("article").first();
const previewMessage = preview.getByText(
"The revised conversation layout is ready to review.",
);
const previewTimestamp = preview.getByText("9:41");
const densityDescription = page
.getByTestId("conversation-density-row")
.locator("[data-settings-subcopy]");
const fontSizeDescription = page
.getByTestId("font-size-row")
.locator("[data-settings-subcopy]");
const readScale = () =>
root.evaluate((element) => {
const style = window.getComputedStyle(element);
return {
authorLineHeight: Number.parseFloat(
style.getPropertyValue("--conversation-author-line-height"),
),
bodyGap: Number.parseFloat(
style.getPropertyValue("--conversation-body-gap"),
),
fontSize: style.getPropertyValue("--conversation-message-font-size"),
lineHeight: style.getPropertyValue(
"--conversation-message-line-height",
),
paragraphGap: Number.parseFloat(
style.getPropertyValue("--conversation-paragraph-gap"),
),
rowPadding: Number.parseFloat(
style.getPropertyValue("--conversation-row-padding-block"),
),
timestampFontSize: style.getPropertyValue(
"--conversation-timestamp-font-size",
),
timestampLineHeight: Number.parseFloat(
style.getPropertyValue("--conversation-timestamp-line-height"),
),
};
});
const readSettingsScale = () =>
page.getByTestId("conversation-density-row").evaluate((element) => {
const rowStyle = window.getComputedStyle(element);
const label = element.querySelector("p");
if (!label) throw new Error("Conversation density label is missing");
const labelStyle = window.getComputedStyle(label);
return {
fontSize: labelStyle.fontSize,
lineHeight: labelStyle.lineHeight,
minHeight: rowStyle.minHeight,
paddingBlock: rowStyle.paddingTop,
};
});
const readPreviewTimestampScale = () =>
previewTimestamp.evaluate((element) => {
const style = window.getComputedStyle(element);
return [style.fontSize, style.lineHeight];
});
const readSettingsChromeScale = () =>
Promise.all([
page
.getByRole("heading", { name: "Appearance" })
.evaluate((element) => window.getComputedStyle(element).fontSize),
page
.getByTestId("settings-nav-appearance")
.evaluate((element) => window.getComputedStyle(element).fontSize),
page
.getByRole("heading", { name: "Preferences" })
.evaluate((element) => window.getComputedStyle(element).fontSize),
]);
await expect(
page.getByRole("group", { name: "Conversation density" }),
).toBeVisible();
await expect(page.getByRole("group", { name: "Font size" })).toBeVisible();
await expect(densityControl).toHaveAccessibleName("Conversation density");
await expect(fontSizeControl).toHaveAccessibleName("Font size");
await expect(comfortable).toHaveText("Comfy");
await expect(defaultSize).toHaveText("Default");
await expect(preview).toContainText("Preview");
await expect(preview).not.toContainText("Message #design");
await expect(comfortable).toHaveAttribute("aria-pressed", "true");
await expect(defaultSize).toHaveAttribute("aria-pressed", "true");
await expect(densityDescription).toHaveText(
"Spacing in conversations and Markdown content across Buzz",
);
await expect(fontSizeDescription).toHaveText(
"Applies across conversations and interface text",
);
await expect.poll(readScale).toEqual({
authorLineHeight: 16,
bodyGap: 0.125,
fontSize: "calc(16px * .875)",
lineHeight: "calc(16px * 1.25)",
paragraphGap: 0.5,
rowPadding: 0.25,
timestampFontSize: "calc(16px * .75)",
timestampLineHeight: 16,
});
await expect
.poll(() =>
Promise.all([
densityControl.evaluate(
(element) => element.getBoundingClientRect().width,
),
fontSizeControl.evaluate(
(element) => element.getBoundingClientRect().width,
),
page
.getByTestId("appearance-color-mode-control")
.evaluate((element) => element.getBoundingClientRect().width),
]),
)
.toEqual([288, 288, 240]);
await expect
.poll(() =>
previewMessage.evaluate((element) => {
const style = window.getComputedStyle(element);
return [style.fontSize, style.lineHeight];
}),
)
.toEqual(["14px", "20px"]);
await expect.poll(readSettingsScale).toEqual({
fontSize: "14px",
lineHeight: "20px",
minHeight: "64px",
paddingBlock: "12px",
});
await expect.poll(readPreviewTimestampScale).toEqual(["12px", "16px"]);
await expect.poll(readSettingsChromeScale).toEqual(["24px", "14px", "14px"]);
await expect(densityIndicator).toHaveCSS("transition-duration", "0.2s");
await expect(densityIndicator).toHaveCSS("transition-property", /transform/);
await expect(fontSizeIndicator).toHaveCSS("transition-duration", "0.2s");
await expect(fontSizeIndicator).toHaveCSS("transition-property", /transform/);
await expect
.poll(async () => {
const [previewBackground, labelBackground, controlBackground] =
await Promise.all([
previewSurface.evaluate(
(element) => window.getComputedStyle(element).backgroundColor,
),
previewChip.evaluate(
(element) => window.getComputedStyle(element).backgroundColor,
),
densityControl.evaluate(
(element) => window.getComputedStyle(element).backgroundColor,
),
]);
return {
labelIsAnnotation: labelBackground !== controlBackground,
previewBackground,
};
})
.toEqual({
labelIsAnnotation: true,
previewBackground: "rgba(0, 0, 0, 0)",
});
const previewSurfaceBox = await previewSurface.boundingBox();
const previewChipBox = await previewChip.boundingBox();
const firstPreviewMessageBox = await firstPreviewMessage.boundingBox();
expect(previewSurfaceBox).not.toBeNull();
expect(previewChipBox).not.toBeNull();
expect(firstPreviewMessageBox).not.toBeNull();
if (!previewSurfaceBox || !previewChipBox || !firstPreviewMessageBox) {
throw new Error("Conversation preview geometry is missing");
}
const previewChipRightInset =
previewSurfaceBox.x +
previewSurfaceBox.width -
(previewChipBox.x + previewChipBox.width);
expect(previewChipRightInset).toBeGreaterThanOrEqual(13);
expect(previewChipRightInset).toBeLessThanOrEqual(15);
const previewChipTopInset = previewChipBox.y - previewSurfaceBox.y;
expect(previewChipTopInset).toBeGreaterThanOrEqual(13);
expect(previewChipTopInset).toBeLessThanOrEqual(15);
await expect(previewContent).toHaveCSS("padding-top", "16px");
await expect(previewContent).toHaveCSS("padding-right", "16px");
await expect(previewContent).toHaveCSS("padding-bottom", "16px");
await expect(previewContent).toHaveCSS("padding-left", "16px");
expect(firstPreviewMessageBox.x - previewSurfaceBox.x).toBeGreaterThanOrEqual(
15,
);
expect(firstPreviewMessageBox.x - previewSurfaceBox.x).toBeLessThanOrEqual(
17,
);
expect(firstPreviewMessageBox.y - previewSurfaceBox.y).toBeGreaterThanOrEqual(
15,
);
expect(firstPreviewMessageBox.y - previewSurfaceBox.y).toBeLessThanOrEqual(
17,
);
await densityIndicator.evaluate((element) => {
element.addEventListener(
"transitionrun",
() => element.setAttribute("data-transition-ran", "true"),
{ once: true },
);
});
await compact.click();
await expect(densityIndicator).toHaveAttribute("data-transition-ran", "true");
await expect(root).toHaveAttribute("data-conversation-density", "compact");
await expect(root).toHaveAttribute("data-font-size", "default");
await expect(compact).toHaveAttribute("aria-pressed", "true");
await expect
.poll(() =>
page.evaluate(
(key) => window.localStorage.getItem(key),
CONVERSATION_DENSITY_STORAGE_KEY,
),
)
.toBe("compact");
await expect.poll(readScale).toEqual({
authorLineHeight: 16,
bodyGap: 0,
fontSize: "calc(16px * .875)",
lineHeight: "calc(16px * 1.25)",
paragraphGap: 0.375,
rowPadding: 0.25,
timestampFontSize: "calc(16px * .75)",
timestampLineHeight: 16,
});
await expect.poll(readSettingsScale).toEqual({
fontSize: "14px",
lineHeight: "20px",
minHeight: "64px",
paddingBlock: "12px",
});
await expect.poll(readPreviewTimestampScale).toEqual(["12px", "16px"]);
await expect.poll(readSettingsChromeScale).toEqual(["24px", "14px", "14px"]);
await larger.click();
await expect(root).toHaveAttribute("data-conversation-density", "compact");
await expect(root).toHaveAttribute("data-font-size", "larger");
await expect(larger).toHaveAttribute("aria-pressed", "true");
await expect
.poll(() =>
page.evaluate(
(key) => window.localStorage.getItem(key),
FONT_SIZE_STORAGE_KEY,
),
)
.toBe("larger");
await expect.poll(readScale).toEqual({
authorLineHeight: 17.142857,
bodyGap: 0,
fontSize: "calc(17.142857px * .875)",
lineHeight: "calc(17.142857px * 1.25)",
paragraphGap: 0.375,
rowPadding: 0.25,
timestampFontSize: "calc(17.142857px * .75)",
timestampLineHeight: 17.142857,
});
await expect
.poll(() =>
previewMessage.evaluate((element) => {
const style = window.getComputedStyle(element);
return [style.fontSize, style.lineHeight];
}),
)
.toEqual(["15px", "21.4286px"]);
await expect.poll(readSettingsScale).toEqual({
fontSize: "15px",
lineHeight: "21.4286px",
minHeight: "64px",
paddingBlock: "12px",
});
await expect
.poll(readPreviewTimestampScale)
.toEqual(["12.8571px", "17.1429px"]);
await expect
.poll(readSettingsChromeScale)
.toEqual(["25.7143px", "15px", "15px"]);
await waitForAnimations(page);
await page.getByTestId("appearance-preferences-card").screenshot({
path: `${SHOTS}/15-conversation-compact-larger.png`,
});
await spacious.click();
await expect(root).toHaveAttribute("data-conversation-density", "spacious");
await expect(root).toHaveAttribute("data-font-size", "larger");
await expect(spacious).toHaveAttribute("aria-pressed", "true");
await expect.poll(readScale).toEqual({
authorLineHeight: 17.142857,
bodyGap: 0.25,
fontSize: "calc(17.142857px * .875)",
lineHeight: "calc(17.142857px * 1.25)",
paragraphGap: 0.625,
rowPadding: 0.5,
timestampFontSize: "calc(17.142857px * .75)",
timestampLineHeight: 17.142857,
});
await expect.poll(readSettingsScale).toEqual({
fontSize: "15px",
lineHeight: "21.4286px",
minHeight: "64px",
paddingBlock: "12px",
});
await expect
.poll(readPreviewTimestampScale)
.toEqual(["12.8571px", "17.1429px"]);
await expect
.poll(readSettingsChromeScale)
.toEqual(["25.7143px", "15px", "15px"]);
await smaller.click();
await expect(root).toHaveAttribute("data-conversation-density", "spacious");
await expect(root).toHaveAttribute("data-font-size", "smaller");
await expect(smaller).toHaveAttribute("aria-pressed", "true");
await expect.poll(readScale).toEqual({
authorLineHeight: 14.857143,
bodyGap: 0.25,
fontSize: "calc(14.857143px * .875)",
lineHeight: "calc(14.857143px * 1.25)",
paragraphGap: 0.625,
rowPadding: 0.5,
timestampFontSize: "calc(14.857143px * .75)",
timestampLineHeight: 14.857143,
});
await expect
.poll(() =>
previewMessage.evaluate((element) => {
const style = window.getComputedStyle(element);
return [style.fontSize, style.lineHeight];
}),
)
.toEqual(["13px", "18.5714px"]);
await expect.poll(readSettingsScale).toEqual({
fontSize: "13px",
lineHeight: "18.5714px",
minHeight: "64px",
paddingBlock: "12px",
});
await expect
.poll(readPreviewTimestampScale)
.toEqual(["11.1429px", "14.8571px"]);
await expect
.poll(readSettingsChromeScale)
.toEqual(["22.2857px", "13px", "13px"]);
await waitForAnimations(page);
await page.getByTestId("appearance-preferences-card").screenshot({
path: `${SHOTS}/16-conversation-spacious-smaller.png`,
});
await comfortable.click();
await defaultSize.click();
await expect(root).toHaveAttribute(
"data-conversation-density",
"comfortable",
);
await expect(comfortable).toHaveAttribute("aria-pressed", "true");
const controlBox = await densityControl.boundingBox();
const compactBox = await compact.boundingBox();
const spaciousBox = await spacious.boundingBox();
expect(controlBox).not.toBeNull();
expect(compactBox).not.toBeNull();
expect(spaciousBox).not.toBeNull();
if (!controlBox || !compactBox || !spaciousBox) {
throw new Error("Conversation density control geometry is missing");
}
await page.mouse.move(
compactBox.x + compactBox.width / 2,
controlBox.y + controlBox.height / 2,
);
await page.mouse.down();
await page.mouse.move(
spaciousBox.x + spaciousBox.width / 2,
controlBox.y + controlBox.height / 2,
);
await expect(root).toHaveAttribute("data-conversation-density", "spacious");
await expect(root).toHaveAttribute("data-font-size", "default");
await expect
.poll(() =>
page.evaluate(
(key) => window.localStorage.getItem(key),
CONVERSATION_DENSITY_STORAGE_KEY,
),
)
.toBe("comfortable");
await expect.poll(readScale).toEqual({
authorLineHeight: 16,
bodyGap: 0.25,
fontSize: "calc(16px * .875)",
lineHeight: "calc(16px * 1.25)",
paragraphGap: 0.625,
rowPadding: 0.5,
timestampFontSize: "calc(16px * .75)",
timestampLineHeight: 16,
});
await expect.poll(readSettingsScale).toEqual({
fontSize: "14px",
lineHeight: "20px",
minHeight: "64px",
paddingBlock: "12px",
});
await page.mouse.up();
await expect(spacious).toHaveAttribute("aria-pressed", "true");
await expect
.poll(() =>
page.evaluate(
(key) => window.localStorage.getItem(key),
CONVERSATION_DENSITY_STORAGE_KEY,
),
)
.toBe("spacious");
await comfortable.click();
const fontSizeControlBox = await fontSizeControl.boundingBox();
const smallerBox = await smaller.boundingBox();
const largerBox = await larger.boundingBox();
expect(fontSizeControlBox).not.toBeNull();
expect(smallerBox).not.toBeNull();
expect(largerBox).not.toBeNull();
if (!fontSizeControlBox || !smallerBox || !largerBox) {
throw new Error("Font size control geometry is missing");
}
await page.mouse.move(
smallerBox.x + smallerBox.width / 2,
fontSizeControlBox.y + fontSizeControlBox.height / 2,
);
await page.mouse.down();
await page.mouse.move(
largerBox.x + largerBox.width / 2,
fontSizeControlBox.y + fontSizeControlBox.height / 2,
);
await expect(root).toHaveAttribute("data-font-size", "larger");
await expect(root).toHaveAttribute(
"data-conversation-density",
"comfortable",
);
await expect
.poll(() =>
page.evaluate(
(key) => window.localStorage.getItem(key),
FONT_SIZE_STORAGE_KEY,
),
)
.toBe("default");
await expect
.poll(() =>
previewMessage.evaluate((element) => {
const style = window.getComputedStyle(element);
return [style.fontSize, style.lineHeight];
}),
)
.toEqual(["15px", "21.4286px"]);
await expect
.poll(readSettingsChromeScale)
.toEqual(["25.7143px", "15px", "15px"]);
// Losing the window during a scrub cancels the temporary preview rather
// than leaving presentation and persisted selection out of sync.
await page.evaluate(() => window.dispatchEvent(new Event("blur")));
await expect(root).toHaveAttribute("data-font-size", "default");
await expect(defaultSize).toHaveAttribute("aria-pressed", "true");
await expect
.poll(() =>
page.evaluate(
(key) => window.localStorage.getItem(key),
FONT_SIZE_STORAGE_KEY,
),
)
.toBe("default");
// Cancellation resets the gesture completely; the next selection persists.
await larger.click();
await expect(larger).toHaveAttribute("aria-pressed", "true");
await expect
.poll(() =>
page.evaluate(
(key) => window.localStorage.getItem(key),
FONT_SIZE_STORAGE_KEY,
),
)
.toBe("larger");
await defaultSize.click();
await waitForAnimations(page);
await page.getByTestId("appearance-preferences-card").screenshot({
path: `${SHOTS}/14-conversation-preferences.png`,
});
});
test("appearance picker — system tab (Buzz follows OS)", async ({ page }) => {
await seedTheme(page, "buzz");
await installMockBridge(page);
@@ -1051,6 +1559,10 @@ test("glass background keeps the content panel solid", async ({ page }) => {
const matchingRadiusControls = [
page.getByTestId("appearance-color-mode-control"),
page.getByTestId("appearance-color-mode-indicator"),
page.getByTestId("font-size-control"),
page.getByTestId("font-size-control-indicator"),
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"),

View File

@@ -116,9 +116,11 @@ test("agent-style message with bare buzz:// links renders entity cards without s
await expect(
repoCard.locator("[data-link-preview-hostname-favicon]"),
).toHaveCount(0);
// Default typography is 14px; keep the image-less card compact while
// allowing fractional line-height rounding across rendering platforms.
expect(
await repoCard.evaluate((card) => card.getBoundingClientRect().height),
).toBeLessThan(84);
).toBeLessThan(90);
await waitForAnimations(page);
await page.screenshot({

View File

@@ -10,7 +10,7 @@
* tests/e2e/inbox-refactor-screenshots.spec.ts
* Output: test-results/inbox-refactor/
*/
import { expect, test } from "@playwright/test";
import { expect, test, type Page } from "@playwright/test";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
@@ -24,11 +24,66 @@ const DM_CHANNEL_ID = "f48efb06-0c93-5025-aac9-2e646bb6bfa8";
// Mock bridge default pubkey — must match DEFAULT_MOCK_PUBKEY in bridge.ts.
const MOCK_PUBKEY = "deadbeef".repeat(8);
const DRAFT_STORE_KEY = `buzz-drafts.v1:${MOCK_PUBKEY}`;
const FONT_SIZE_STORAGE_KEY = "buzz.appearance.fontSize";
const CONVERSATION_DENSITY_STORAGE_KEY = "buzz.appearance.conversationDensity";
// Fixed timestamps so draft ordering renders deterministically.
const DRAFT_CREATED_AT_1 = "2026-07-01T10:00:00.000Z";
const DRAFT_CREATED_AT_2 = "2026-07-02T14:30:00.000Z";
type FontSize = "smaller" | "default" | "larger";
type ConversationDensity = "compact" | "comfortable" | "spacious";
async function seedConversationPreferences(
page: Page,
fontSize: FontSize,
density: ConversationDensity,
) {
await page.addInitScript(
({ densityKey, densityValue, fontSizeKey, fontSizeValue }) => {
window.localStorage.setItem(densityKey, densityValue);
window.localStorage.setItem(fontSizeKey, fontSizeValue);
},
{
densityKey: CONVERSATION_DENSITY_STORAGE_KEY,
densityValue: density,
fontSizeKey: FONT_SIZE_STORAGE_KEY,
fontSizeValue: fontSize,
},
);
}
async function applyConversationPreferences(
page: Page,
fontSize: FontSize,
density: ConversationDensity,
) {
await page.evaluate(
({ densityKey, densityValue, fontSizeKey, fontSizeValue }) => {
const update = (key: string, value: string) => {
const oldValue = window.localStorage.getItem(key);
window.localStorage.setItem(key, value);
window.dispatchEvent(
new StorageEvent("storage", {
key,
newValue: value,
oldValue,
storageArea: window.localStorage,
}),
);
};
update(densityKey, densityValue);
update(fontSizeKey, fontSizeValue);
},
{
densityKey: CONVERSATION_DENSITY_STORAGE_KEY,
densityValue: density,
fontSizeKey: FONT_SIZE_STORAGE_KEY,
fontSizeValue: fontSize,
},
);
}
type MockFeedWindow = Window & {
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
channelName: string;
@@ -273,6 +328,7 @@ test.describe("inbox refactor screenshots", () => {
});
test("04 — thread opens at the oldest unread reply", async ({ page }) => {
await seedConversationPreferences(page, "default", "comfortable");
await installMockBridge(page, { mode: "mock" });
await page.goto("/", { waitUntil: "domcontentloaded" });
@@ -341,6 +397,9 @@ test.describe("inbox refactor screenshots", () => {
const firstUnreadRow = page.getByTestId(`home-inbox-item-${replyIds[0]}`);
await expect(firstUnreadRow).toBeVisible();
const listPreview = firstUnreadRow.locator(".inbox-preview-markdown");
await expect(listPreview).toHaveCSS("font-size", "14px");
await expect(listPreview).toHaveCSS("line-height", "20px");
await firstUnreadRow.click();
const detail = page.getByTestId("home-inbox-detail");
@@ -352,8 +411,172 @@ test.describe("inbox refactor screenshots", () => {
await expect(page.getByTestId("home-inbox-selected-message")).toContainText(
"Started on the changelog — first pass is up.",
);
const selectedMessage = page.getByTestId("home-inbox-selected-message");
const selectedAuthor = selectedMessage.getByTestId("message-author");
const selectedBody = selectedMessage.locator(".message-markdown").first();
const selectedTimestamp = selectedMessage.getByTestId(
"inbox-message-timestamp",
);
const remainingListPreview = page
.locator("[data-testid^='home-inbox-item-']")
.locator(".inbox-preview-markdown")
.first();
const composerInput = page.getByTestId("message-input");
const readConversationMetrics = () =>
Promise.all([
remainingListPreview.evaluate((element) => {
const style = window.getComputedStyle(element);
return {
fontSize: style.fontSize,
lineHeight: style.lineHeight,
};
}),
selectedMessage.evaluate((element) => {
const style = window.getComputedStyle(element);
return {
paddingBottom: style.paddingBottom,
paddingTop: style.paddingTop,
};
}),
selectedMessage.evaluate((element) => {
const header = element.querySelector<HTMLElement>(
"[data-testid='message-header']",
);
const body = element.querySelector<HTMLElement>(
"[data-testid='message-body']",
);
if (!header || !body) {
throw new Error("Inbox message spacing geometry is missing");
}
return (
body.getBoundingClientRect().top -
header.getBoundingClientRect().bottom
);
}),
selectedAuthor.evaluate((element) => {
const style = window.getComputedStyle(element);
return {
fontSize: style.fontSize,
lineHeight: style.lineHeight,
};
}),
selectedBody.evaluate((element) => {
const style = window.getComputedStyle(element);
return {
fontSize: style.fontSize,
lineHeight: style.lineHeight,
};
}),
selectedTimestamp.evaluate((element) => {
const style = window.getComputedStyle(element);
return {
fontSize: style.fontSize,
lineHeight: style.lineHeight,
};
}),
composerInput.evaluate((element) => {
const style = window.getComputedStyle(element);
return {
fontSize: style.fontSize,
lineHeight: style.lineHeight,
};
}),
]);
await expect
.poll(readConversationMetrics)
.toEqual([
{ fontSize: "14px", lineHeight: "20px" },
{ paddingBottom: "4px", paddingTop: "4px" },
2,
{ fontSize: "14px", lineHeight: "16px" },
{ fontSize: "14px", lineHeight: "20px" },
{ fontSize: "12px", lineHeight: "16px" },
{ fontSize: "14px", lineHeight: "20px" },
]);
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/04-thread-context.png` });
await applyConversationPreferences(page, "default", "compact");
await expect
.poll(readConversationMetrics)
.toEqual([
{ fontSize: "14px", lineHeight: "20px" },
{ paddingBottom: "4px", paddingTop: "4px" },
0,
{ fontSize: "14px", lineHeight: "16px" },
{ fontSize: "14px", lineHeight: "20px" },
{ fontSize: "12px", lineHeight: "16px" },
{ fontSize: "14px", lineHeight: "20px" },
]);
await applyConversationPreferences(page, "smaller", "compact");
await expect
.poll(readConversationMetrics)
.toEqual([
{ fontSize: "13px", lineHeight: "18.5714px" },
{ paddingBottom: "4px", paddingTop: "4px" },
0,
{ fontSize: "13px", lineHeight: "14.8571px" },
{ fontSize: "13px", lineHeight: "18.5714px" },
{ fontSize: "11.1429px", lineHeight: "14.8571px" },
{ fontSize: "13px", lineHeight: "18.5714px" },
]);
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/05-thread-context-compact.png` });
await applyConversationPreferences(page, "larger", "spacious");
await expect
.poll(readConversationMetrics)
.toEqual([
{ fontSize: "15px", lineHeight: "21.4286px" },
{ paddingBottom: "8px", paddingTop: "8px" },
4,
{ fontSize: "15px", lineHeight: "17.1429px" },
{ fontSize: "15px", lineHeight: "21.4286px" },
{ fontSize: "12.8571px", lineHeight: "17.1429px" },
{ fontSize: "15px", lineHeight: "21.4286px" },
]);
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/06-thread-context-spacious.png` });
await applyConversationPreferences(page, "default", "comfortable");
await page.evaluate(() => {
const isMac = /mac|iphone|ipad|ipod/i.test(navigator.platform);
window.dispatchEvent(
new KeyboardEvent("keydown", {
bubbles: true,
cancelable: true,
code: "Equal",
ctrlKey: !isMac,
key: "+",
metaKey: isMac,
shiftKey: true,
}),
);
});
await expect
.poll(async () => [
await page.evaluate(() =>
window
.getComputedStyle(document.documentElement)
.getPropertyValue("--buzz-type-rem")
.trim(),
),
...(await readConversationMetrics()),
])
.toEqual([
"17.6px",
{ fontSize: "15.4px", lineHeight: "22px" },
{ paddingBottom: "4px", paddingTop: "4px" },
2,
{ fontSize: "15.4px", lineHeight: "17.6px" },
{ fontSize: "15.4px", lineHeight: "22px" },
{ fontSize: "13.2px", lineHeight: "17.6px" },
{ fontSize: "15.4px", lineHeight: "22px" },
]);
});
});

View File

@@ -304,9 +304,8 @@ test("pairing completion updates the final step and resets after leaving", async
const confirmButton = confirmation.getByTestId("confirm-sas");
const cancelButton = confirmation.getByTestId("deny-sas");
const confirmationBox = await confirmation.boundingBox();
const confirmationTitleBox = await confirmation
.getByTestId("pairing-sas-title")
.boundingBox();
const confirmationTitle = confirmation.getByTestId("pairing-sas-title");
const confirmationTitleBox = await confirmationTitle.boundingBox();
const confirmationCodeBox = await confirmationCode.boundingBox();
const confirmationActionsBox = await confirmation
.getByTestId("pairing-sas-actions")
@@ -345,10 +344,7 @@ test("pairing completion updates the final step and resets after leaving", async
await expect(
confirmation.getByText(/Only confirm if you started this pairing/),
).toHaveCount(0);
await expect(confirmation.getByTestId("pairing-sas-title")).toHaveCSS(
"font-size",
"16px",
);
await expect(confirmationTitle).toHaveCSS("font-size", "16px");
mkdirSync(SCREENSHOT_DIR, { recursive: true });
await waitForAnimations(page);

View File

@@ -2489,7 +2489,10 @@ test("supports webview zoom keyboard shortcuts", async ({ page }) => {
const getTextScaleState = () =>
page.evaluate(() => ({
fontSize: getComputedStyle(document.documentElement).fontSize,
rootFontSize: getComputedStyle(document.documentElement).fontSize,
textRemSize: getComputedStyle(document.documentElement)
.getPropertyValue("--buzz-type-rem")
.trim(),
storedScale: localStorage.getItem("buzz:text-scale"),
webviewZoom: (window as Window & { __BUZZ_E2E_WEBVIEW_ZOOM__?: number })
.__BUZZ_E2E_WEBVIEW_ZOOM__,
@@ -2520,7 +2523,8 @@ test("supports webview zoom keyboard shortcuts", async ({ page }) => {
await dispatchPrimaryShortcut("+", "Equal", true);
await expect.poll(getTextScaleState).toEqual({
fontSize: "17.6px",
rootFontSize: "16px",
textRemSize: "17.6px",
storedScale: "1.1",
webviewZoom: 1,
});
@@ -2528,7 +2532,8 @@ test("supports webview zoom keyboard shortcuts", async ({ page }) => {
await dispatchPrimaryShortcut("-", "Minus");
await expect.poll(getTextScaleState).toEqual({
fontSize: "16px",
rootFontSize: "16px",
textRemSize: "16px",
storedScale: null,
webviewZoom: 1,
});
@@ -2537,7 +2542,8 @@ test("supports webview zoom keyboard shortcuts", async ({ page }) => {
await dispatchPrimaryShortcut("+", "Equal", true);
await expect.poll(getTextScaleState).toEqual({
fontSize: "19.2px",
rootFontSize: "16px",
textRemSize: "19.2px",
storedScale: "1.2",
webviewZoom: 1,
});
@@ -2545,12 +2551,95 @@ test("supports webview zoom keyboard shortcuts", async ({ page }) => {
await dispatchPrimaryShortcut("0", "Digit0");
await expect.poll(getTextScaleState).toEqual({
fontSize: "16px",
rootFontSize: "16px",
textRemSize: "16px",
storedScale: null,
webviewZoom: 1,
});
});
test("storage clear resets composed font size and keyboard zoom across windows", async ({
context,
page,
}) => {
await page.goto("/");
await openSettings(page, "appearance");
await page.getByTestId("font-size-larger").click();
const dispatchZoomIn = () =>
page.evaluate(() => {
const isMac = /mac|iphone|ipad|ipod/i.test(navigator.platform);
window.dispatchEvent(
new KeyboardEvent("keydown", {
bubbles: true,
cancelable: true,
code: "Equal",
ctrlKey: !isMac,
key: "+",
metaKey: isMac,
shiftKey: true,
}),
);
});
for (let step = 0; step < 5; step += 1) {
await dispatchZoomIn();
}
await expect
.poll(() =>
page.evaluate(() => ({
fontSize: document.documentElement.dataset.fontSize,
textRemSize: getComputedStyle(document.documentElement)
.getPropertyValue("--buzz-type-rem")
.trim(),
textScale: localStorage.getItem("buzz:text-scale"),
})),
)
.toEqual({
fontSize: "larger",
textRemSize: "25.714286px",
textScale: "1.5",
});
const peerPage = await context.newPage();
await installMockBridge(peerPage);
await peerPage.goto("/");
await peerPage.evaluate(() => localStorage.clear());
await expect
.poll(() =>
page.evaluate(() => ({
fontSize: document.documentElement.dataset.fontSize,
textRemSize: getComputedStyle(document.documentElement)
.getPropertyValue("--buzz-type-rem")
.trim(),
textScale: localStorage.getItem("buzz:text-scale"),
})),
)
.toEqual({
fontSize: "default",
textRemSize: "16px",
textScale: null,
});
await page.keyboard.press(
process.platform === "darwin" ? "Meta+-" : "Control+-",
);
await expect
.poll(() =>
page.evaluate(() => ({
textRemSize: getComputedStyle(document.documentElement)
.getPropertyValue("--buzz-type-rem")
.trim(),
textScale: localStorage.getItem("buzz:text-scale"),
})),
)
.toEqual({ textRemSize: "14.4px", textScale: "0.9" });
await peerPage.close();
});
test("shows agent runtimes in agent settings", async ({ page }) => {
await page.goto("/");

View File

@@ -23,7 +23,7 @@ const EXPECTED_NAV_CENTER_Y = 23;
// The macOS traffic lights are native chrome: with `trafficLightPosition`
// x:16 they occupy roughly x 16–68 regardless of the app's Cmd +/- text
// zoom. The top-chrome nav row must clear that band in fixed px, so the
// clearance cannot shrink when the root font size scales down.
// clearance cannot change when text scales.
const TRAFFIC_LIGHT_RIGHT_EDGE = 72;
async function spoofMacPlatform(page: import("@playwright/test").Page) {
@@ -90,15 +90,24 @@ async function seedTextScale(
}, scale);
}
async function expectRootFontSize(
async function expectTextRemSize(
page: import("@playwright/test").Page,
fontSize: string,
) {
await expect
.poll(() =>
page.evaluate(() => getComputedStyle(document.documentElement).fontSize),
page.evaluate(() =>
getComputedStyle(document.documentElement)
.getPropertyValue("--buzz-type-rem")
.trim(),
),
)
.toBe(fontSize);
await expect
.poll(() =>
page.evaluate(() => getComputedStyle(document.documentElement).fontSize),
)
.toBe("16px");
}
test.describe("top chrome macOS traffic-light clearance under text zoom", () => {
@@ -143,8 +152,8 @@ test.describe("top chrome macOS traffic-light clearance under text zoom", () =>
await installMockBridge(page);
await page.goto("/");
// Confirm the zoomed-out scale actually applied to the root font size.
await expectRootFontSize(page, "12px");
// Confirm the zoomed-out text scale applied without changing the root.
await expectTextRemSize(page, "12px");
expect(await firstNavButtonX(page)).toBeGreaterThanOrEqual(
TRAFFIC_LIGHT_RIGHT_EDGE,
@@ -161,7 +170,7 @@ test.describe("top chrome macOS traffic-light clearance under text zoom", () =>
await installMockBridge(page);
await page.goto("/");
await expectRootFontSize(page, "24px");
await expectTextRemSize(page, "24px");
expect(await firstNavButtonX(page)).toBeGreaterThanOrEqual(
TRAFFIC_LIGHT_RIGHT_EDGE,