mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Add conversation density preference
Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -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!",
|
||||
|
||||
@@ -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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user