Add conversation density preference

Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
kenny lopez
2026-08-12 10:18:16 +01:00
parent 4b3570671e
commit 74f74e3d37
25 changed files with 1065 additions and 96 deletions
@@ -24,7 +24,7 @@ export const THREAD_FOCUS_SLIVER_WIDTH_PX = 72;
* with auto horizontal margins so the reading measure stays comfortable no
* matter how wide the window gets.
*/
export const THREAD_FOCUS_COLUMN_MAX_WIDTH_PX = 880;
export const THREAD_FOCUS_COLUMN_MAX_WIDTH_PX = 820;
/**
* Horizontal distance the focus drawer travels on enter/exit.
@@ -13,6 +13,7 @@ import {
DraftsPanel,
type DraftViewItem,
} from "@/features/messages/ui/DraftsPanel";
import { MessageAuthorText } from "@/features/messages/ui/MessageHeader";
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
import type { Reminder } from "@/features/reminders/lib/reminderTypes";
import { isDue } from "@/features/reminders/lib/reminderFilters";
@@ -351,7 +352,7 @@ export function InboxListPane({
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-start gap-2">
<span
className="flex min-w-0 flex-1 items-start leading-4"
className="flex min-w-0 flex-1 items-start leading-message-author"
data-inbox-profile-trigger="true"
>
<UserProfilePopover
@@ -360,14 +361,14 @@ export function InboxListPane({
role={profileRole}
triggerElement="span"
>
<span className="block max-w-full truncate rounded text-sm font-semibold leading-4 text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring">
<MessageAuthorText className="block max-w-full">
{item.senderLabel}
</span>
</MessageAuthorText>
</UserProfilePopover>
</span>
<span
className={cn(
"flex shrink-0 items-center gap-1.5 text-xs leading-4 text-muted-foreground/70 transition-opacity group-hover/inbox-item:opacity-0 group-focus-within/inbox-item:opacity-0",
"flex shrink-0 items-center gap-1.5 text-message-timestamp text-muted-foreground/70 transition-opacity group-hover/inbox-item:opacity-0 group-focus-within/inbox-item:opacity-0",
isDone ? "font-normal" : "font-medium",
)}
>
@@ -402,14 +403,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",
)}
>
<Markdown
className="inbox-preview-markdown text-inherit leading-5"
className="inbox-preview-markdown text-inherit"
content={item.preview}
interactive={false}
mentionNames={item.mentionNames}
@@ -8,6 +8,10 @@ import type { TimelineMessage } from "@/features/messages/types";
import { getConfigNudgeAuthorPubkey } from "@/features/messages/ui/configNudgeAuthPubkey";
import { MessageActionBar } from "@/features/messages/ui/MessageActionBar";
import { MessageAgentOwner } from "@/features/messages/ui/MessageAgentOwner";
import {
MessageAuthorText,
MessageHeaderRow,
} from "@/features/messages/ui/MessageHeader";
import { MessageReactions } from "@/features/messages/ui/MessageReactions";
import { UnreadDivider } from "@/features/messages/ui/UnreadDivider";
import { useReactionHandler } from "@/features/messages/ui/useReactionHandler";
@@ -109,7 +113,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}
@@ -151,7 +155,10 @@ 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"
data-testid="inbox-message-timestamp"
>
{hoverTimestampLabel}
</p>
</div>
@@ -175,18 +182,18 @@ export function InboxMessageRow({
</div>
)}
<div className="min-w-0 flex-1">
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
{isContinuation ? null : (
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-0">
<MessageHeaderRow className="gap-x-2">
<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">
<MessageAuthorText className="block max-w-full">
{message.authorLabel}
</span>
</MessageAuthorText>
</UserProfilePopover>
{message.isAgent ? (
<MessageAgentOwner
@@ -194,16 +201,19 @@ export function InboxMessageRow({
ownerPubkey={message.ownerPubkey}
/>
) : null}
<p className="shrink-0 text-xs font-normal tabular-nums text-muted-foreground/55">
<p
className="shrink-0 text-message-timestamp font-normal tabular-nums text-muted-foreground/55"
data-testid="inbox-message-timestamp"
>
{message.fullTimestampLabel}
</p>
</div>
</MessageHeaderRow>
)}
<div className={isContinuation ? "mt-0" : "mt-0.5"}>
<div className={isContinuation ? "mt-0" : "mt-conversation-body"}>
<Markdown
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",
)}
@@ -4,15 +4,15 @@
*/
/** Inline gutter around thread message rows. */
export const THREAD_PANEL_MESSAGE_GUTTER_CLASS = "px-2";
export const THREAD_PANEL_MESSAGE_GUTTER_CLASS = "px-4";
/** Inline gutter around the thread composer and its activity row. */
export const THREAD_PANEL_COMPOSER_GUTTER_CLASS = "px-5";
/**
* Centers the reading column when a `columnMaxWidthPx` is supplied (focus-mode
* drawer). `px-10` (40px) is the inline gutter between the column and the drawer
* edges; the max-width itself is applied inline since it is a caller-provided
* pixel value.
* drawer). The responsive inline gutter keeps compact overlays usable while
* retaining the calmer 40px edge at desktop widths; the max-width itself is
* applied inline since it is a caller-provided pixel value.
*/
export const THREAD_PANEL_COLUMN_CLASS = "mx-auto w-full px-10";
export const THREAD_PANEL_COLUMN_CLASS = "mx-auto w-full px-6 sm:px-10";
@@ -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",
},
@@ -1,5 +1,3 @@
import { Bot } from "lucide-react";
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
export function MessageAgentOwner({
@@ -11,7 +9,7 @@ export function MessageAgentOwner({
}) {
return (
<span
className="inline-flex min-w-0 max-w-56 items-baseline gap-1 text-xs leading-4 text-muted-foreground/65"
className="inline-flex min-w-0 max-w-56 items-baseline gap-1 text-2xs leading-4 text-muted-foreground"
data-testid="message-agent-owner"
>
<span className="sr-only">
@@ -19,30 +17,22 @@ export function MessageAgentOwner({
</span>
{ownerPubkey && ownerLabel ? (
<>
<span
aria-hidden="true"
className="inline-flex shrink-0 items-baseline gap-1 leading-4"
>
<Bot className="relative -top-px h-3.5 w-3.5 self-center" />
<span>managed by</span>
<span aria-hidden="true" className="shrink-0 leading-4">
managed by
</span>
<UserProfilePopover
pubkey={ownerPubkey}
triggerAriaLabel={ownerLabel}
triggerElement="span"
>
<span className="min-w-0 truncate rounded font-semibold text-foreground/85 hover:text-foreground hover:underline focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring">
<span className="min-w-0 truncate rounded font-medium text-muted-foreground hover:text-foreground hover:underline focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring">
{ownerLabel}
</span>
</UserProfilePopover>
</>
) : (
<span
aria-hidden="true"
className="inline-flex min-w-0 items-center gap-1"
>
<Bot className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">owner unavailable</span>
<span aria-hidden="true" className="min-w-0 truncate">
owner unavailable
</span>
)}
</span>
@@ -14,7 +14,7 @@ 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,
)}
>
@@ -39,7 +39,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,
)}
@@ -294,7 +294,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();
const openVideoReviewAt = useOpenVideoReviewAt();
@@ -415,7 +415,7 @@ export const MessageRow = React.memo(
<Markdown
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",
)}
@@ -648,7 +648,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}
@@ -881,7 +881,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
@@ -578,7 +578,7 @@ export function MessageThreadPanel({
</div>
) : (
<div
className={cn(THREAD_PANEL_MESSAGE_GUTTER_CLASS, "pb-1 pt-0")}
className={cn(THREAD_PANEL_MESSAGE_GUTTER_CLASS, "pb-2 pt-1")}
data-testid="message-thread-head"
>
<div className="rounded-2xl">
@@ -636,7 +636,7 @@ export function MessageThreadPanel({
{showThreadHeadDivider ? (
<div
className={cn(THREAD_PANEL_MESSAGE_GUTTER_CLASS, "pb-3 pt-2")}
className={cn(THREAD_PANEL_MESSAGE_GUTTER_CLASS, "pb-4 pt-3")}
data-testid="message-thread-head-divider"
>
<Separator className="bg-border/60" />
@@ -644,7 +644,7 @@ export function MessageThreadPanel({
) : null}
<div
className={cn(THREAD_PANEL_MESSAGE_GUTTER_CLASS, "pb-3 pt-0")}
className={cn(THREAD_PANEL_MESSAGE_GUTTER_CLASS, "pb-4 pt-0")}
data-testid="message-thread-replies"
>
{threadRepliesPending && !isHuddleTranscript ? (
@@ -131,14 +131,14 @@ export function MessageThreadPanelSkeleton({
}
>
<div
className={cn(THREAD_PANEL_MESSAGE_GUTTER_CLASS, "pb-1 pt-0")}
className={cn(THREAD_PANEL_MESSAGE_GUTTER_CLASS, "pb-2 pt-1")}
data-testid="message-thread-head-loading"
>
<ThreadMessageSkeleton isHead />
</div>
<div
className={cn(
"space-y-2.5 pb-3 pt-1",
"space-y-2.5 pb-4 pt-2",
THREAD_PANEL_MESSAGE_GUTTER_CLASS,
)}
>
@@ -34,7 +34,7 @@ export function MessageTimestamp({
<TooltipTrigger asChild>
<p
className={cn(
"shrink-0 cursor-default whitespace-nowrap text-xs font-normal leading-4 tabular-nums text-muted-foreground/55",
"shrink-0 cursor-default whitespace-nowrap text-message-timestamp font-normal tabular-nums text-muted-foreground/55",
className,
)}
data-testid="message-timestamp"
@@ -1,3 +1,4 @@
import type { ReactNode } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ChevronDown } from "lucide-react";
import {
@@ -13,6 +14,12 @@ import {
useLinkPreviewStyle,
type LinkPreviewStyle,
} from "@/shared/lib/linkPreviewStylePreference";
import {
previewConversationDensity,
setConversationDensity,
useConversationDensity,
type ConversationDensity,
} from "@/shared/lib/conversationDensityPreference";
import {
ACCENT_COLORS,
DEFAULT_GLASS_OPACITY,
@@ -31,6 +38,7 @@ import {
} from "@/shared/ui/dropdown-menu";
import { Switch } from "@/shared/ui/switch";
import { SettingsOptionRow } from "./SettingsOptionGroup";
import { SettingsSegmentedControl } from "./SettingsSegmentedControl";
/** Buzz navigation can use either its production tint or a stronger tab. */
export function ProminentActiveTabSetting() {
@@ -79,6 +87,127 @@ const LINK_PREVIEW_STYLE_OPTIONS: {
},
];
const CONVERSATION_DENSITY_OPTIONS: readonly {
value: ConversationDensity;
label: string;
}[] = [
{
value: "compact",
label: "Compact",
},
{
value: "comfortable",
label: "Comfy",
},
{
value: "spacious",
label: "Spacious",
},
];
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 ConversationDensityPreview() {
return (
<div className="px-4 py-3" data-testid="conversation-density-preview">
<div
aria-hidden="true"
className="relative overflow-hidden rounded-xl border border-border/65 bg-muted/45"
data-testid="conversation-density-preview-surface"
>
<span className="absolute right-3 top-3 inline-flex rounded-full border border-border/65 bg-background/70 px-2.5 py-1 text-xs font-medium text-muted-foreground">
Preview
</span>
<div className="p-4" data-testid="conversation-density-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>
);
}
/** Message sizing and spacing shared by every conversation surface. */
export function ConversationDensitySetting() {
const density = useConversationDensity();
return (
<>
<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
>
Adjust message size and spacing.
</p>
</div>
<SettingsSegmentedControl
className="w-72"
legend="Conversation density"
onPreviewChange={previewConversationDensity}
onValueChange={setConversationDensity}
optionTestIdPrefix="conversation-density"
options={CONVERSATION_DENSITY_OPTIONS}
testId="conversation-density-control"
value={density}
/>
</SettingsOptionRow>
<ConversationDensityPreview />
</>
);
}
export function LinkPreviewStyleSetting() {
const style = useLinkPreviewStyle();
const activeOption =
@@ -55,6 +55,7 @@ import {
import { appearanceCommunityLabel } from "../lib/appearanceScopeCopy";
import {
AccentPickerContent,
ConversationDensitySetting,
GlassBackgroundSetting,
LinkPreviewStyleSetting,
ProminentActiveTabSetting,
@@ -70,6 +71,7 @@ import { NotificationSettingsCard } from "./NotificationSettingsCard";
import { AgentsSettingsPanel } from "./AgentsSettingsPanel";
import { HostedCommunitiesSettingsCard } from "./HostedCommunitiesSettingsCard";
import { SettingsOptionGroup, SettingsOptionRow } from "./SettingsOptionGroup";
import { SettingsSegmentedControl } from "./SettingsSegmentedControl";
import { ProfileSettingsCard } from "./ProfileSettingsCard";
import { UpdateChecker } from "../UpdateChecker";
import { SettingsSectionHeader } from "./SettingsSectionHeader";
@@ -395,9 +397,9 @@ function SingleThemeTile({
type AppearanceMode = "system" | "light" | "dark";
const APPEARANCE_MODE_OPTIONS = [
{ mode: "system" as const, label: "System", Icon: SunMoon },
{ mode: "light" as const, label: "Light", Icon: Sun },
{ mode: "dark" as const, label: "Dark", Icon: Moon },
{ value: "system" as const, label: "System", Icon: SunMoon },
{ value: "light" as const, label: "Light", Icon: Sun },
{ value: "dark" as const, label: "Dark", Icon: Moon },
] as const;
// Reveal/hide motion for the accent picker: a small translate + opacity fade.
@@ -670,39 +672,15 @@ 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>
<SettingsSegmentedControl
indicatorTestId="appearance-color-mode-indicator"
legend="Color mode"
onValueChange={handleModeSelect}
optionTestIdPrefix="appearance-mode"
options={APPEARANCE_MODE_OPTIONS}
testId="appearance-color-mode-control"
value={selectedMode}
/>
</SettingsOptionRow>
<SettingsOptionRow data-testid="theme-style-row">
@@ -804,6 +782,7 @@ function ThemeSettingsCard() {
data-testid="appearance-preferences-card"
title="Preferences"
>
<ConversationDensitySetting />
<LinkPreviewStyleSetting />
<ThreadLayoutSetting />
</SettingsOptionGroup>
@@ -0,0 +1,185 @@
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 }>;
};
export function SettingsSegmentedControl<Value extends string>({
className,
indicatorTestId,
legend,
onPreviewChange,
onValueChange,
optionTestIdPrefix,
options,
testId,
value,
}: {
className?: string;
indicatorTestId?: string;
legend: string;
onPreviewChange?: (value: Value | null) => void;
onValueChange: (value: Value) => void;
optionTestIdPrefix: string;
options: readonly SegmentOption<Value>[];
testId: string;
value: Value;
}) {
const [previewValue, setPreviewValue] = React.useState<Value | 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],
);
React.useEffect(
() => () => {
onPreviewChange?.(null);
},
[onPreviewChange],
);
const handlePointerDown = (
event: React.PointerEvent<HTMLFieldSetElement>,
) => {
if (!onPreviewChange || event.button !== 0) return;
event.currentTarget.setPointerCapture(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);
event.currentTarget.releasePointerCapture(event.pointerId);
pointerStartXRef.current = null;
pointerStartValueRef.current = null;
onValueChange(nextValue);
preview(null);
globalThis.setTimeout(() => {
skipPointerClickRef.current = false;
}, 0);
};
const handlePointerCancel = (
event: React.PointerEvent<HTMLFieldSetElement>,
) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return;
event.currentTarget.releasePointerCapture(event.pointerId);
pointerStartXRef.current = null;
pointerStartValueRef.current = null;
skipPointerClickRef.current = false;
preview(null);
};
return (
<fieldset
className={cn(
"relative isolate grid h-8 w-60 shrink-0 grid-cols-3 overflow-hidden rounded-md bg-muted/45 p-0.5",
onPreviewChange && "touch-none select-none cursor-ew-resize",
className,
)}
data-testid={testId}
onPointerCancel={handlePointerCancel}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
>
<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})`,
}}
/>
{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>
))}
</fieldset>
);
}
+4 -1
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,7 @@ 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";
type E2eWindow = Window & {
__BUZZ_E2E__?: unknown;
@@ -123,6 +125,7 @@ async function bootstrap() {
resetDevWebviewStateFromUrl();
configureDevE2eBridgeFromUrl();
recoverLocalStorageQuotaOnStartup();
initializeConversationDensityPreference();
startLocalStorageSweep();
await installE2eBridgeIfConfigured();
await migrateLegacyCommunityStorageBeforeRender();
+14 -2
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));
}
@@ -0,0 +1,59 @@
import assert from "node:assert/strict";
import test from "node:test";
const values = new Map();
const attributes = new Map();
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");
});
@@ -0,0 +1,84 @@
import * as React from "react";
/** Device-level message sizing and 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;
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();
}
/** Apply the persisted preference before React renders to avoid a layout jump. */
export function initializeConversationDensityPreference(): void {
const nextDensity = readStoredConversationDensity();
const changed = nextDensity !== conversationDensity;
conversationDensity = nextDensity;
applyConversationDensity(nextDensity);
if (changed) notifyListeners();
}
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,
);
}
@@ -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 {
@@ -2,6 +2,20 @@
:root {
/* Catppuccin Latte (mauve accent) */
--radius: 0.625rem;
/*
* Comfortable conversation rhythm for channels, DMs, threads, Inbox, and
* the composer. The Appearance preference overrides this complete scale
* below, while rem units keep Cmd +/- text scaling intact.
*/
--conversation-message-font-size: 0.9375rem;
--conversation-message-line-height: 1.25rem;
--conversation-author-line-height: 1.0625rem;
--conversation-body-gap: 0.125rem;
--conversation-row-padding-block: 0.375rem;
--conversation-paragraph-gap: 0.5rem;
--conversation-list-item-gap: 0.375rem;
--conversation-timestamp-font-size: 0.6875rem;
--conversation-timestamp-line-height: 1rem;
--background: 220 23.08% 94.9%;
--foreground: 234 16.02% 35.49%;
--card: 220 23.08% 94.9%;
@@ -65,6 +79,30 @@
--buzz-hosted-community-identity-bg: 255 255 255;
}
:root[data-conversation-density="compact"] {
--conversation-message-font-size: 0.875rem;
--conversation-message-line-height: 1.1875rem;
--conversation-author-line-height: 1rem;
--conversation-body-gap: 0rem;
--conversation-row-padding-block: 0.25rem;
--conversation-paragraph-gap: 0.375rem;
--conversation-list-item-gap: 0.25rem;
--conversation-timestamp-font-size: 0.625rem;
--conversation-timestamp-line-height: 0.875rem;
}
:root[data-conversation-density="spacious"] {
--conversation-message-font-size: 1rem;
--conversation-message-line-height: 1.375rem;
--conversation-author-line-height: 1.125rem;
--conversation-body-gap: 0.25rem;
--conversation-row-padding-block: 0.5rem;
--conversation-paragraph-gap: 0.625rem;
--conversation-list-item-gap: 0.5rem;
--conversation-timestamp-font-size: 0.75rem;
--conversation-timestamp-line-height: 1.125rem;
}
.dark {
/* Catppuccin Macchiato (mauve accent) */
--radius: 0.625rem;
@@ -153,6 +191,10 @@
body {
@apply bg-background text-foreground antialiased;
font-family: "Inter Variable", Inter, "Avenir Next", "Segoe UI", sans-serif;
font-synthesis-weight: none;
font-variant-emoji: unicode;
font-variant-ligatures: no-contextual;
text-rendering: optimizeLegibility;
}
/*
+2 -2
View File
@@ -1893,10 +1893,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!",
+18
View File
@@ -11,11 +11,25 @@ export default {
"2xs": "0.6875rem", // 11px — meta-text workhorse (timestamps, badges)
"3xs": "0.5rem", // 8px — tiny glyphs / micro labels
badge: "0.625rem", // 10px — compact status badges
// Shared channel, DM, thread, and composer type. Backed by CSS
// variables so the Appearance preference can change the complete
// conversation rhythm without branching individual 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 — 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" }],
},
lineHeight: {
"message-author": "var(--conversation-author-line-height)",
},
boxShadow: {
"content-edge": "-1px -1px 0 0 hsl(var(--sidebar-border) / 0.45)",
// Edge + elevation for a surface anchored to the right of the content
@@ -36,6 +50,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: [
@@ -8,6 +8,7 @@ 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 MOCK_PUBKEY = "deadbeef".repeat(8);
const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
@@ -616,6 +617,329 @@ test("appearance groups theme and preferences into labeled rows", async ({
).toBeLessThanOrEqual(0.5);
});
test("conversation density applies and persists all three scales", async ({
page,
}) => {
await seedTheme(page, "buzz");
await installMockBridge(page);
await openAppearance(page, "light");
const root = page.locator("html");
const control = 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 indicator = page.getByTestId("conversation-density-control-indicator");
const preview = page.getByTestId("conversation-density-preview");
const previewSurface = page.getByTestId(
"conversation-density-preview-surface",
);
const previewContent = page.getByTestId(
"conversation-density-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 description = page
.getByTestId("conversation-density-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: Number.parseFloat(
style.getPropertyValue("--conversation-message-font-size"),
),
lineHeight: Number.parseFloat(
style.getPropertyValue("--conversation-message-line-height"),
),
paragraphGap: Number.parseFloat(
style.getPropertyValue("--conversation-paragraph-gap"),
),
rowPadding: Number.parseFloat(
style.getPropertyValue("--conversation-row-padding-block"),
),
timestampFontSize: Number.parseFloat(
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(control).toHaveAccessibleName("Conversation density");
await expect(comfortable).toHaveText("Comfy");
await expect(preview).toContainText("Preview");
await expect(preview).not.toContainText("Message #design");
await expect(comfortable).toHaveAttribute("aria-pressed", "true");
await expect(description).toHaveText("Adjust message size and spacing.");
await expect.poll(readScale).toEqual({
authorLineHeight: 1.0625,
bodyGap: 0.125,
fontSize: 0.9375,
lineHeight: 1.25,
paragraphGap: 0.5,
rowPadding: 0.375,
timestampFontSize: 0.6875,
timestampLineHeight: 1,
});
await expect
.poll(() =>
Promise.all([
control.evaluate((element) => element.getBoundingClientRect().width),
page
.getByTestId("appearance-color-mode-control")
.evaluate((element) => element.getBoundingClientRect().width),
]),
)
.toEqual([288, 240]);
await expect
.poll(() =>
previewMessage.evaluate((element) => {
const style = window.getComputedStyle(element);
return [style.fontSize, style.lineHeight];
}),
)
.toEqual(["15px", "20px"]);
await expect.poll(readSettingsScale).toEqual({
fontSize: "14px",
lineHeight: "20px",
minHeight: "64px",
paddingBlock: "12px",
});
await expect.poll(readPreviewTimestampScale).toEqual(["11px", "16px"]);
await expect.poll(readSettingsChromeScale).toEqual(["24px", "14px", "18px"]);
await expect(indicator).toHaveCSS("transition-duration", "0.2s");
await expect(indicator).toHaveCSS("transition-property", /transform/);
await expect
.poll(async () => {
const [previewBackground, controlBackground] = await Promise.all([
previewSurface.evaluate(
(element) => window.getComputedStyle(element).backgroundColor,
),
control.evaluate(
(element) => window.getComputedStyle(element).backgroundColor,
),
]);
return previewBackground === controlBackground;
})
.toBe(true);
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 density preview geometry is missing");
}
const previewChipRightInset =
previewSurfaceBox.x +
previewSurfaceBox.width -
(previewChipBox.x + previewChipBox.width);
expect(previewChipRightInset).toBeGreaterThanOrEqual(12);
expect(previewChipRightInset).toBeLessThanOrEqual(13);
const previewChipTopInset = previewChipBox.y - previewSurfaceBox.y;
expect(previewChipTopInset).toBeGreaterThanOrEqual(12);
expect(previewChipTopInset).toBeLessThanOrEqual(13);
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(
16,
);
expect(firstPreviewMessageBox.x - previewSurfaceBox.x).toBeLessThanOrEqual(
18,
);
expect(firstPreviewMessageBox.y - previewSurfaceBox.y).toBeGreaterThanOrEqual(
16,
);
expect(firstPreviewMessageBox.y - previewSurfaceBox.y).toBeLessThanOrEqual(
18,
);
await expect(previewChip).toHaveCSS("padding-top", "4px");
await expect(previewChip).toHaveCSS("padding-bottom", "4px");
await indicator.evaluate((element) => {
element.addEventListener(
"transitionrun",
() => element.setAttribute("data-transition-ran", "true"),
{ once: true },
);
});
await compact.click();
await expect(indicator).toHaveAttribute("data-transition-ran", "true");
await expect(root).toHaveAttribute("data-conversation-density", "compact");
await expect(compact).toHaveAttribute("aria-pressed", "true");
await expect(description).toHaveText("Adjust message size and spacing.");
await expect
.poll(() =>
page.evaluate(
(key) => window.localStorage.getItem(key),
CONVERSATION_DENSITY_STORAGE_KEY,
),
)
.toBe("compact");
await expect.poll(readScale).toEqual({
authorLineHeight: 1,
bodyGap: 0,
fontSize: 0.875,
lineHeight: 1.1875,
paragraphGap: 0.375,
rowPadding: 0.25,
timestampFontSize: 0.625,
timestampLineHeight: 0.875,
});
await expect.poll(readSettingsScale).toEqual({
fontSize: "14px",
lineHeight: "20px",
minHeight: "64px",
paddingBlock: "12px",
});
await expect.poll(readPreviewTimestampScale).toEqual(["10px", "14px"]);
await expect.poll(readSettingsChromeScale).toEqual(["24px", "14px", "18px"]);
await waitForAnimations(page);
await page.getByTestId("appearance-preferences-card").screenshot({
path: `${SHOTS}/15-conversation-density-compact.png`,
});
await spacious.click();
await expect(root).toHaveAttribute("data-conversation-density", "spacious");
await expect(spacious).toHaveAttribute("aria-pressed", "true");
await expect(description).toHaveText("Adjust message size and spacing.");
await expect.poll(readScale).toEqual({
authorLineHeight: 1.125,
bodyGap: 0.25,
fontSize: 1,
lineHeight: 1.375,
paragraphGap: 0.625,
rowPadding: 0.5,
timestampFontSize: 0.75,
timestampLineHeight: 1.125,
});
await expect.poll(readSettingsScale).toEqual({
fontSize: "14px",
lineHeight: "20px",
minHeight: "64px",
paddingBlock: "12px",
});
await expect.poll(readPreviewTimestampScale).toEqual(["12px", "18px"]);
await expect.poll(readSettingsChromeScale).toEqual(["24px", "14px", "18px"]);
await waitForAnimations(page);
await page.getByTestId("appearance-preferences-card").screenshot({
path: `${SHOTS}/16-conversation-density-spacious.png`,
});
await comfortable.click();
await expect(root).toHaveAttribute(
"data-conversation-density",
"comfortable",
);
await expect(comfortable).toHaveAttribute("aria-pressed", "true");
const controlBox = await control.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
.poll(() =>
page.evaluate(
(key) => window.localStorage.getItem(key),
CONVERSATION_DENSITY_STORAGE_KEY,
),
)
.toBe("comfortable");
await expect
.poll(() =>
previewMessage.evaluate((element) => {
const style = window.getComputedStyle(element);
return [style.fontSize, style.lineHeight];
}),
)
.toEqual(["16px", "22px"]);
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();
await waitForAnimations(page);
await page.getByTestId("appearance-preferences-card").screenshot({
path: `${SHOTS}/14-conversation-density.png`,
});
});
test("appearance picker — system tab (Buzz follows OS)", async ({ page }) => {
await seedTheme(page, "buzz");
await installMockBridge(page);
@@ -1028,6 +1352,8 @@ 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("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"),
@@ -341,6 +341,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", "15px");
await expect(listPreview).toHaveCSS("line-height", "20px");
await firstUnreadRow.click();
const detail = page.getByTestId("home-inbox-detail");
@@ -352,8 +355,137 @@ 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,
};
}),
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: "15px", lineHeight: "20px" },
{ paddingBottom: "6px", paddingTop: "6px" },
{ fontSize: "15px", lineHeight: "17px" },
{ fontSize: "15px", lineHeight: "20px" },
{ fontSize: "11px", lineHeight: "16px" },
{ fontSize: "15px", lineHeight: "20px" },
]);
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/04-thread-context.png` });
await page.locator("html").evaluate((root) => {
root.setAttribute("data-conversation-density", "compact");
});
await expect.poll(readConversationMetrics).toEqual([
{ fontSize: "14px", lineHeight: "19px" },
{ paddingBottom: "4px", paddingTop: "4px" },
{ fontSize: "14px", lineHeight: "16px" },
{ fontSize: "14px", lineHeight: "19px" },
{ fontSize: "10px", lineHeight: "14px" },
{ fontSize: "14px", lineHeight: "19px" },
]);
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/05-thread-context-compact.png` });
await page.locator("html").evaluate((root) => {
root.setAttribute("data-conversation-density", "spacious");
});
await expect.poll(readConversationMetrics).toEqual([
{ fontSize: "16px", lineHeight: "22px" },
{ paddingBottom: "8px", paddingTop: "8px" },
{ fontSize: "16px", lineHeight: "18px" },
{ fontSize: "16px", lineHeight: "22px" },
{ fontSize: "12px", lineHeight: "18px" },
{ fontSize: "16px", lineHeight: "22px" },
]);
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/06-thread-context-spacious.png` });
await page.locator("html").evaluate((root) => {
root.setAttribute("data-conversation-density", "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).fontSize,
),
...(await readConversationMetrics()),
])
.toEqual([
"17.6px",
{ fontSize: "16.5px", lineHeight: "22px" },
{ paddingBottom: "6.6px", paddingTop: "6.6px" },
{ fontSize: "16.5px", lineHeight: "18.7px" },
{ fontSize: "16.5px", lineHeight: "22px" },
{ fontSize: "12.1px", lineHeight: "17.6px" },
{ fontSize: "16.5px", lineHeight: "22px" },
]);
});
});
+2 -2
View File
@@ -333,11 +333,11 @@ test("agent owner label identifies the agent and owner", async ({ page }) => {
.filter({ hasText: "Hey team — checking in." });
const ownerTreatment = aliceMessage.getByTestId("message-agent-owner");
await expect(ownerTreatment.locator("svg")).toBeVisible();
await expect(ownerTreatment.locator("svg")).toHaveCount(0);
await expect(
ownerTreatment.getByText("managed by", { exact: true }),
).toBeVisible();
await expect(ownerTreatment.locator(".font-semibold")).toHaveText("bob");
await expect(ownerTreatment.locator(".font-medium")).toHaveText("bob");
await expect(ownerTreatment.getByRole("button")).toHaveAccessibleName("bob");
await expect(ownerTreatment.locator(".sr-only")).toHaveText(
"Agent managed by",