mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
Add font size and conversation density preferences (#5644)
## Summary - Add separate **Font size** and **Conversation density** controls in Appearance. - Use a 13 / 14 / 15px conversation text ramp for Smaller / Default / Larger while scaling interface typography through a shared virtual-rem system. - Keep layout geometry fixed while typography changes, and compose Cmd +/- text zoom on top of the selected preference. - Let Compact, Comfy, and Spacious control conversation row spacing plus paragraph and list rhythm in Markdown content across Buzz. - Preview font size and density together with click-and-drag comparison; interrupted scrubbing restores the saved preference. - Synchronize preferences across open Buzz windows, including full local-storage clears during sign-out or dev reset. - Promote the segmented control to shared UI and make Settings rows adapt to their card width. ## Validation - `just ci` - 4,965+ desktop unit tests across the final merged branch - 1,465 mobile tests through the full repository gate - Focused Playwright coverage for font-size/density independence, drag preview cancellation, Inbox geometry, thread rendering, keyboard zoom, and cross-surface typography - Fresh E2E build against the final merged branch --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com> Signed-off-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz> Co-authored-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz> Co-authored-by: morgmart <98432065+morgmart@users.noreply.github.com>
This commit is contained in:
17
AGENTS.md
17
AGENTS.md
@@ -476,11 +476,18 @@ are frozen.**
|
||||
So for any readable text, reach for rem-based Tailwind tokens, never arbitrary
|
||||
px:
|
||||
|
||||
- ✅ Stock rem tokens (`text-base`, `text-sm`, `text-xs`, …). **Chat body/author
|
||||
text === `text-base` (16px) — chat is the app's base type size**, and the
|
||||
surrounding timeline elements (timestamps, system rows, code, reactions) are
|
||||
deliberate steps on that same stock ramp.
|
||||
- ✅ The `text-2xs` (0.6875rem / 11px) and `text-3xs` (0.5rem / 8px) meta-text
|
||||
- ✅ Stock rem tokens (`text-base`, `text-sm`, `text-xs`, …) for general
|
||||
interface text. All of these derive from the virtual typography rem and
|
||||
therefore follow the user's font-size preference and Cmd +/- zoom.
|
||||
- ✅ Conversation text uses the named `text-message` token. Its
|
||||
**Smaller / Default / Larger contract is 13 / 14 / 15px** before keyboard
|
||||
zoom. Author names use the same conversation-size step; timestamps, system
|
||||
rows, code, and reactions are deliberate neighboring steps on the shared
|
||||
virtual-rem ramp. Keep those relationships tokenized rather than restoring a
|
||||
fixed 16px chat baseline or hardcoding preference-specific values in
|
||||
components.
|
||||
- ✅ The `text-2xs` (0.6875rem / 11px at a 16px virtual rem) and `text-3xs`
|
||||
(0.5rem / 8px at a 16px virtual rem) meta-text
|
||||
tokens (in `desktop/tailwind.config.js` under `theme.extend.fontSize`) for the
|
||||
sub-`text-xs` ramp — timestamps, count badges, tracking labels, tiny glyphs.
|
||||
These replaced the dozens of arbitrary `text-[…rem]` literals that had drifted
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import * as React from "react";
|
||||
import { getCurrentWebview } from "@tauri-apps/api/webview";
|
||||
|
||||
import { applyTextZoomFactor } from "@/shared/lib/fontSizePreference";
|
||||
import { hasPrimaryShortcutModifier } from "@/shared/lib/platform";
|
||||
|
||||
const DEFAULT_ZOOM_FACTOR = 1;
|
||||
const MIN_ZOOM_FACTOR = 0.75;
|
||||
const MAX_ZOOM_FACTOR = 1.5;
|
||||
const ZOOM_STEP = 0.1;
|
||||
const BASE_FONT_SIZE_PX = 16;
|
||||
const TEXT_SCALE_STORAGE_KEY = "buzz:text-scale";
|
||||
|
||||
type ZoomAction = "increase" | "decrease" | "reset";
|
||||
@@ -76,13 +76,12 @@ function readStoredZoomFactor() {
|
||||
}
|
||||
|
||||
function applyTextScale(zoomFactor: number) {
|
||||
applyTextZoomFactor(zoomFactor);
|
||||
if (zoomFactor === DEFAULT_ZOOM_FACTOR) {
|
||||
document.documentElement.style.fontSize = "";
|
||||
window.localStorage.removeItem(TEXT_SCALE_STORAGE_KEY);
|
||||
return;
|
||||
}
|
||||
|
||||
document.documentElement.style.fontSize = `${BASE_FONT_SIZE_PX * zoomFactor}px`;
|
||||
window.localStorage.setItem(TEXT_SCALE_STORAGE_KEY, String(zoomFactor));
|
||||
}
|
||||
|
||||
@@ -120,9 +119,21 @@ export function useWebviewZoomShortcuts() {
|
||||
applyTextScale(nextZoomFactor);
|
||||
}
|
||||
|
||||
function handleStorage(event: StorageEvent) {
|
||||
if (event.key !== TEXT_SCALE_STORAGE_KEY && event.key !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const storedZoomFactor = readStoredZoomFactor();
|
||||
zoomFactorRef.current = storedZoomFactor;
|
||||
applyTextZoomFactor(storedZoomFactor);
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
window.addEventListener("storage", handleStorage);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
window.removeEventListener("storage", handleStorage);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
|
||||
@@ -433,14 +433,14 @@ export function InboxListPane({
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"mt-1.5 text-sm leading-5 [&_a]:font-medium [&_a]:text-current",
|
||||
"mt-1.5 text-message [&_a]:font-medium [&_a]:text-current",
|
||||
isDone
|
||||
? "font-normal text-muted-foreground"
|
||||
: "font-semibold text-foreground",
|
||||
)}
|
||||
>
|
||||
<VideoReviewCommentMarkdown
|
||||
className="inbox-preview-markdown text-inherit leading-5"
|
||||
className="inbox-preview-markdown text-inherit"
|
||||
content={item.preview}
|
||||
interactive={false}
|
||||
mentionNames={item.mentionNames}
|
||||
|
||||
@@ -115,7 +115,8 @@ export function InboxMessageRow({
|
||||
});
|
||||
const timestampNode = (
|
||||
<p
|
||||
className="shrink-0 text-xs font-normal tabular-nums text-muted-foreground/55"
|
||||
className="shrink-0 text-message-timestamp font-normal tabular-nums text-muted-foreground/55"
|
||||
data-testid="inbox-message-timestamp"
|
||||
title={message.fullTimestampLabel}
|
||||
>
|
||||
{timestampLabel}
|
||||
@@ -138,7 +139,7 @@ export function InboxMessageRow({
|
||||
) : null}
|
||||
<article
|
||||
className={cn(
|
||||
"group/message relative z-10 mx-1 flex gap-2.5 rounded-2xl px-2 py-1 transition-colors hover:bg-muted/50 focus-within:bg-muted/50",
|
||||
"group/message relative z-10 mx-1 flex gap-2.5 rounded-2xl px-2 py-conversation-row transition-colors hover:bg-muted/50 focus-within:bg-muted/50",
|
||||
isContinuation ? "items-center" : "items-start",
|
||||
)}
|
||||
data-message-id={message.id}
|
||||
@@ -181,7 +182,7 @@ export function InboxMessageRow({
|
||||
className="flex w-9 shrink-0 self-stretch items-start justify-end pt-0.5"
|
||||
title={message.fullTimestampLabel}
|
||||
>
|
||||
<p className="shrink-0 cursor-default whitespace-nowrap text-xs font-normal leading-4 tabular-nums text-muted-foreground/55 opacity-0 transition-opacity group-hover/message:opacity-100 group-focus-within/message:opacity-100">
|
||||
<p className="shrink-0 cursor-default whitespace-nowrap text-message-timestamp font-normal tabular-nums text-muted-foreground/55 opacity-0 transition-opacity group-hover/message:opacity-100 group-focus-within/message:opacity-100">
|
||||
{hoverTimestampLabel}
|
||||
</p>
|
||||
</div>
|
||||
@@ -207,14 +208,20 @@ export function InboxMessageRow({
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
{isContinuation ? null : (
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-0">
|
||||
<div
|
||||
className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-0"
|
||||
data-testid="message-header"
|
||||
>
|
||||
<UserProfilePopover
|
||||
botIdenticonValue={message.authorLabel}
|
||||
pubkey={message.authorPubkey}
|
||||
role={profileRole}
|
||||
triggerElement="span"
|
||||
>
|
||||
<span className="block max-w-full truncate rounded text-sm font-semibold text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring">
|
||||
<span
|
||||
className="block max-w-full truncate rounded text-message font-semibold leading-message-author text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
|
||||
data-testid="message-author"
|
||||
>
|
||||
{message.authorLabel}
|
||||
</span>
|
||||
</UserProfilePopover>
|
||||
@@ -240,10 +247,13 @@ export function InboxMessageRow({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={isContinuation ? "mt-0" : "mt-0.5"}>
|
||||
<div
|
||||
className={isContinuation ? "mt-0" : "mt-conversation-body"}
|
||||
data-testid="message-body"
|
||||
>
|
||||
<VideoReviewCommentMarkdown
|
||||
className={cn(
|
||||
"max-w-full text-left text-sm text-foreground",
|
||||
"max-w-full text-left text-message text-foreground",
|
||||
emojiOnly &&
|
||||
"text-4xl leading-tight [&_p]:leading-tight [&_img[data-custom-emoji]]:h-[1.45em] [&_img[data-custom-emoji]]:align-middle [&_button:has(img[data-custom-emoji])]:align-middle",
|
||||
)}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
}
|
||||
|
||||
.buzz-diff-theme .diff {
|
||||
font-size: 0.75rem;
|
||||
font-size: calc(var(--buzz-type-rem) * 0.75);
|
||||
}
|
||||
|
||||
.buzz-diff-theme .diff td {
|
||||
@@ -68,7 +68,7 @@
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-right: 1px solid hsl(var(--border) / 0.65);
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 0.6875rem;
|
||||
font-size: calc(var(--buzz-type-rem) * 0.6875);
|
||||
}
|
||||
|
||||
.buzz-diff-theme .buzz-diff-code {
|
||||
@@ -86,7 +86,7 @@
|
||||
padding: 0.2rem 0.75rem;
|
||||
background: hsl(var(--muted) / 0.35);
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 0.6875rem;
|
||||
font-size: calc(var(--buzz-type-rem) * 0.6875);
|
||||
}
|
||||
|
||||
.buzz-diff-theme .diff-gutter-omit::before {
|
||||
|
||||
@@ -14,9 +14,10 @@ export function MessageHeaderRow({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 flex-wrap items-baseline gap-x-1.5 gap-y-0 leading-4",
|
||||
"flex min-w-0 flex-wrap items-baseline gap-x-1.5 gap-y-0 leading-message-author",
|
||||
className,
|
||||
)}
|
||||
data-testid="message-header"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
@@ -93,7 +94,7 @@ export function MessageAuthorText({
|
||||
return (
|
||||
<Component
|
||||
className={cn(
|
||||
"truncate text-sm font-bold leading-4 tracking-tight",
|
||||
"truncate text-message font-semibold leading-message-author tracking-normal",
|
||||
hoverUnderline && "hover:underline",
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -295,7 +295,7 @@ export const MessageRow = React.memo(
|
||||
message.body,
|
||||
message.tags,
|
||||
);
|
||||
const bodyOffsetClass = emojiOnly ? "mt-1" : "-mt-0.5";
|
||||
const bodyOffsetClass = emojiOnly ? "mt-1" : "mt-conversation-body";
|
||||
|
||||
const { nonDmChannelNames: channelNames } = useChannelNavigation();
|
||||
|
||||
@@ -411,7 +411,7 @@ export const MessageRow = React.memo(
|
||||
<VideoReviewCommentMarkdown
|
||||
channelNames={channelNames}
|
||||
className={cn(
|
||||
"max-w-full text-sm",
|
||||
"max-w-full text-message",
|
||||
emojiOnly &&
|
||||
"text-4xl leading-tight [&_p]:leading-tight [&_img[data-custom-emoji]]:h-[1.45em] [&_img[data-custom-emoji]]:align-middle [&_button:has(img[data-custom-emoji])]:align-middle",
|
||||
)}
|
||||
@@ -635,7 +635,7 @@ export const MessageRow = React.memo(
|
||||
botIdenticonValue={message.author}
|
||||
>
|
||||
<button
|
||||
className="truncate rounded leading-4 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
|
||||
className="truncate rounded leading-message-author focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
|
||||
type="button"
|
||||
>
|
||||
{authorNode}
|
||||
@@ -868,7 +868,7 @@ export const MessageRow = React.memo(
|
||||
className={cn(
|
||||
"group/message relative z-10 rounded-2xl transition-colors",
|
||||
playEntrance && "motion-enter-conversation",
|
||||
"py-1",
|
||||
"py-conversation-row",
|
||||
hoverBackground
|
||||
? "mx-1 px-2 hover:bg-muted/50 focus-within:bg-muted/50"
|
||||
: isThreadReplyLayout
|
||||
@@ -888,17 +888,21 @@ export const MessageRow = React.memo(
|
||||
{isThreadReplyLayout ? (
|
||||
<>
|
||||
{avatarGutterNode}
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
{headerNode}
|
||||
<div className={bodyContainerClass}>{messageBodyNode}</div>
|
||||
<div className={bodyContainerClass} data-testid="message-body">
|
||||
{messageBodyNode}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{avatarGutterNode}
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
{headerNode}
|
||||
<div className={bodyContainerClass}>{messageBodyNode}</div>
|
||||
<div className={bodyContainerClass} data-testid="message-body">
|
||||
{messageBodyNode}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -5,7 +5,14 @@ import {
|
||||
} from "@/features/messages/lib/dateFormatters";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { formatItemTimestamp } from "@/shared/lib/datetime";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/shared/ui/tooltip";
|
||||
|
||||
const TIMESTAMP_TOOLTIP_DELAY_MS = 500;
|
||||
|
||||
/**
|
||||
* The timestamp beside a message author, and the clock that fades in over the
|
||||
@@ -43,21 +50,26 @@ export function MessageTimestamp({
|
||||
: formatItemTimestamp(createdAt, { withTime: true });
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<p
|
||||
className={cn(
|
||||
"shrink-0 cursor-default whitespace-nowrap text-xs font-normal leading-4 tabular-nums text-muted-foreground/55",
|
||||
className,
|
||||
)}
|
||||
data-testid="message-timestamp"
|
||||
>
|
||||
{displayTime}
|
||||
</p>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{formatFullDateTime(createdAt)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<TooltipProvider
|
||||
delayDuration={TIMESTAMP_TOOLTIP_DELAY_MS}
|
||||
skipDelayDuration={0}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<p
|
||||
className={cn(
|
||||
"shrink-0 cursor-default whitespace-nowrap text-message-timestamp font-normal tabular-nums text-muted-foreground/55",
|
||||
className,
|
||||
)}
|
||||
data-testid="message-timestamp"
|
||||
>
|
||||
{displayTime}
|
||||
</p>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{formatFullDateTime(createdAt)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { ChevronDown, Eye } from "lucide-react";
|
||||
import {
|
||||
setThreadViewMode,
|
||||
useThreadViewMode,
|
||||
@@ -14,6 +15,18 @@ import {
|
||||
type LinkPreviewStyle,
|
||||
} from "@/shared/lib/linkPreviewStylePreference";
|
||||
import { isLinuxPlatform } from "@/shared/lib/platform";
|
||||
import {
|
||||
previewConversationDensity,
|
||||
setConversationDensity,
|
||||
useConversationDensity,
|
||||
type ConversationDensity,
|
||||
} from "@/shared/lib/conversationDensityPreference";
|
||||
import {
|
||||
previewFontSize,
|
||||
setFontSize,
|
||||
useFontSize,
|
||||
type FontSize,
|
||||
} from "@/shared/lib/fontSizePreference";
|
||||
import {
|
||||
ACCENT_COLORS,
|
||||
DEFAULT_GLASS_OPACITY,
|
||||
@@ -22,6 +35,7 @@ import {
|
||||
NEUTRAL_ACCENT,
|
||||
useTheme,
|
||||
} from "@/shared/theme/ThemeProvider";
|
||||
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -32,6 +46,7 @@ import {
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import { Switch } from "@/shared/ui/switch";
|
||||
import { SettingsOptionRow } from "./SettingsOptionGroup";
|
||||
import { SegmentedControl } from "@/shared/ui/segmented-control";
|
||||
|
||||
/** Buzz navigation can use either its production tint or a stronger tab. */
|
||||
export function ProminentActiveTabSetting() {
|
||||
@@ -71,15 +86,177 @@ const LINK_PREVIEW_STYLE_OPTIONS: {
|
||||
{
|
||||
value: "compact",
|
||||
label: "Compact",
|
||||
description: "Show links as compact horizontal cards",
|
||||
description: "Small cards with a thumbnail",
|
||||
},
|
||||
{
|
||||
value: "rich",
|
||||
label: "Rich",
|
||||
description: "Unfurl links with larger images and descriptions",
|
||||
description: "Large previews with images and descriptions",
|
||||
},
|
||||
];
|
||||
|
||||
const CONVERSATION_DENSITY_OPTIONS: readonly {
|
||||
value: ConversationDensity;
|
||||
label: string;
|
||||
}[] = [
|
||||
{
|
||||
value: "compact",
|
||||
label: "Compact",
|
||||
},
|
||||
{
|
||||
value: "comfortable",
|
||||
label: "Comfy",
|
||||
},
|
||||
{
|
||||
value: "spacious",
|
||||
label: "Spacious",
|
||||
},
|
||||
];
|
||||
|
||||
const FONT_SIZE_OPTIONS: readonly {
|
||||
value: FontSize;
|
||||
label: string;
|
||||
}[] = [
|
||||
{
|
||||
value: "smaller",
|
||||
label: "Smaller",
|
||||
},
|
||||
{
|
||||
value: "default",
|
||||
label: "Default",
|
||||
},
|
||||
{
|
||||
value: "larger",
|
||||
label: "Larger",
|
||||
},
|
||||
];
|
||||
|
||||
function ConversationDensityPreviewMessage({
|
||||
avatar,
|
||||
author,
|
||||
children,
|
||||
timestamp,
|
||||
}: {
|
||||
avatar: string;
|
||||
author: string;
|
||||
children: ReactNode;
|
||||
timestamp: string;
|
||||
}) {
|
||||
return (
|
||||
<article className="flex gap-2.5 py-conversation-row">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold text-muted-foreground">
|
||||
{avatar}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 flex-wrap items-baseline gap-x-1.5 leading-message-author">
|
||||
<span className="text-message font-semibold leading-message-author tracking-normal text-foreground">
|
||||
{author}
|
||||
</span>
|
||||
<span className="text-message-timestamp font-normal text-muted-foreground/65">
|
||||
{timestamp}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-conversation-body text-message font-normal tracking-normal text-foreground">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function ConversationPreview() {
|
||||
return (
|
||||
<div className="px-4 py-3" data-testid="conversation-preview">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="relative overflow-hidden rounded-xl border border-border/65 bg-transparent"
|
||||
data-testid="conversation-preview-surface"
|
||||
>
|
||||
<span className="absolute right-3.5 top-3 inline-flex items-center gap-1 text-2xs font-medium text-muted-foreground/55">
|
||||
<Eye aria-hidden="true" className="size-3" />
|
||||
Preview
|
||||
</span>
|
||||
<div className="p-4" data-testid="conversation-preview-content">
|
||||
<ConversationDensityPreviewMessage
|
||||
avatar="M"
|
||||
author="Maya"
|
||||
timestamp="9:41"
|
||||
>
|
||||
The revised conversation layout is ready to review.
|
||||
</ConversationDensityPreviewMessage>
|
||||
<ConversationDensityPreviewMessage
|
||||
avatar="T"
|
||||
author="Theo"
|
||||
timestamp="9:43"
|
||||
>
|
||||
<p>
|
||||
I added a longer message so you can compare line height and text
|
||||
spacing.
|
||||
</p>
|
||||
<p className="mt-conversation-paragraph">
|
||||
The same rhythm carries through channels, threads, DMs, and Inbox.
|
||||
</p>
|
||||
</ConversationDensityPreviewMessage>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** App-wide type sizing and conversation-specific spacing controls. */
|
||||
export function ConversationDisplaySettings() {
|
||||
const density = useConversationDensity();
|
||||
const fontSize = useFontSize();
|
||||
|
||||
return (
|
||||
<div data-testid="conversation-display-group">
|
||||
<SettingsOptionRow data-testid="font-size-row">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">Font size</p>
|
||||
<p
|
||||
className="text-sm font-normal text-muted-foreground/70"
|
||||
data-settings-subcopy
|
||||
>
|
||||
Applies across conversations and interface text
|
||||
</p>
|
||||
</div>
|
||||
<SegmentedControl
|
||||
size="wide"
|
||||
legend="Font size"
|
||||
onPreviewChange={previewFontSize}
|
||||
onValueChange={setFontSize}
|
||||
optionTestIdPrefix="font-size"
|
||||
options={FONT_SIZE_OPTIONS}
|
||||
testId="font-size-control"
|
||||
value={fontSize}
|
||||
/>
|
||||
</SettingsOptionRow>
|
||||
<SettingsOptionRow data-testid="conversation-density-row">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">Conversation density</p>
|
||||
<p
|
||||
className="text-sm font-normal text-muted-foreground/70"
|
||||
data-settings-subcopy
|
||||
>
|
||||
Spacing in conversations and Markdown content across Buzz
|
||||
</p>
|
||||
</div>
|
||||
<SegmentedControl
|
||||
size="wide"
|
||||
legend="Conversation density"
|
||||
onPreviewChange={previewConversationDensity}
|
||||
onValueChange={setConversationDensity}
|
||||
optionTestIdPrefix="conversation-density"
|
||||
options={CONVERSATION_DENSITY_OPTIONS}
|
||||
testId="conversation-density-control"
|
||||
value={density}
|
||||
/>
|
||||
</SettingsOptionRow>
|
||||
<ConversationPreview />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LinkPreviewStyleSetting() {
|
||||
const style = useLinkPreviewStyle();
|
||||
const activeOption =
|
||||
@@ -150,7 +327,7 @@ const THREAD_VIEW_MODE_OPTIONS: {
|
||||
{
|
||||
value: "focus",
|
||||
label: "Focus",
|
||||
description: "Threads open over the channel, full width",
|
||||
description: "Threads open over the channel",
|
||||
},
|
||||
{
|
||||
value: "split",
|
||||
|
||||
@@ -45,6 +45,7 @@ export function SettingsOptionGroup({
|
||||
) : null}
|
||||
<div
|
||||
className={cn(
|
||||
"[container-type:inline-size]",
|
||||
surface === "soft"
|
||||
? "overflow-hidden rounded-2xl bg-muted/20"
|
||||
: "divide-y divide-border/55 overflow-hidden rounded-xl border border-border/70 bg-background/70",
|
||||
@@ -77,7 +78,7 @@ export function SettingsOptionRow({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-16 items-center justify-between gap-4 px-4 py-3 text-sm",
|
||||
"flex min-h-16 items-center justify-between gap-4 px-4 py-3 text-sm [@container(max-width:34rem)]:flex-col [@container(max-width:34rem)]:items-start [@container(max-width:34rem)]:[&>[data-slot=segmented-control]]:w-full",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
import { appearanceCommunityLabel } from "../lib/appearanceScopeCopy";
|
||||
import {
|
||||
AccentPickerContent,
|
||||
ConversationDisplaySettings,
|
||||
GlassBackgroundSetting,
|
||||
LinkPreviewStyleSetting,
|
||||
ProminentActiveTabSetting,
|
||||
@@ -74,6 +75,7 @@ import {
|
||||
SettingsOptionGroupList,
|
||||
SettingsOptionRow,
|
||||
} from "./SettingsOptionGroup";
|
||||
import { SegmentedControl } from "@/shared/ui/segmented-control";
|
||||
import { ProfileSettingsCard } from "./ProfileSettingsCard";
|
||||
import { UpdateChecker } from "../UpdateChecker";
|
||||
import { SettingsSectionHeader } from "./SettingsSectionHeader";
|
||||
@@ -674,39 +676,19 @@ function ThemeSettingsCard() {
|
||||
Follow your system or choose a light or dark appearance.
|
||||
</p>
|
||||
</div>
|
||||
<fieldset
|
||||
className="relative isolate grid h-8 w-[15rem] shrink-0 grid-cols-3 overflow-hidden rounded-md bg-muted/45 p-0.5"
|
||||
data-testid="appearance-color-mode-control"
|
||||
>
|
||||
<legend className="sr-only">Color mode</legend>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute bottom-0.5 left-0.5 top-0.5 z-0 rounded-md bg-background shadow-sm transition-transform duration-[250ms] ease-out motion-reduce:transition-none"
|
||||
data-testid="appearance-color-mode-indicator"
|
||||
style={{
|
||||
transform: `translateX(${APPEARANCE_MODE_OPTIONS.findIndex((option) => option.mode === selectedMode) * 100}%)`,
|
||||
width: "calc((100% - 4px) / 3)",
|
||||
}}
|
||||
/>
|
||||
{APPEARANCE_MODE_OPTIONS.map(({ mode, label, Icon }) => (
|
||||
<button
|
||||
aria-pressed={selectedMode === mode}
|
||||
className={cn(
|
||||
"relative z-10 flex h-full items-center justify-center gap-1.5 rounded-md bg-transparent px-2.5 text-xs font-medium transition-colors duration-[250ms] ease-out focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none",
|
||||
selectedMode === mode
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
data-testid={`appearance-mode-${mode}`}
|
||||
key={mode}
|
||||
onClick={() => handleModeSelect(mode)}
|
||||
type="button"
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</fieldset>
|
||||
<SegmentedControl
|
||||
indicatorTestId="appearance-color-mode-indicator"
|
||||
legend="Color mode"
|
||||
onValueChange={handleModeSelect}
|
||||
optionTestIdPrefix="appearance-mode"
|
||||
options={APPEARANCE_MODE_OPTIONS.map(({ mode, label, Icon }) => ({
|
||||
value: mode,
|
||||
label,
|
||||
Icon,
|
||||
}))}
|
||||
testId="appearance-color-mode-control"
|
||||
value={selectedMode}
|
||||
/>
|
||||
</SettingsOptionRow>
|
||||
|
||||
<SettingsOptionRow data-testid="theme-style-row">
|
||||
@@ -808,6 +790,7 @@ function ThemeSettingsCard() {
|
||||
data-testid="appearance-preferences-card"
|
||||
title="Preferences"
|
||||
>
|
||||
<ConversationDisplaySettings />
|
||||
<LinkPreviewStyleSetting />
|
||||
<ThreadLayoutSetting />
|
||||
</SettingsOptionGroup>
|
||||
|
||||
@@ -3,7 +3,8 @@ import ReactDOM from "react-dom/client";
|
||||
import { App } from "@/app/App";
|
||||
import { RootErrorBoundary } from "@/app/RootErrorBoundary";
|
||||
import { NostrBindConsentDialog } from "@/features/profile/ui/NostrBindConsentDialog";
|
||||
import "@fontsource-variable/inter/wght.css";
|
||||
import "@fontsource-variable/inter/opsz.css";
|
||||
import "@fontsource-variable/inter/opsz-italic.css";
|
||||
import "@fontsource/jetbrains-mono/400.css";
|
||||
import "@fontsource/jetbrains-mono/700.css";
|
||||
import "@/shared/styles/globals.css";
|
||||
@@ -19,6 +20,8 @@ import { Toaster } from "@/shared/ui/sonner";
|
||||
import { TooltipProvider } from "@/shared/ui/tooltip";
|
||||
import { recoverLocalStorageQuotaOnStartup } from "@/shared/lib/localStorageQuota";
|
||||
import { startLocalStorageSweep } from "@/shared/lib/localStorageSweep";
|
||||
import { initializeConversationDensityPreference } from "@/shared/lib/conversationDensityPreference";
|
||||
import { initializeFontSizePreference } from "@/shared/lib/fontSizePreference";
|
||||
|
||||
type E2eWindow = Window & {
|
||||
__BUZZ_E2E__?: unknown;
|
||||
@@ -123,6 +126,8 @@ async function bootstrap() {
|
||||
resetDevWebviewStateFromUrl();
|
||||
configureDevE2eBridgeFromUrl();
|
||||
recoverLocalStorageQuotaOnStartup();
|
||||
initializeConversationDensityPreference();
|
||||
initializeFontSizePreference();
|
||||
startLocalStorageSweep();
|
||||
await installE2eBridgeIfConfigured();
|
||||
await migrateLegacyCommunityStorageBeforeRender();
|
||||
|
||||
@@ -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,80 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
const values = new Map();
|
||||
const attributes = new Map();
|
||||
const windowListeners = new Map();
|
||||
|
||||
globalThis.window = {
|
||||
addEventListener: (type, listener) => windowListeners.set(type, listener),
|
||||
};
|
||||
globalThis.localStorage = {
|
||||
getItem: (key) => values.get(key) ?? null,
|
||||
setItem: (key, value) => values.set(key, String(value)),
|
||||
};
|
||||
globalThis.document = {
|
||||
documentElement: {
|
||||
setAttribute: (name, value) => attributes.set(name, value),
|
||||
},
|
||||
};
|
||||
|
||||
const preference = await import("./conversationDensityPreference.ts");
|
||||
|
||||
test("defaults invalid and missing conversation densities to comfortable", () => {
|
||||
assert.equal(preference.parseConversationDensity(null), "comfortable");
|
||||
assert.equal(preference.parseConversationDensity("dense"), "comfortable");
|
||||
assert.equal(preference.parseConversationDensity("compact"), "compact");
|
||||
assert.equal(
|
||||
preference.parseConversationDensity("comfortable"),
|
||||
"comfortable",
|
||||
);
|
||||
assert.equal(preference.parseConversationDensity("spacious"), "spacious");
|
||||
});
|
||||
|
||||
test("persists and applies the selected conversation density", () => {
|
||||
preference.setConversationDensity("compact");
|
||||
assert.equal(preference.getConversationDensity(), "compact");
|
||||
assert.equal(
|
||||
values.get(preference.CONVERSATION_DENSITY_STORAGE_KEY),
|
||||
"compact",
|
||||
);
|
||||
assert.equal(attributes.get("data-conversation-density"), "compact");
|
||||
});
|
||||
|
||||
test("previews a density without changing the saved preference", () => {
|
||||
preference.setConversationDensity("compact");
|
||||
preference.previewConversationDensity("spacious");
|
||||
assert.equal(preference.getConversationDensity(), "compact");
|
||||
assert.equal(
|
||||
values.get(preference.CONVERSATION_DENSITY_STORAGE_KEY),
|
||||
"compact",
|
||||
);
|
||||
assert.equal(attributes.get("data-conversation-density"), "spacious");
|
||||
|
||||
preference.previewConversationDensity(null);
|
||||
assert.equal(attributes.get("data-conversation-density"), "compact");
|
||||
});
|
||||
|
||||
test("initializes from the persisted conversation density", () => {
|
||||
values.set(preference.CONVERSATION_DENSITY_STORAGE_KEY, "spacious");
|
||||
preference.initializeConversationDensityPreference();
|
||||
assert.equal(preference.getConversationDensity(), "spacious");
|
||||
assert.equal(attributes.get("data-conversation-density"), "spacious");
|
||||
});
|
||||
|
||||
test("applies conversation density changes from another window", () => {
|
||||
values.set(preference.CONVERSATION_DENSITY_STORAGE_KEY, "compact");
|
||||
windowListeners.get("storage")({
|
||||
key: preference.CONVERSATION_DENSITY_STORAGE_KEY,
|
||||
});
|
||||
assert.equal(preference.getConversationDensity(), "compact");
|
||||
assert.equal(attributes.get("data-conversation-density"), "compact");
|
||||
});
|
||||
|
||||
test("returns to comfortable when another window clears storage", () => {
|
||||
preference.setConversationDensity("spacious");
|
||||
values.clear();
|
||||
windowListeners.get("storage")({ key: null });
|
||||
assert.equal(preference.getConversationDensity(), "comfortable");
|
||||
assert.equal(attributes.get("data-conversation-density"), "comfortable");
|
||||
});
|
||||
101
desktop/src/shared/lib/conversationDensityPreference.ts
Normal file
101
desktop/src/shared/lib/conversationDensityPreference.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import * as React from "react";
|
||||
|
||||
/** Device-level spacing used across conversation surfaces. */
|
||||
export type ConversationDensity = "compact" | "comfortable" | "spacious";
|
||||
|
||||
export const CONVERSATION_DENSITY_STORAGE_KEY =
|
||||
"buzz.appearance.conversationDensity";
|
||||
export const DEFAULT_CONVERSATION_DENSITY: ConversationDensity = "comfortable";
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
let conversationDensity: ConversationDensity = DEFAULT_CONVERSATION_DENSITY;
|
||||
let listeningForStorageChanges = false;
|
||||
|
||||
export function parseConversationDensity(
|
||||
value: string | null | undefined,
|
||||
): ConversationDensity {
|
||||
return value === "compact" || value === "comfortable" || value === "spacious"
|
||||
? value
|
||||
: DEFAULT_CONVERSATION_DENSITY;
|
||||
}
|
||||
|
||||
function readStoredConversationDensity(): ConversationDensity {
|
||||
try {
|
||||
return parseConversationDensity(
|
||||
globalThis.localStorage?.getItem(CONVERSATION_DENSITY_STORAGE_KEY),
|
||||
);
|
||||
} catch {
|
||||
return DEFAULT_CONVERSATION_DENSITY;
|
||||
}
|
||||
}
|
||||
|
||||
function applyConversationDensity(density: ConversationDensity): void {
|
||||
globalThis.document?.documentElement?.setAttribute(
|
||||
"data-conversation-density",
|
||||
density,
|
||||
);
|
||||
}
|
||||
|
||||
function notifyListeners(): void {
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
function applyStoredConversationDensity(): void {
|
||||
const nextDensity = readStoredConversationDensity();
|
||||
const changed = nextDensity !== conversationDensity;
|
||||
conversationDensity = nextDensity;
|
||||
applyConversationDensity(nextDensity);
|
||||
if (changed) notifyListeners();
|
||||
}
|
||||
|
||||
function listenForStorageChanges(): void {
|
||||
if (listeningForStorageChanges || !globalThis.window?.addEventListener)
|
||||
return;
|
||||
globalThis.window.addEventListener("storage", (event) => {
|
||||
if (event.key === CONVERSATION_DENSITY_STORAGE_KEY || event.key === null) {
|
||||
applyStoredConversationDensity();
|
||||
}
|
||||
});
|
||||
listeningForStorageChanges = true;
|
||||
}
|
||||
|
||||
/** Apply the persisted preference before React renders to avoid a layout jump. */
|
||||
export function initializeConversationDensityPreference(): void {
|
||||
applyStoredConversationDensity();
|
||||
listenForStorageChanges();
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
export function getConversationDensity(): ConversationDensity {
|
||||
return conversationDensity;
|
||||
}
|
||||
|
||||
export function setConversationDensity(density: ConversationDensity): void {
|
||||
conversationDensity = density;
|
||||
applyConversationDensity(density);
|
||||
try {
|
||||
globalThis.localStorage?.setItem(CONVERSATION_DENSITY_STORAGE_KEY, density);
|
||||
} catch {
|
||||
// Persistence is best-effort; the live preference still applies.
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/** Temporarily apply a density without changing the saved preference. */
|
||||
export function previewConversationDensity(
|
||||
density: ConversationDensity | null,
|
||||
): void {
|
||||
applyConversationDensity(density ?? conversationDensity);
|
||||
}
|
||||
|
||||
export function useConversationDensity(): ConversationDensity {
|
||||
return React.useSyncExternalStore(
|
||||
subscribe,
|
||||
getConversationDensity,
|
||||
() => DEFAULT_CONVERSATION_DENSITY,
|
||||
);
|
||||
}
|
||||
97
desktop/src/shared/lib/fontSizePreference.test.mjs
Normal file
97
desktop/src/shared/lib/fontSizePreference.test.mjs
Normal file
@@ -0,0 +1,97 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import config from "../../../tailwind.config.js";
|
||||
|
||||
const values = new Map();
|
||||
const attributes = new Map();
|
||||
const styleValues = new Map();
|
||||
const windowListeners = new Map();
|
||||
const style = {
|
||||
setProperty: (name, value) => styleValues.set(name, value),
|
||||
};
|
||||
|
||||
globalThis.window = {
|
||||
addEventListener: (type, listener) => windowListeners.set(type, listener),
|
||||
};
|
||||
globalThis.localStorage = {
|
||||
getItem: (key) => values.get(key) ?? null,
|
||||
setItem: (key, value) => values.set(key, String(value)),
|
||||
};
|
||||
globalThis.document = {
|
||||
documentElement: {
|
||||
setAttribute: (name, value) => attributes.set(name, value),
|
||||
style,
|
||||
},
|
||||
};
|
||||
|
||||
const preference = await import("./fontSizePreference.ts");
|
||||
|
||||
test("scales fixed line-height utilities with the typography rem", () => {
|
||||
assert.deepEqual(config.theme.extend.lineHeight, {
|
||||
3: "calc(var(--buzz-type-rem) * 0.75)",
|
||||
4: "var(--buzz-type-rem)",
|
||||
5: "calc(var(--buzz-type-rem) * 1.25)",
|
||||
6: "calc(var(--buzz-type-rem) * 1.5)",
|
||||
7: "calc(var(--buzz-type-rem) * 1.75)",
|
||||
8: "calc(var(--buzz-type-rem) * 2)",
|
||||
"message-author": "var(--conversation-author-line-height)",
|
||||
});
|
||||
});
|
||||
|
||||
test("defaults invalid and missing font sizes to default", () => {
|
||||
assert.equal(preference.parseFontSize(null), "default");
|
||||
assert.equal(preference.parseFontSize("medium"), "default");
|
||||
assert.equal(preference.parseFontSize("smaller"), "smaller");
|
||||
assert.equal(preference.parseFontSize("default"), "default");
|
||||
assert.equal(preference.parseFontSize("larger"), "larger");
|
||||
});
|
||||
|
||||
test("persists and applies the selected font size across the app", () => {
|
||||
preference.applyTextZoomFactor(1);
|
||||
preference.setFontSize("smaller");
|
||||
assert.equal(preference.getFontSize(), "smaller");
|
||||
assert.equal(values.get(preference.FONT_SIZE_STORAGE_KEY), "smaller");
|
||||
assert.equal(attributes.get("data-font-size"), "smaller");
|
||||
assert.equal(styleValues.get("--buzz-type-rem"), "14.857143px");
|
||||
});
|
||||
|
||||
test("previews a font size without changing the saved preference", () => {
|
||||
preference.applyTextZoomFactor(1.1);
|
||||
preference.setFontSize("smaller");
|
||||
preference.previewFontSize("larger");
|
||||
assert.equal(preference.getFontSize(), "smaller");
|
||||
assert.equal(values.get(preference.FONT_SIZE_STORAGE_KEY), "smaller");
|
||||
assert.equal(attributes.get("data-font-size"), "larger");
|
||||
assert.equal(styleValues.get("--buzz-type-rem"), "18.857143px");
|
||||
|
||||
preference.previewFontSize(null);
|
||||
assert.equal(attributes.get("data-font-size"), "smaller");
|
||||
assert.equal(styleValues.get("--buzz-type-rem"), "16.342857px");
|
||||
});
|
||||
|
||||
test("initializes from the stored font size", () => {
|
||||
preference.applyTextZoomFactor(1);
|
||||
values.set(preference.FONT_SIZE_STORAGE_KEY, "larger");
|
||||
preference.initializeFontSizePreference();
|
||||
assert.equal(preference.getFontSize(), "larger");
|
||||
assert.equal(attributes.get("data-font-size"), "larger");
|
||||
assert.equal(styleValues.get("--buzz-type-rem"), "17.142857px");
|
||||
});
|
||||
|
||||
test("applies font size changes from another window", () => {
|
||||
values.set(preference.FONT_SIZE_STORAGE_KEY, "smaller");
|
||||
windowListeners.get("storage")({ key: preference.FONT_SIZE_STORAGE_KEY });
|
||||
assert.equal(preference.getFontSize(), "smaller");
|
||||
assert.equal(attributes.get("data-font-size"), "smaller");
|
||||
assert.equal(styleValues.get("--buzz-type-rem"), "14.857143px");
|
||||
});
|
||||
|
||||
test("returns to the default when another window clears storage", () => {
|
||||
preference.setFontSize("larger");
|
||||
values.clear();
|
||||
windowListeners.get("storage")({ key: null });
|
||||
assert.equal(preference.getFontSize(), "default");
|
||||
assert.equal(attributes.get("data-font-size"), "default");
|
||||
assert.equal(styleValues.get("--buzz-type-rem"), "16px");
|
||||
});
|
||||
121
desktop/src/shared/lib/fontSizePreference.ts
Normal file
121
desktop/src/shared/lib/fontSizePreference.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import * as React from "react";
|
||||
|
||||
/** Device-level type scale applied throughout the desktop interface. */
|
||||
export type FontSize = "smaller" | "default" | "larger";
|
||||
|
||||
export const FONT_SIZE_STORAGE_KEY = "buzz.appearance.fontSize";
|
||||
export const DEFAULT_FONT_SIZE: FontSize = "default";
|
||||
|
||||
/**
|
||||
* Virtual rem sizes used by typography tokens. Keeping the real root at 16px
|
||||
* prevents a text preference from also resizing rem-based layout geometry.
|
||||
*/
|
||||
const TYPE_REM_SIZE_PX: Record<FontSize, number> = {
|
||||
smaller: 13 / 0.875,
|
||||
default: 14 / 0.875,
|
||||
larger: 15 / 0.875,
|
||||
};
|
||||
|
||||
const TYPE_REM_PROPERTY = "--buzz-type-rem";
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
let fontSize: FontSize = DEFAULT_FONT_SIZE;
|
||||
let textZoomFactor = 1;
|
||||
let listeningForStorageChanges = false;
|
||||
|
||||
export function parseFontSize(value: string | null | undefined): FontSize {
|
||||
return value === "smaller" || value === "default" || value === "larger"
|
||||
? value
|
||||
: DEFAULT_FONT_SIZE;
|
||||
}
|
||||
|
||||
function readStoredFontSize(): FontSize {
|
||||
try {
|
||||
return parseFontSize(
|
||||
globalThis.localStorage?.getItem(FONT_SIZE_STORAGE_KEY),
|
||||
);
|
||||
} catch {
|
||||
return DEFAULT_FONT_SIZE;
|
||||
}
|
||||
}
|
||||
|
||||
function typeRemSizePx(size: FontSize): number {
|
||||
return (
|
||||
Math.round(TYPE_REM_SIZE_PX[size] * textZoomFactor * 1_000_000) / 1_000_000
|
||||
);
|
||||
}
|
||||
|
||||
function applyFontSize(size: FontSize): void {
|
||||
const root = globalThis.document?.documentElement;
|
||||
root?.setAttribute("data-font-size", size);
|
||||
root?.style.setProperty(TYPE_REM_PROPERTY, `${typeRemSizePx(size)}px`);
|
||||
}
|
||||
|
||||
function notifyListeners(): void {
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
function applyStoredFontSize(): void {
|
||||
const nextSize = readStoredFontSize();
|
||||
const changed = nextSize !== fontSize;
|
||||
fontSize = nextSize;
|
||||
applyFontSize(nextSize);
|
||||
if (changed) notifyListeners();
|
||||
}
|
||||
|
||||
function listenForStorageChanges(): void {
|
||||
if (listeningForStorageChanges || !globalThis.window?.addEventListener)
|
||||
return;
|
||||
globalThis.window.addEventListener("storage", (event) => {
|
||||
if (event.key === FONT_SIZE_STORAGE_KEY || event.key === null) {
|
||||
applyStoredFontSize();
|
||||
}
|
||||
});
|
||||
listeningForStorageChanges = true;
|
||||
}
|
||||
|
||||
/** Apply the persisted preference before React renders to avoid a layout jump. */
|
||||
export function initializeFontSizePreference(): void {
|
||||
applyStoredFontSize();
|
||||
listenForStorageChanges();
|
||||
}
|
||||
|
||||
/** Combine Cmd +/- zoom with the selected app-wide type scale. */
|
||||
export function applyTextZoomFactor(zoomFactor: number): void {
|
||||
if (!Number.isFinite(zoomFactor) || zoomFactor <= 0) return;
|
||||
textZoomFactor = zoomFactor;
|
||||
applyFontSize(fontSize);
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
export function getFontSize(): FontSize {
|
||||
return fontSize;
|
||||
}
|
||||
|
||||
export function setFontSize(size: FontSize): void {
|
||||
fontSize = size;
|
||||
applyFontSize(size);
|
||||
try {
|
||||
globalThis.localStorage?.setItem(FONT_SIZE_STORAGE_KEY, size);
|
||||
} catch {
|
||||
// Persistence is best-effort; the live preference still applies.
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/** Temporarily apply a size without changing the saved preference. */
|
||||
export function previewFontSize(size: FontSize | null): void {
|
||||
applyFontSize(size ?? fontSize);
|
||||
}
|
||||
|
||||
export function useFontSize(): FontSize {
|
||||
return React.useSyncExternalStore(
|
||||
subscribe,
|
||||
getFontSize,
|
||||
() => DEFAULT_FONT_SIZE,
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
@import "./globals/composer.css";
|
||||
@import "./globals/markdown.css";
|
||||
@import "./globals/theme.css";
|
||||
@import "./globals/typography.css";
|
||||
@import "./globals/skeleton.css";
|
||||
@import "./globals/spoilers.css";
|
||||
@import "./globals/components.css";
|
||||
|
||||
@@ -132,8 +132,8 @@
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
color: hsl(var(--muted-foreground) / 0.72);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
font-size: calc(var(--buzz-type-rem) * 0.875);
|
||||
line-height: calc(var(--buzz-type-rem) * 1.25);
|
||||
text-align: center;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
|
||||
@@ -558,7 +558,7 @@
|
||||
.buzz-onboarding-runtime-pill {
|
||||
animation: buzz-onboarding-runtime-pill-in 180ms
|
||||
cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
font-size: 0.625rem;
|
||||
font-size: calc(var(--buzz-type-rem) * 0.625);
|
||||
letter-spacing: 0;
|
||||
line-height: 1;
|
||||
text-transform: uppercase;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
color: hsl(var(--muted-foreground));
|
||||
display: flex;
|
||||
font: inherit;
|
||||
font-size: 0.75rem;
|
||||
font-size: calc(var(--buzz-type-rem) * 0.75);
|
||||
font-weight: 500;
|
||||
gap: 6px;
|
||||
height: 30px;
|
||||
@@ -130,7 +130,7 @@
|
||||
.buzz-terminal-designator {
|
||||
align-items: center;
|
||||
display: inline-flex;
|
||||
font-size: 0.75rem;
|
||||
font-size: calc(var(--buzz-type-rem) * 0.75);
|
||||
gap: 2px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
56
desktop/src/shared/styles/globals/typography.css
Normal file
56
desktop/src/shared/styles/globals/typography.css
Normal file
@@ -0,0 +1,56 @@
|
||||
@layer base {
|
||||
:root {
|
||||
/*
|
||||
* A virtual typography rem. Font preferences and Cmd +/- change this
|
||||
* token while the browser root remains 16px, so text scales without also
|
||||
* resizing rem-based widths, gaps, radii, and controls.
|
||||
*/
|
||||
--buzz-type-rem: 1rem;
|
||||
--text-xs: calc(var(--buzz-type-rem) * 0.75);
|
||||
--text-sm: calc(var(--buzz-type-rem) * 0.875);
|
||||
--text-base: var(--buzz-type-rem);
|
||||
--text-lg: calc(var(--buzz-type-rem) * 1.125);
|
||||
--text-xl: calc(var(--buzz-type-rem) * 1.25);
|
||||
--text-2xl: calc(var(--buzz-type-rem) * 1.5);
|
||||
--text-3xl: calc(var(--buzz-type-rem) * 1.875);
|
||||
--text-4xl: calc(var(--buzz-type-rem) * 2.25);
|
||||
--text-5xl: calc(var(--buzz-type-rem) * 3);
|
||||
--text-6xl: calc(var(--buzz-type-rem) * 3.75);
|
||||
|
||||
/*
|
||||
* Default conversation type and comfy spacing for channels, DMs, threads,
|
||||
* Inbox, and the composer. Font size changes the type tokens only;
|
||||
* Conversation density overrides only spacing.
|
||||
*/
|
||||
--conversation-message-font-size: calc(var(--buzz-type-rem) * 0.875);
|
||||
--conversation-message-line-height: calc(var(--buzz-type-rem) * 1.25);
|
||||
--conversation-author-line-height: var(--buzz-type-rem);
|
||||
--conversation-body-gap: 0.125rem;
|
||||
--conversation-row-padding-block: 0.25rem;
|
||||
--conversation-paragraph-gap: 0.5rem;
|
||||
--conversation-list-item-gap: 0.375rem;
|
||||
--conversation-timestamp-font-size: calc(var(--buzz-type-rem) * 0.75);
|
||||
--conversation-timestamp-line-height: var(--buzz-type-rem);
|
||||
}
|
||||
|
||||
:root[data-conversation-density="compact"] {
|
||||
--conversation-body-gap: 0rem;
|
||||
--conversation-row-padding-block: 0.25rem;
|
||||
--conversation-paragraph-gap: 0.375rem;
|
||||
--conversation-list-item-gap: 0.25rem;
|
||||
}
|
||||
|
||||
:root[data-conversation-density="spacious"] {
|
||||
--conversation-body-gap: 0.25rem;
|
||||
--conversation-row-padding-block: 0.5rem;
|
||||
--conversation-paragraph-gap: 0.625rem;
|
||||
--conversation-list-item-gap: 0.5rem;
|
||||
}
|
||||
|
||||
body {
|
||||
font-synthesis-weight: none;
|
||||
font-variant-emoji: unicode;
|
||||
font-variant-ligatures: no-contextual;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
}
|
||||
@@ -1877,10 +1877,10 @@ function MarkdownInner({
|
||||
className={cn(
|
||||
MESSAGE_MARKDOWN_CLASS,
|
||||
[
|
||||
"max-w-none wrap-anywhere text-sm leading-5 text-foreground",
|
||||
"max-w-none wrap-anywhere text-message font-normal tracking-normal text-foreground",
|
||||
"[&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
|
||||
"[&>*+*]:mt-3",
|
||||
"[&>p+p]:mt-1.5",
|
||||
"[&>p+p]:mt-conversation-paragraph [&>ol]:space-y-conversation-list [&>ul]:space-y-conversation-list",
|
||||
"[&>*+h1]:mt-3.5 [&>*+h2]:mt-3.5 [&>*+h3]:mt-3.5 [&>*+h4]:mt-3.5 [&>*+h5]:mt-3.5 [&>*+h6]:mt-3.5",
|
||||
"[&>h1+*]:mt-0.5 [&>h2+*]:mt-0.5 [&>h3+*]:mt-0.5 [&>h4+*]:mt-0.5 [&>h5+*]:mt-0.5 [&>h6+*]:mt-0.5",
|
||||
"[&>h1+h2]:mt-1.5! [&>h2+h3]:mt-1.5! [&>h3+h4]:mt-1.5! [&>h4+h5]:mt-1.5! [&>h5+h6]:mt-1.5!",
|
||||
|
||||
218
desktop/src/shared/ui/segmented-control.tsx
Normal file
218
desktop/src/shared/ui/segmented-control.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
|
||||
type SegmentOption<Value extends string> = {
|
||||
value: Value;
|
||||
label: string;
|
||||
Icon?: React.ComponentType<{ className?: string }>;
|
||||
};
|
||||
|
||||
type SegmentedControlSize = "compact" | "default" | "wide";
|
||||
|
||||
const SIZE_CLASSES: Record<SegmentedControlSize, string> = {
|
||||
compact: "w-48",
|
||||
default: "w-60",
|
||||
wide: "w-72",
|
||||
};
|
||||
|
||||
/** A mutually exclusive control with equal-width, optionally scrubbable options. */
|
||||
export function SegmentedControl<Value extends string>({
|
||||
className,
|
||||
indicatorTestId,
|
||||
legend,
|
||||
onPreviewChange,
|
||||
onValueChange,
|
||||
optionTestIdPrefix,
|
||||
options,
|
||||
size = "default",
|
||||
testId,
|
||||
value,
|
||||
}: {
|
||||
className?: string;
|
||||
indicatorTestId?: string;
|
||||
legend: string;
|
||||
onPreviewChange?: (value: Value | null) => void;
|
||||
onValueChange: (value: Value) => void;
|
||||
optionTestIdPrefix: string;
|
||||
options: readonly SegmentOption<Value>[];
|
||||
size?: SegmentedControlSize;
|
||||
testId: string;
|
||||
value: Value;
|
||||
}) {
|
||||
const [previewValue, setPreviewValue] = React.useState<Value | null>(null);
|
||||
const controlRef = React.useRef<HTMLFieldSetElement | null>(null);
|
||||
const activePointerIdRef = React.useRef<number | null>(null);
|
||||
const pointerStartXRef = React.useRef<number | null>(null);
|
||||
const pointerStartValueRef = React.useRef<Value | null>(null);
|
||||
const scrubValueRef = React.useRef<Value | null>(null);
|
||||
const skipPointerClickRef = React.useRef(false);
|
||||
const displayedValue = previewValue ?? value;
|
||||
const selectedIndex = Math.max(
|
||||
0,
|
||||
options.findIndex((option) => option.value === displayedValue),
|
||||
);
|
||||
|
||||
const getValueAtPointer = React.useCallback(
|
||||
(element: HTMLFieldSetElement, clientX: number): Value => {
|
||||
const bounds = element.getBoundingClientRect();
|
||||
const position = Math.max(
|
||||
0,
|
||||
Math.min(bounds.width - 1, clientX - bounds.left),
|
||||
);
|
||||
const index = Math.min(
|
||||
options.length - 1,
|
||||
Math.floor((position / bounds.width) * options.length),
|
||||
);
|
||||
return options[index]?.value ?? value;
|
||||
},
|
||||
[options, value],
|
||||
);
|
||||
|
||||
const preview = React.useCallback(
|
||||
(nextValue: Value | null) => {
|
||||
scrubValueRef.current = nextValue;
|
||||
setPreviewValue(nextValue);
|
||||
onPreviewChange?.(nextValue);
|
||||
},
|
||||
[onPreviewChange],
|
||||
);
|
||||
|
||||
const cancelScrub = React.useCallback(() => {
|
||||
const control = controlRef.current;
|
||||
const pointerId = activePointerIdRef.current;
|
||||
activePointerIdRef.current = null;
|
||||
if (control && pointerId != null && control.hasPointerCapture(pointerId)) {
|
||||
control.releasePointerCapture(pointerId);
|
||||
}
|
||||
pointerStartXRef.current = null;
|
||||
pointerStartValueRef.current = null;
|
||||
skipPointerClickRef.current = false;
|
||||
preview(null);
|
||||
}, [preview]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleWindowBlur = () => cancelScrub();
|
||||
globalThis.addEventListener?.("blur", handleWindowBlur);
|
||||
return () => {
|
||||
globalThis.removeEventListener?.("blur", handleWindowBlur);
|
||||
cancelScrub();
|
||||
};
|
||||
}, [cancelScrub]);
|
||||
|
||||
const handlePointerDown = (
|
||||
event: React.PointerEvent<HTMLFieldSetElement>,
|
||||
) => {
|
||||
if (!onPreviewChange || event.button !== 0) return;
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
activePointerIdRef.current = event.pointerId;
|
||||
pointerStartXRef.current = event.clientX;
|
||||
pointerStartValueRef.current = getValueAtPointer(
|
||||
event.currentTarget,
|
||||
event.clientX,
|
||||
);
|
||||
scrubValueRef.current = null;
|
||||
skipPointerClickRef.current = true;
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
const handlePointerMove = (
|
||||
event: React.PointerEvent<HTMLFieldSetElement>,
|
||||
) => {
|
||||
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return;
|
||||
const nextValue = getValueAtPointer(event.currentTarget, event.clientX);
|
||||
const pointerStartX = pointerStartXRef.current;
|
||||
const pointerStartValue = pointerStartValueRef.current;
|
||||
const crossedDragThreshold =
|
||||
pointerStartX != null && Math.abs(event.clientX - pointerStartX) >= 4;
|
||||
if (
|
||||
scrubValueRef.current == null &&
|
||||
!crossedDragThreshold &&
|
||||
nextValue === pointerStartValue
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (nextValue !== scrubValueRef.current) preview(nextValue);
|
||||
};
|
||||
|
||||
const handlePointerUp = (event: React.PointerEvent<HTMLFieldSetElement>) => {
|
||||
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return;
|
||||
const nextValue = getValueAtPointer(event.currentTarget, event.clientX);
|
||||
activePointerIdRef.current = null;
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
pointerStartXRef.current = null;
|
||||
pointerStartValueRef.current = null;
|
||||
onValueChange(nextValue);
|
||||
preview(null);
|
||||
globalThis.setTimeout(() => {
|
||||
skipPointerClickRef.current = false;
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const handlePointerCancel = () => cancelScrub();
|
||||
|
||||
const handleLostPointerCapture = () => {
|
||||
if (activePointerIdRef.current != null) cancelScrub();
|
||||
};
|
||||
|
||||
return (
|
||||
<fieldset
|
||||
className={cn(
|
||||
"relative isolate h-8 max-w-full shrink-0 overflow-hidden rounded-md bg-muted/45 p-0.5",
|
||||
SIZE_CLASSES[size],
|
||||
onPreviewChange && "touch-none select-none cursor-ew-resize",
|
||||
className,
|
||||
)}
|
||||
data-slot="segmented-control"
|
||||
data-testid={testId}
|
||||
onLostPointerCapture={handleLostPointerCapture}
|
||||
onPointerCancel={handlePointerCancel}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
ref={controlRef}
|
||||
>
|
||||
<legend className="sr-only">{legend}</legend>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"absolute bottom-0.5 left-0.5 top-0.5 z-0 rounded-md bg-background shadow-sm transition-transform duration-200 ease-in-out motion-reduce:transition-none",
|
||||
previewValue && "duration-0",
|
||||
)}
|
||||
data-testid={indicatorTestId ?? `${testId}-indicator`}
|
||||
style={{
|
||||
transform: `translateX(${selectedIndex * 100}%)`,
|
||||
width: `calc((100% - 0.25rem) / ${options.length})`,
|
||||
}}
|
||||
/>
|
||||
{/* Legends escape grid/flex layout on a fieldset, so the columns live
|
||||
on an inner wrapper the legend is not part of. */}
|
||||
<div className="grid h-full auto-cols-fr grid-flow-col">
|
||||
{options.map(({ value: optionValue, label, Icon }) => (
|
||||
<button
|
||||
aria-pressed={value === optionValue}
|
||||
className={cn(
|
||||
"relative z-10 flex h-full items-center justify-center gap-1.5 rounded-md bg-transparent px-2.5 text-xs font-medium transition-colors duration-150 ease-out focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none",
|
||||
displayedValue === optionValue
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
data-testid={`${optionTestIdPrefix}-${optionValue}`}
|
||||
key={optionValue}
|
||||
onClick={(event) => {
|
||||
if (event.detail > 0 && skipPointerClickRef.current) {
|
||||
skipPointerClickRef.current = false;
|
||||
return;
|
||||
}
|
||||
onValueChange(optionValue);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{Icon ? <Icon className="h-3.5 w-3.5" /> : null}
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
@@ -3,18 +3,46 @@ export default {
|
||||
theme: {
|
||||
extend: {
|
||||
// Sub-`text-xs` ramp for meta text (timestamps, count badges, tracking
|
||||
// labels) and tiny glyphs. Defined in rem so Cmd +/- zoom — which scales
|
||||
// the root <html> font-size — keeps scaling them. Do NOT reintroduce
|
||||
// arbitrary `text-[…rem]` / `text-[…px]` literals; the px-text guard
|
||||
// rejects them. Stock scale picks up from here: xs (12px), sm (14px)…
|
||||
// labels) and tiny glyphs. These follow the virtual typography rem so
|
||||
// preferences and Cmd +/- scale text without changing layout geometry.
|
||||
// Do NOT reintroduce arbitrary `text-[…rem]` / `text-[…px]` literals;
|
||||
// the px-text guard rejects them. Stock scale picks up from xs.
|
||||
fontSize: {
|
||||
"2xs": "0.6875rem", // 11px — meta-text workhorse (timestamps, badges)
|
||||
"3xs": "0.5rem", // 8px — tiny glyphs / micro labels
|
||||
badge: "0.625rem", // 10px — compact status badges
|
||||
// 40px — onboarding page titles (tightened tracking for large display type)
|
||||
title: ["2.5rem", { lineHeight: "1.15", letterSpacing: "-0.02em" }],
|
||||
// 36px — the backup-step private key, shown large in monospace
|
||||
"nsec-key": ["2.25rem", { lineHeight: "1.3" }],
|
||||
"2xs": "calc(var(--buzz-type-rem) * 0.6875)", // 11px at 16px type rem
|
||||
"3xs": "calc(var(--buzz-type-rem) * 0.5)", // 8px at 16px type rem
|
||||
badge: "calc(var(--buzz-type-rem) * 0.625)", // 10px at 16px type rem
|
||||
// Shared channel, DM, thread, and composer type. Variables keep app-wide
|
||||
// font size and keyboard zoom consistent without branching components.
|
||||
message: [
|
||||
"var(--conversation-message-font-size)",
|
||||
{ lineHeight: "var(--conversation-message-line-height)" },
|
||||
],
|
||||
"message-timestamp": [
|
||||
"var(--conversation-timestamp-font-size)",
|
||||
{ lineHeight: "var(--conversation-timestamp-line-height)" },
|
||||
],
|
||||
// 40px at the 16px type rem — onboarding page titles.
|
||||
title: [
|
||||
"calc(var(--buzz-type-rem) * 2.5)",
|
||||
{ lineHeight: "1.15", letterSpacing: "-0.02em" },
|
||||
],
|
||||
// 36px at the 16px type rem — backup-step private key.
|
||||
"nsec-key": [
|
||||
"calc(var(--buzz-type-rem) * 2.25)",
|
||||
{ lineHeight: "1.3" },
|
||||
],
|
||||
},
|
||||
lineHeight: {
|
||||
// Keep fixed Tailwind line-height utilities in the typography scale so
|
||||
// Cmd +/- cannot enlarge glyphs inside an unchanged line box. Single-
|
||||
// line surfaces keep their existing truncate/overflow behavior.
|
||||
3: "calc(var(--buzz-type-rem) * 0.75)",
|
||||
4: "var(--buzz-type-rem)",
|
||||
5: "calc(var(--buzz-type-rem) * 1.25)",
|
||||
6: "calc(var(--buzz-type-rem) * 1.5)",
|
||||
7: "calc(var(--buzz-type-rem) * 1.75)",
|
||||
8: "calc(var(--buzz-type-rem) * 2)",
|
||||
"message-author": "var(--conversation-author-line-height)",
|
||||
},
|
||||
boxShadow: {
|
||||
"content-edge": "-1px -1px 0 0 hsl(var(--sidebar-border) / 0.45)",
|
||||
@@ -36,6 +64,10 @@ export default {
|
||||
},
|
||||
spacing: {
|
||||
4.5: "1.125rem",
|
||||
"conversation-body": "var(--conversation-body-gap)",
|
||||
"conversation-list": "var(--conversation-list-item-gap)",
|
||||
"conversation-paragraph": "var(--conversation-paragraph-gap)",
|
||||
"conversation-row": "var(--conversation-row-padding-block)",
|
||||
},
|
||||
fontFamily: {
|
||||
sans: [
|
||||
|
||||
@@ -747,6 +747,8 @@ test("moves agent actions into an overflow menu in a narrow view", async ({
|
||||
});
|
||||
|
||||
await expect(page.getByTestId("agent-defaults-button")).toBeVisible();
|
||||
// The app-wide default renders text-base at 16px with Tailwind's 1.5
|
||||
// line-height ratio, producing a 24px one-line scroll height.
|
||||
await expect(
|
||||
page.getByText("Set up and manage your agents.", { exact: true }),
|
||||
).toHaveJSProperty("scrollHeight", 24);
|
||||
|
||||
@@ -8,6 +8,8 @@ const THEME_STORAGE_KEY = "buzz-theme";
|
||||
const GLASS_BACKGROUND_STORAGE_KEY = "buzz-glass-background";
|
||||
const GLASS_OPACITY_STORAGE_KEY = "buzz-glass-opacity";
|
||||
const PROMINENT_ACTIVE_TAB_STORAGE_KEY = "buzz-prominent-active-tab";
|
||||
const CONVERSATION_DENSITY_STORAGE_KEY = "buzz.appearance.conversationDensity";
|
||||
const FONT_SIZE_STORAGE_KEY = "buzz.appearance.fontSize";
|
||||
const MOCK_PUBKEY = "deadbeef".repeat(8);
|
||||
const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
|
||||
|
||||
@@ -617,7 +619,7 @@ test("appearance groups theme and preferences into labeled rows", async ({
|
||||
(lightModeButtonBox.x + lightModeButtonBox.width / 2),
|
||||
),
|
||||
).toBeLessThanOrEqual(0.5);
|
||||
await expect(colorModeIndicator).toHaveCSS("transition-duration", "0.25s");
|
||||
await expect(colorModeIndicator).toHaveCSS("transition-duration", "0.2s");
|
||||
|
||||
await themeCard.getByTestId("appearance-mode-dark").click();
|
||||
await waitForAnimations(page);
|
||||
@@ -639,6 +641,512 @@ test("appearance groups theme and preferences into labeled rows", async ({
|
||||
).toBeLessThanOrEqual(0.5);
|
||||
});
|
||||
|
||||
test("app font size and conversation density apply independently", async ({
|
||||
page,
|
||||
}) => {
|
||||
await seedTheme(page, "buzz");
|
||||
await installMockBridge(page);
|
||||
await openAppearance(page, "light");
|
||||
|
||||
const root = page.locator("html");
|
||||
const densityControl = page.getByTestId("conversation-density-control");
|
||||
const compact = page.getByTestId("conversation-density-compact");
|
||||
const comfortable = page.getByTestId("conversation-density-comfortable");
|
||||
const spacious = page.getByTestId("conversation-density-spacious");
|
||||
const densityIndicator = page.getByTestId(
|
||||
"conversation-density-control-indicator",
|
||||
);
|
||||
const fontSizeControl = page.getByTestId("font-size-control");
|
||||
const smaller = page.getByTestId("font-size-smaller");
|
||||
const defaultSize = page.getByTestId("font-size-default");
|
||||
const larger = page.getByTestId("font-size-larger");
|
||||
const fontSizeIndicator = page.getByTestId("font-size-control-indicator");
|
||||
const preview = page.getByTestId("conversation-preview");
|
||||
const previewSurface = page.getByTestId("conversation-preview-surface");
|
||||
const previewContent = page.getByTestId("conversation-preview-content");
|
||||
const previewChip = preview.getByText("Preview");
|
||||
const firstPreviewMessage = previewSurface.locator("article").first();
|
||||
const previewMessage = preview.getByText(
|
||||
"The revised conversation layout is ready to review.",
|
||||
);
|
||||
const previewTimestamp = preview.getByText("9:41");
|
||||
const densityDescription = page
|
||||
.getByTestId("conversation-density-row")
|
||||
.locator("[data-settings-subcopy]");
|
||||
const fontSizeDescription = page
|
||||
.getByTestId("font-size-row")
|
||||
.locator("[data-settings-subcopy]");
|
||||
const readScale = () =>
|
||||
root.evaluate((element) => {
|
||||
const style = window.getComputedStyle(element);
|
||||
return {
|
||||
authorLineHeight: Number.parseFloat(
|
||||
style.getPropertyValue("--conversation-author-line-height"),
|
||||
),
|
||||
bodyGap: Number.parseFloat(
|
||||
style.getPropertyValue("--conversation-body-gap"),
|
||||
),
|
||||
fontSize: style.getPropertyValue("--conversation-message-font-size"),
|
||||
lineHeight: style.getPropertyValue(
|
||||
"--conversation-message-line-height",
|
||||
),
|
||||
paragraphGap: Number.parseFloat(
|
||||
style.getPropertyValue("--conversation-paragraph-gap"),
|
||||
),
|
||||
rowPadding: Number.parseFloat(
|
||||
style.getPropertyValue("--conversation-row-padding-block"),
|
||||
),
|
||||
timestampFontSize: style.getPropertyValue(
|
||||
"--conversation-timestamp-font-size",
|
||||
),
|
||||
timestampLineHeight: Number.parseFloat(
|
||||
style.getPropertyValue("--conversation-timestamp-line-height"),
|
||||
),
|
||||
};
|
||||
});
|
||||
const readSettingsScale = () =>
|
||||
page.getByTestId("conversation-density-row").evaluate((element) => {
|
||||
const rowStyle = window.getComputedStyle(element);
|
||||
const label = element.querySelector("p");
|
||||
if (!label) throw new Error("Conversation density label is missing");
|
||||
const labelStyle = window.getComputedStyle(label);
|
||||
return {
|
||||
fontSize: labelStyle.fontSize,
|
||||
lineHeight: labelStyle.lineHeight,
|
||||
minHeight: rowStyle.minHeight,
|
||||
paddingBlock: rowStyle.paddingTop,
|
||||
};
|
||||
});
|
||||
const readPreviewTimestampScale = () =>
|
||||
previewTimestamp.evaluate((element) => {
|
||||
const style = window.getComputedStyle(element);
|
||||
return [style.fontSize, style.lineHeight];
|
||||
});
|
||||
const readSettingsChromeScale = () =>
|
||||
Promise.all([
|
||||
page
|
||||
.getByRole("heading", { name: "Appearance" })
|
||||
.evaluate((element) => window.getComputedStyle(element).fontSize),
|
||||
page
|
||||
.getByTestId("settings-nav-appearance")
|
||||
.evaluate((element) => window.getComputedStyle(element).fontSize),
|
||||
page
|
||||
.getByRole("heading", { name: "Preferences" })
|
||||
.evaluate((element) => window.getComputedStyle(element).fontSize),
|
||||
]);
|
||||
|
||||
await expect(
|
||||
page.getByRole("group", { name: "Conversation density" }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("group", { name: "Font size" })).toBeVisible();
|
||||
await expect(densityControl).toHaveAccessibleName("Conversation density");
|
||||
await expect(fontSizeControl).toHaveAccessibleName("Font size");
|
||||
await expect(comfortable).toHaveText("Comfy");
|
||||
await expect(defaultSize).toHaveText("Default");
|
||||
await expect(preview).toContainText("Preview");
|
||||
await expect(preview).not.toContainText("Message #design");
|
||||
await expect(comfortable).toHaveAttribute("aria-pressed", "true");
|
||||
await expect(defaultSize).toHaveAttribute("aria-pressed", "true");
|
||||
await expect(densityDescription).toHaveText(
|
||||
"Spacing in conversations and Markdown content across Buzz",
|
||||
);
|
||||
await expect(fontSizeDescription).toHaveText(
|
||||
"Applies across conversations and interface text",
|
||||
);
|
||||
await expect.poll(readScale).toEqual({
|
||||
authorLineHeight: 16,
|
||||
bodyGap: 0.125,
|
||||
fontSize: "calc(16px * .875)",
|
||||
lineHeight: "calc(16px * 1.25)",
|
||||
paragraphGap: 0.5,
|
||||
rowPadding: 0.25,
|
||||
timestampFontSize: "calc(16px * .75)",
|
||||
timestampLineHeight: 16,
|
||||
});
|
||||
await expect
|
||||
.poll(() =>
|
||||
Promise.all([
|
||||
densityControl.evaluate(
|
||||
(element) => element.getBoundingClientRect().width,
|
||||
),
|
||||
fontSizeControl.evaluate(
|
||||
(element) => element.getBoundingClientRect().width,
|
||||
),
|
||||
page
|
||||
.getByTestId("appearance-color-mode-control")
|
||||
.evaluate((element) => element.getBoundingClientRect().width),
|
||||
]),
|
||||
)
|
||||
.toEqual([288, 288, 240]);
|
||||
await expect
|
||||
.poll(() =>
|
||||
previewMessage.evaluate((element) => {
|
||||
const style = window.getComputedStyle(element);
|
||||
return [style.fontSize, style.lineHeight];
|
||||
}),
|
||||
)
|
||||
.toEqual(["14px", "20px"]);
|
||||
await expect.poll(readSettingsScale).toEqual({
|
||||
fontSize: "14px",
|
||||
lineHeight: "20px",
|
||||
minHeight: "64px",
|
||||
paddingBlock: "12px",
|
||||
});
|
||||
await expect.poll(readPreviewTimestampScale).toEqual(["12px", "16px"]);
|
||||
await expect.poll(readSettingsChromeScale).toEqual(["24px", "14px", "14px"]);
|
||||
await expect(densityIndicator).toHaveCSS("transition-duration", "0.2s");
|
||||
await expect(densityIndicator).toHaveCSS("transition-property", /transform/);
|
||||
await expect(fontSizeIndicator).toHaveCSS("transition-duration", "0.2s");
|
||||
await expect(fontSizeIndicator).toHaveCSS("transition-property", /transform/);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const [previewBackground, labelBackground, controlBackground] =
|
||||
await Promise.all([
|
||||
previewSurface.evaluate(
|
||||
(element) => window.getComputedStyle(element).backgroundColor,
|
||||
),
|
||||
previewChip.evaluate(
|
||||
(element) => window.getComputedStyle(element).backgroundColor,
|
||||
),
|
||||
densityControl.evaluate(
|
||||
(element) => window.getComputedStyle(element).backgroundColor,
|
||||
),
|
||||
]);
|
||||
return {
|
||||
labelIsAnnotation: labelBackground !== controlBackground,
|
||||
previewBackground,
|
||||
};
|
||||
})
|
||||
.toEqual({
|
||||
labelIsAnnotation: true,
|
||||
previewBackground: "rgba(0, 0, 0, 0)",
|
||||
});
|
||||
const previewSurfaceBox = await previewSurface.boundingBox();
|
||||
const previewChipBox = await previewChip.boundingBox();
|
||||
const firstPreviewMessageBox = await firstPreviewMessage.boundingBox();
|
||||
expect(previewSurfaceBox).not.toBeNull();
|
||||
expect(previewChipBox).not.toBeNull();
|
||||
expect(firstPreviewMessageBox).not.toBeNull();
|
||||
if (!previewSurfaceBox || !previewChipBox || !firstPreviewMessageBox) {
|
||||
throw new Error("Conversation preview geometry is missing");
|
||||
}
|
||||
const previewChipRightInset =
|
||||
previewSurfaceBox.x +
|
||||
previewSurfaceBox.width -
|
||||
(previewChipBox.x + previewChipBox.width);
|
||||
expect(previewChipRightInset).toBeGreaterThanOrEqual(13);
|
||||
expect(previewChipRightInset).toBeLessThanOrEqual(15);
|
||||
const previewChipTopInset = previewChipBox.y - previewSurfaceBox.y;
|
||||
expect(previewChipTopInset).toBeGreaterThanOrEqual(13);
|
||||
expect(previewChipTopInset).toBeLessThanOrEqual(15);
|
||||
await expect(previewContent).toHaveCSS("padding-top", "16px");
|
||||
await expect(previewContent).toHaveCSS("padding-right", "16px");
|
||||
await expect(previewContent).toHaveCSS("padding-bottom", "16px");
|
||||
await expect(previewContent).toHaveCSS("padding-left", "16px");
|
||||
expect(firstPreviewMessageBox.x - previewSurfaceBox.x).toBeGreaterThanOrEqual(
|
||||
15,
|
||||
);
|
||||
expect(firstPreviewMessageBox.x - previewSurfaceBox.x).toBeLessThanOrEqual(
|
||||
17,
|
||||
);
|
||||
expect(firstPreviewMessageBox.y - previewSurfaceBox.y).toBeGreaterThanOrEqual(
|
||||
15,
|
||||
);
|
||||
expect(firstPreviewMessageBox.y - previewSurfaceBox.y).toBeLessThanOrEqual(
|
||||
17,
|
||||
);
|
||||
await densityIndicator.evaluate((element) => {
|
||||
element.addEventListener(
|
||||
"transitionrun",
|
||||
() => element.setAttribute("data-transition-ran", "true"),
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
|
||||
await compact.click();
|
||||
await expect(densityIndicator).toHaveAttribute("data-transition-ran", "true");
|
||||
await expect(root).toHaveAttribute("data-conversation-density", "compact");
|
||||
await expect(root).toHaveAttribute("data-font-size", "default");
|
||||
await expect(compact).toHaveAttribute("aria-pressed", "true");
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
(key) => window.localStorage.getItem(key),
|
||||
CONVERSATION_DENSITY_STORAGE_KEY,
|
||||
),
|
||||
)
|
||||
.toBe("compact");
|
||||
await expect.poll(readScale).toEqual({
|
||||
authorLineHeight: 16,
|
||||
bodyGap: 0,
|
||||
fontSize: "calc(16px * .875)",
|
||||
lineHeight: "calc(16px * 1.25)",
|
||||
paragraphGap: 0.375,
|
||||
rowPadding: 0.25,
|
||||
timestampFontSize: "calc(16px * .75)",
|
||||
timestampLineHeight: 16,
|
||||
});
|
||||
await expect.poll(readSettingsScale).toEqual({
|
||||
fontSize: "14px",
|
||||
lineHeight: "20px",
|
||||
minHeight: "64px",
|
||||
paddingBlock: "12px",
|
||||
});
|
||||
await expect.poll(readPreviewTimestampScale).toEqual(["12px", "16px"]);
|
||||
await expect.poll(readSettingsChromeScale).toEqual(["24px", "14px", "14px"]);
|
||||
|
||||
await larger.click();
|
||||
await expect(root).toHaveAttribute("data-conversation-density", "compact");
|
||||
await expect(root).toHaveAttribute("data-font-size", "larger");
|
||||
await expect(larger).toHaveAttribute("aria-pressed", "true");
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
(key) => window.localStorage.getItem(key),
|
||||
FONT_SIZE_STORAGE_KEY,
|
||||
),
|
||||
)
|
||||
.toBe("larger");
|
||||
await expect.poll(readScale).toEqual({
|
||||
authorLineHeight: 17.142857,
|
||||
bodyGap: 0,
|
||||
fontSize: "calc(17.142857px * .875)",
|
||||
lineHeight: "calc(17.142857px * 1.25)",
|
||||
paragraphGap: 0.375,
|
||||
rowPadding: 0.25,
|
||||
timestampFontSize: "calc(17.142857px * .75)",
|
||||
timestampLineHeight: 17.142857,
|
||||
});
|
||||
await expect
|
||||
.poll(() =>
|
||||
previewMessage.evaluate((element) => {
|
||||
const style = window.getComputedStyle(element);
|
||||
return [style.fontSize, style.lineHeight];
|
||||
}),
|
||||
)
|
||||
.toEqual(["15px", "21.4286px"]);
|
||||
await expect.poll(readSettingsScale).toEqual({
|
||||
fontSize: "15px",
|
||||
lineHeight: "21.4286px",
|
||||
minHeight: "64px",
|
||||
paddingBlock: "12px",
|
||||
});
|
||||
await expect
|
||||
.poll(readPreviewTimestampScale)
|
||||
.toEqual(["12.8571px", "17.1429px"]);
|
||||
await expect
|
||||
.poll(readSettingsChromeScale)
|
||||
.toEqual(["25.7143px", "15px", "15px"]);
|
||||
await waitForAnimations(page);
|
||||
await page.getByTestId("appearance-preferences-card").screenshot({
|
||||
path: `${SHOTS}/15-conversation-compact-larger.png`,
|
||||
});
|
||||
|
||||
await spacious.click();
|
||||
await expect(root).toHaveAttribute("data-conversation-density", "spacious");
|
||||
await expect(root).toHaveAttribute("data-font-size", "larger");
|
||||
await expect(spacious).toHaveAttribute("aria-pressed", "true");
|
||||
await expect.poll(readScale).toEqual({
|
||||
authorLineHeight: 17.142857,
|
||||
bodyGap: 0.25,
|
||||
fontSize: "calc(17.142857px * .875)",
|
||||
lineHeight: "calc(17.142857px * 1.25)",
|
||||
paragraphGap: 0.625,
|
||||
rowPadding: 0.5,
|
||||
timestampFontSize: "calc(17.142857px * .75)",
|
||||
timestampLineHeight: 17.142857,
|
||||
});
|
||||
await expect.poll(readSettingsScale).toEqual({
|
||||
fontSize: "15px",
|
||||
lineHeight: "21.4286px",
|
||||
minHeight: "64px",
|
||||
paddingBlock: "12px",
|
||||
});
|
||||
await expect
|
||||
.poll(readPreviewTimestampScale)
|
||||
.toEqual(["12.8571px", "17.1429px"]);
|
||||
await expect
|
||||
.poll(readSettingsChromeScale)
|
||||
.toEqual(["25.7143px", "15px", "15px"]);
|
||||
|
||||
await smaller.click();
|
||||
await expect(root).toHaveAttribute("data-conversation-density", "spacious");
|
||||
await expect(root).toHaveAttribute("data-font-size", "smaller");
|
||||
await expect(smaller).toHaveAttribute("aria-pressed", "true");
|
||||
await expect.poll(readScale).toEqual({
|
||||
authorLineHeight: 14.857143,
|
||||
bodyGap: 0.25,
|
||||
fontSize: "calc(14.857143px * .875)",
|
||||
lineHeight: "calc(14.857143px * 1.25)",
|
||||
paragraphGap: 0.625,
|
||||
rowPadding: 0.5,
|
||||
timestampFontSize: "calc(14.857143px * .75)",
|
||||
timestampLineHeight: 14.857143,
|
||||
});
|
||||
await expect
|
||||
.poll(() =>
|
||||
previewMessage.evaluate((element) => {
|
||||
const style = window.getComputedStyle(element);
|
||||
return [style.fontSize, style.lineHeight];
|
||||
}),
|
||||
)
|
||||
.toEqual(["13px", "18.5714px"]);
|
||||
await expect.poll(readSettingsScale).toEqual({
|
||||
fontSize: "13px",
|
||||
lineHeight: "18.5714px",
|
||||
minHeight: "64px",
|
||||
paddingBlock: "12px",
|
||||
});
|
||||
await expect
|
||||
.poll(readPreviewTimestampScale)
|
||||
.toEqual(["11.1429px", "14.8571px"]);
|
||||
await expect
|
||||
.poll(readSettingsChromeScale)
|
||||
.toEqual(["22.2857px", "13px", "13px"]);
|
||||
await waitForAnimations(page);
|
||||
await page.getByTestId("appearance-preferences-card").screenshot({
|
||||
path: `${SHOTS}/16-conversation-spacious-smaller.png`,
|
||||
});
|
||||
|
||||
await comfortable.click();
|
||||
await defaultSize.click();
|
||||
await expect(root).toHaveAttribute(
|
||||
"data-conversation-density",
|
||||
"comfortable",
|
||||
);
|
||||
await expect(comfortable).toHaveAttribute("aria-pressed", "true");
|
||||
|
||||
const controlBox = await densityControl.boundingBox();
|
||||
const compactBox = await compact.boundingBox();
|
||||
const spaciousBox = await spacious.boundingBox();
|
||||
expect(controlBox).not.toBeNull();
|
||||
expect(compactBox).not.toBeNull();
|
||||
expect(spaciousBox).not.toBeNull();
|
||||
if (!controlBox || !compactBox || !spaciousBox) {
|
||||
throw new Error("Conversation density control geometry is missing");
|
||||
}
|
||||
await page.mouse.move(
|
||||
compactBox.x + compactBox.width / 2,
|
||||
controlBox.y + controlBox.height / 2,
|
||||
);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(
|
||||
spaciousBox.x + spaciousBox.width / 2,
|
||||
controlBox.y + controlBox.height / 2,
|
||||
);
|
||||
await expect(root).toHaveAttribute("data-conversation-density", "spacious");
|
||||
await expect(root).toHaveAttribute("data-font-size", "default");
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
(key) => window.localStorage.getItem(key),
|
||||
CONVERSATION_DENSITY_STORAGE_KEY,
|
||||
),
|
||||
)
|
||||
.toBe("comfortable");
|
||||
await expect.poll(readScale).toEqual({
|
||||
authorLineHeight: 16,
|
||||
bodyGap: 0.25,
|
||||
fontSize: "calc(16px * .875)",
|
||||
lineHeight: "calc(16px * 1.25)",
|
||||
paragraphGap: 0.625,
|
||||
rowPadding: 0.5,
|
||||
timestampFontSize: "calc(16px * .75)",
|
||||
timestampLineHeight: 16,
|
||||
});
|
||||
await expect.poll(readSettingsScale).toEqual({
|
||||
fontSize: "14px",
|
||||
lineHeight: "20px",
|
||||
minHeight: "64px",
|
||||
paddingBlock: "12px",
|
||||
});
|
||||
await page.mouse.up();
|
||||
await expect(spacious).toHaveAttribute("aria-pressed", "true");
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
(key) => window.localStorage.getItem(key),
|
||||
CONVERSATION_DENSITY_STORAGE_KEY,
|
||||
),
|
||||
)
|
||||
.toBe("spacious");
|
||||
await comfortable.click();
|
||||
|
||||
const fontSizeControlBox = await fontSizeControl.boundingBox();
|
||||
const smallerBox = await smaller.boundingBox();
|
||||
const largerBox = await larger.boundingBox();
|
||||
expect(fontSizeControlBox).not.toBeNull();
|
||||
expect(smallerBox).not.toBeNull();
|
||||
expect(largerBox).not.toBeNull();
|
||||
if (!fontSizeControlBox || !smallerBox || !largerBox) {
|
||||
throw new Error("Font size control geometry is missing");
|
||||
}
|
||||
await page.mouse.move(
|
||||
smallerBox.x + smallerBox.width / 2,
|
||||
fontSizeControlBox.y + fontSizeControlBox.height / 2,
|
||||
);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(
|
||||
largerBox.x + largerBox.width / 2,
|
||||
fontSizeControlBox.y + fontSizeControlBox.height / 2,
|
||||
);
|
||||
await expect(root).toHaveAttribute("data-font-size", "larger");
|
||||
await expect(root).toHaveAttribute(
|
||||
"data-conversation-density",
|
||||
"comfortable",
|
||||
);
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
(key) => window.localStorage.getItem(key),
|
||||
FONT_SIZE_STORAGE_KEY,
|
||||
),
|
||||
)
|
||||
.toBe("default");
|
||||
await expect
|
||||
.poll(() =>
|
||||
previewMessage.evaluate((element) => {
|
||||
const style = window.getComputedStyle(element);
|
||||
return [style.fontSize, style.lineHeight];
|
||||
}),
|
||||
)
|
||||
.toEqual(["15px", "21.4286px"]);
|
||||
await expect
|
||||
.poll(readSettingsChromeScale)
|
||||
.toEqual(["25.7143px", "15px", "15px"]);
|
||||
|
||||
// Losing the window during a scrub cancels the temporary preview rather
|
||||
// than leaving presentation and persisted selection out of sync.
|
||||
await page.evaluate(() => window.dispatchEvent(new Event("blur")));
|
||||
await expect(root).toHaveAttribute("data-font-size", "default");
|
||||
await expect(defaultSize).toHaveAttribute("aria-pressed", "true");
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
(key) => window.localStorage.getItem(key),
|
||||
FONT_SIZE_STORAGE_KEY,
|
||||
),
|
||||
)
|
||||
.toBe("default");
|
||||
|
||||
// Cancellation resets the gesture completely; the next selection persists.
|
||||
await larger.click();
|
||||
await expect(larger).toHaveAttribute("aria-pressed", "true");
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
(key) => window.localStorage.getItem(key),
|
||||
FONT_SIZE_STORAGE_KEY,
|
||||
),
|
||||
)
|
||||
.toBe("larger");
|
||||
await defaultSize.click();
|
||||
await waitForAnimations(page);
|
||||
await page.getByTestId("appearance-preferences-card").screenshot({
|
||||
path: `${SHOTS}/14-conversation-preferences.png`,
|
||||
});
|
||||
});
|
||||
|
||||
test("appearance picker — system tab (Buzz follows OS)", async ({ page }) => {
|
||||
await seedTheme(page, "buzz");
|
||||
await installMockBridge(page);
|
||||
@@ -1051,6 +1559,10 @@ test("glass background keeps the content panel solid", async ({ page }) => {
|
||||
const matchingRadiusControls = [
|
||||
page.getByTestId("appearance-color-mode-control"),
|
||||
page.getByTestId("appearance-color-mode-indicator"),
|
||||
page.getByTestId("font-size-control"),
|
||||
page.getByTestId("font-size-control-indicator"),
|
||||
page.getByTestId("conversation-density-control"),
|
||||
page.getByTestId("conversation-density-control-indicator"),
|
||||
page.getByTestId("theme-style-trigger"),
|
||||
page.getByTestId("link-preview-style-trigger"),
|
||||
page.getByTestId("thread-layout-trigger"),
|
||||
|
||||
@@ -116,9 +116,11 @@ test("agent-style message with bare buzz:// links renders entity cards without s
|
||||
await expect(
|
||||
repoCard.locator("[data-link-preview-hostname-favicon]"),
|
||||
).toHaveCount(0);
|
||||
// Default typography is 14px; keep the image-less card compact while
|
||||
// allowing fractional line-height rounding across rendering platforms.
|
||||
expect(
|
||||
await repoCard.evaluate((card) => card.getBoundingClientRect().height),
|
||||
).toBeLessThan(84);
|
||||
).toBeLessThan(90);
|
||||
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* tests/e2e/inbox-refactor-screenshots.spec.ts
|
||||
* Output: test-results/inbox-refactor/
|
||||
*/
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
import { waitForAnimations } from "../helpers/animations";
|
||||
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
|
||||
@@ -24,11 +24,66 @@ const DM_CHANNEL_ID = "f48efb06-0c93-5025-aac9-2e646bb6bfa8";
|
||||
// Mock bridge default pubkey — must match DEFAULT_MOCK_PUBKEY in bridge.ts.
|
||||
const MOCK_PUBKEY = "deadbeef".repeat(8);
|
||||
const DRAFT_STORE_KEY = `buzz-drafts.v1:${MOCK_PUBKEY}`;
|
||||
const FONT_SIZE_STORAGE_KEY = "buzz.appearance.fontSize";
|
||||
const CONVERSATION_DENSITY_STORAGE_KEY = "buzz.appearance.conversationDensity";
|
||||
|
||||
// Fixed timestamps so draft ordering renders deterministically.
|
||||
const DRAFT_CREATED_AT_1 = "2026-07-01T10:00:00.000Z";
|
||||
const DRAFT_CREATED_AT_2 = "2026-07-02T14:30:00.000Z";
|
||||
|
||||
type FontSize = "smaller" | "default" | "larger";
|
||||
type ConversationDensity = "compact" | "comfortable" | "spacious";
|
||||
|
||||
async function seedConversationPreferences(
|
||||
page: Page,
|
||||
fontSize: FontSize,
|
||||
density: ConversationDensity,
|
||||
) {
|
||||
await page.addInitScript(
|
||||
({ densityKey, densityValue, fontSizeKey, fontSizeValue }) => {
|
||||
window.localStorage.setItem(densityKey, densityValue);
|
||||
window.localStorage.setItem(fontSizeKey, fontSizeValue);
|
||||
},
|
||||
{
|
||||
densityKey: CONVERSATION_DENSITY_STORAGE_KEY,
|
||||
densityValue: density,
|
||||
fontSizeKey: FONT_SIZE_STORAGE_KEY,
|
||||
fontSizeValue: fontSize,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function applyConversationPreferences(
|
||||
page: Page,
|
||||
fontSize: FontSize,
|
||||
density: ConversationDensity,
|
||||
) {
|
||||
await page.evaluate(
|
||||
({ densityKey, densityValue, fontSizeKey, fontSizeValue }) => {
|
||||
const update = (key: string, value: string) => {
|
||||
const oldValue = window.localStorage.getItem(key);
|
||||
window.localStorage.setItem(key, value);
|
||||
window.dispatchEvent(
|
||||
new StorageEvent("storage", {
|
||||
key,
|
||||
newValue: value,
|
||||
oldValue,
|
||||
storageArea: window.localStorage,
|
||||
}),
|
||||
);
|
||||
};
|
||||
update(densityKey, densityValue);
|
||||
update(fontSizeKey, fontSizeValue);
|
||||
},
|
||||
{
|
||||
densityKey: CONVERSATION_DENSITY_STORAGE_KEY,
|
||||
densityValue: density,
|
||||
fontSizeKey: FONT_SIZE_STORAGE_KEY,
|
||||
fontSizeValue: fontSize,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
type MockFeedWindow = Window & {
|
||||
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
|
||||
channelName: string;
|
||||
@@ -273,6 +328,7 @@ test.describe("inbox refactor screenshots", () => {
|
||||
});
|
||||
|
||||
test("04 — thread opens at the oldest unread reply", async ({ page }) => {
|
||||
await seedConversationPreferences(page, "default", "comfortable");
|
||||
await installMockBridge(page, { mode: "mock" });
|
||||
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
@@ -341,6 +397,9 @@ test.describe("inbox refactor screenshots", () => {
|
||||
|
||||
const firstUnreadRow = page.getByTestId(`home-inbox-item-${replyIds[0]}`);
|
||||
await expect(firstUnreadRow).toBeVisible();
|
||||
const listPreview = firstUnreadRow.locator(".inbox-preview-markdown");
|
||||
await expect(listPreview).toHaveCSS("font-size", "14px");
|
||||
await expect(listPreview).toHaveCSS("line-height", "20px");
|
||||
await firstUnreadRow.click();
|
||||
|
||||
const detail = page.getByTestId("home-inbox-detail");
|
||||
@@ -352,8 +411,172 @@ test.describe("inbox refactor screenshots", () => {
|
||||
await expect(page.getByTestId("home-inbox-selected-message")).toContainText(
|
||||
"Started on the changelog — first pass is up.",
|
||||
);
|
||||
|
||||
const selectedMessage = page.getByTestId("home-inbox-selected-message");
|
||||
const selectedAuthor = selectedMessage.getByTestId("message-author");
|
||||
const selectedBody = selectedMessage.locator(".message-markdown").first();
|
||||
const selectedTimestamp = selectedMessage.getByTestId(
|
||||
"inbox-message-timestamp",
|
||||
);
|
||||
const remainingListPreview = page
|
||||
.locator("[data-testid^='home-inbox-item-']")
|
||||
.locator(".inbox-preview-markdown")
|
||||
.first();
|
||||
const composerInput = page.getByTestId("message-input");
|
||||
const readConversationMetrics = () =>
|
||||
Promise.all([
|
||||
remainingListPreview.evaluate((element) => {
|
||||
const style = window.getComputedStyle(element);
|
||||
return {
|
||||
fontSize: style.fontSize,
|
||||
lineHeight: style.lineHeight,
|
||||
};
|
||||
}),
|
||||
selectedMessage.evaluate((element) => {
|
||||
const style = window.getComputedStyle(element);
|
||||
return {
|
||||
paddingBottom: style.paddingBottom,
|
||||
paddingTop: style.paddingTop,
|
||||
};
|
||||
}),
|
||||
selectedMessage.evaluate((element) => {
|
||||
const header = element.querySelector<HTMLElement>(
|
||||
"[data-testid='message-header']",
|
||||
);
|
||||
const body = element.querySelector<HTMLElement>(
|
||||
"[data-testid='message-body']",
|
||||
);
|
||||
if (!header || !body) {
|
||||
throw new Error("Inbox message spacing geometry is missing");
|
||||
}
|
||||
return (
|
||||
body.getBoundingClientRect().top -
|
||||
header.getBoundingClientRect().bottom
|
||||
);
|
||||
}),
|
||||
selectedAuthor.evaluate((element) => {
|
||||
const style = window.getComputedStyle(element);
|
||||
return {
|
||||
fontSize: style.fontSize,
|
||||
lineHeight: style.lineHeight,
|
||||
};
|
||||
}),
|
||||
selectedBody.evaluate((element) => {
|
||||
const style = window.getComputedStyle(element);
|
||||
return {
|
||||
fontSize: style.fontSize,
|
||||
lineHeight: style.lineHeight,
|
||||
};
|
||||
}),
|
||||
selectedTimestamp.evaluate((element) => {
|
||||
const style = window.getComputedStyle(element);
|
||||
return {
|
||||
fontSize: style.fontSize,
|
||||
lineHeight: style.lineHeight,
|
||||
};
|
||||
}),
|
||||
composerInput.evaluate((element) => {
|
||||
const style = window.getComputedStyle(element);
|
||||
return {
|
||||
fontSize: style.fontSize,
|
||||
lineHeight: style.lineHeight,
|
||||
};
|
||||
}),
|
||||
]);
|
||||
await expect
|
||||
.poll(readConversationMetrics)
|
||||
.toEqual([
|
||||
{ fontSize: "14px", lineHeight: "20px" },
|
||||
{ paddingBottom: "4px", paddingTop: "4px" },
|
||||
2,
|
||||
{ fontSize: "14px", lineHeight: "16px" },
|
||||
{ fontSize: "14px", lineHeight: "20px" },
|
||||
{ fontSize: "12px", lineHeight: "16px" },
|
||||
{ fontSize: "14px", lineHeight: "20px" },
|
||||
]);
|
||||
await waitForAnimations(page);
|
||||
|
||||
await page.screenshot({ path: `${SHOTS}/04-thread-context.png` });
|
||||
|
||||
await applyConversationPreferences(page, "default", "compact");
|
||||
await expect
|
||||
.poll(readConversationMetrics)
|
||||
.toEqual([
|
||||
{ fontSize: "14px", lineHeight: "20px" },
|
||||
{ paddingBottom: "4px", paddingTop: "4px" },
|
||||
0,
|
||||
{ fontSize: "14px", lineHeight: "16px" },
|
||||
{ fontSize: "14px", lineHeight: "20px" },
|
||||
{ fontSize: "12px", lineHeight: "16px" },
|
||||
{ fontSize: "14px", lineHeight: "20px" },
|
||||
]);
|
||||
|
||||
await applyConversationPreferences(page, "smaller", "compact");
|
||||
await expect
|
||||
.poll(readConversationMetrics)
|
||||
.toEqual([
|
||||
{ fontSize: "13px", lineHeight: "18.5714px" },
|
||||
{ paddingBottom: "4px", paddingTop: "4px" },
|
||||
0,
|
||||
{ fontSize: "13px", lineHeight: "14.8571px" },
|
||||
{ fontSize: "13px", lineHeight: "18.5714px" },
|
||||
{ fontSize: "11.1429px", lineHeight: "14.8571px" },
|
||||
{ fontSize: "13px", lineHeight: "18.5714px" },
|
||||
]);
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({ path: `${SHOTS}/05-thread-context-compact.png` });
|
||||
|
||||
await applyConversationPreferences(page, "larger", "spacious");
|
||||
await expect
|
||||
.poll(readConversationMetrics)
|
||||
.toEqual([
|
||||
{ fontSize: "15px", lineHeight: "21.4286px" },
|
||||
{ paddingBottom: "8px", paddingTop: "8px" },
|
||||
4,
|
||||
{ fontSize: "15px", lineHeight: "17.1429px" },
|
||||
{ fontSize: "15px", lineHeight: "21.4286px" },
|
||||
{ fontSize: "12.8571px", lineHeight: "17.1429px" },
|
||||
{ fontSize: "15px", lineHeight: "21.4286px" },
|
||||
]);
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({ path: `${SHOTS}/06-thread-context-spacious.png` });
|
||||
|
||||
await applyConversationPreferences(page, "default", "comfortable");
|
||||
|
||||
await page.evaluate(() => {
|
||||
const isMac = /mac|iphone|ipad|ipod/i.test(navigator.platform);
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
code: "Equal",
|
||||
ctrlKey: !isMac,
|
||||
key: "+",
|
||||
metaKey: isMac,
|
||||
shiftKey: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await expect
|
||||
.poll(async () => [
|
||||
await page.evaluate(() =>
|
||||
window
|
||||
.getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--buzz-type-rem")
|
||||
.trim(),
|
||||
),
|
||||
...(await readConversationMetrics()),
|
||||
])
|
||||
.toEqual([
|
||||
"17.6px",
|
||||
{ fontSize: "15.4px", lineHeight: "22px" },
|
||||
{ paddingBottom: "4px", paddingTop: "4px" },
|
||||
2,
|
||||
{ fontSize: "15.4px", lineHeight: "17.6px" },
|
||||
{ fontSize: "15.4px", lineHeight: "22px" },
|
||||
{ fontSize: "13.2px", lineHeight: "17.6px" },
|
||||
{ fontSize: "15.4px", lineHeight: "22px" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -304,9 +304,8 @@ test("pairing completion updates the final step and resets after leaving", async
|
||||
const confirmButton = confirmation.getByTestId("confirm-sas");
|
||||
const cancelButton = confirmation.getByTestId("deny-sas");
|
||||
const confirmationBox = await confirmation.boundingBox();
|
||||
const confirmationTitleBox = await confirmation
|
||||
.getByTestId("pairing-sas-title")
|
||||
.boundingBox();
|
||||
const confirmationTitle = confirmation.getByTestId("pairing-sas-title");
|
||||
const confirmationTitleBox = await confirmationTitle.boundingBox();
|
||||
const confirmationCodeBox = await confirmationCode.boundingBox();
|
||||
const confirmationActionsBox = await confirmation
|
||||
.getByTestId("pairing-sas-actions")
|
||||
@@ -345,10 +344,7 @@ test("pairing completion updates the final step and resets after leaving", async
|
||||
await expect(
|
||||
confirmation.getByText(/Only confirm if you started this pairing/),
|
||||
).toHaveCount(0);
|
||||
await expect(confirmation.getByTestId("pairing-sas-title")).toHaveCSS(
|
||||
"font-size",
|
||||
"16px",
|
||||
);
|
||||
await expect(confirmationTitle).toHaveCSS("font-size", "16px");
|
||||
|
||||
mkdirSync(SCREENSHOT_DIR, { recursive: true });
|
||||
await waitForAnimations(page);
|
||||
|
||||
@@ -2489,7 +2489,10 @@ test("supports webview zoom keyboard shortcuts", async ({ page }) => {
|
||||
|
||||
const getTextScaleState = () =>
|
||||
page.evaluate(() => ({
|
||||
fontSize: getComputedStyle(document.documentElement).fontSize,
|
||||
rootFontSize: getComputedStyle(document.documentElement).fontSize,
|
||||
textRemSize: getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--buzz-type-rem")
|
||||
.trim(),
|
||||
storedScale: localStorage.getItem("buzz:text-scale"),
|
||||
webviewZoom: (window as Window & { __BUZZ_E2E_WEBVIEW_ZOOM__?: number })
|
||||
.__BUZZ_E2E_WEBVIEW_ZOOM__,
|
||||
@@ -2520,7 +2523,8 @@ test("supports webview zoom keyboard shortcuts", async ({ page }) => {
|
||||
await dispatchPrimaryShortcut("+", "Equal", true);
|
||||
|
||||
await expect.poll(getTextScaleState).toEqual({
|
||||
fontSize: "17.6px",
|
||||
rootFontSize: "16px",
|
||||
textRemSize: "17.6px",
|
||||
storedScale: "1.1",
|
||||
webviewZoom: 1,
|
||||
});
|
||||
@@ -2528,7 +2532,8 @@ test("supports webview zoom keyboard shortcuts", async ({ page }) => {
|
||||
await dispatchPrimaryShortcut("-", "Minus");
|
||||
|
||||
await expect.poll(getTextScaleState).toEqual({
|
||||
fontSize: "16px",
|
||||
rootFontSize: "16px",
|
||||
textRemSize: "16px",
|
||||
storedScale: null,
|
||||
webviewZoom: 1,
|
||||
});
|
||||
@@ -2537,7 +2542,8 @@ test("supports webview zoom keyboard shortcuts", async ({ page }) => {
|
||||
await dispatchPrimaryShortcut("+", "Equal", true);
|
||||
|
||||
await expect.poll(getTextScaleState).toEqual({
|
||||
fontSize: "19.2px",
|
||||
rootFontSize: "16px",
|
||||
textRemSize: "19.2px",
|
||||
storedScale: "1.2",
|
||||
webviewZoom: 1,
|
||||
});
|
||||
@@ -2545,12 +2551,95 @@ test("supports webview zoom keyboard shortcuts", async ({ page }) => {
|
||||
await dispatchPrimaryShortcut("0", "Digit0");
|
||||
|
||||
await expect.poll(getTextScaleState).toEqual({
|
||||
fontSize: "16px",
|
||||
rootFontSize: "16px",
|
||||
textRemSize: "16px",
|
||||
storedScale: null,
|
||||
webviewZoom: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test("storage clear resets composed font size and keyboard zoom across windows", async ({
|
||||
context,
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
await openSettings(page, "appearance");
|
||||
await page.getByTestId("font-size-larger").click();
|
||||
|
||||
const dispatchZoomIn = () =>
|
||||
page.evaluate(() => {
|
||||
const isMac = /mac|iphone|ipad|ipod/i.test(navigator.platform);
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
code: "Equal",
|
||||
ctrlKey: !isMac,
|
||||
key: "+",
|
||||
metaKey: isMac,
|
||||
shiftKey: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
for (let step = 0; step < 5; step += 1) {
|
||||
await dispatchZoomIn();
|
||||
}
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => ({
|
||||
fontSize: document.documentElement.dataset.fontSize,
|
||||
textRemSize: getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--buzz-type-rem")
|
||||
.trim(),
|
||||
textScale: localStorage.getItem("buzz:text-scale"),
|
||||
})),
|
||||
)
|
||||
.toEqual({
|
||||
fontSize: "larger",
|
||||
textRemSize: "25.714286px",
|
||||
textScale: "1.5",
|
||||
});
|
||||
|
||||
const peerPage = await context.newPage();
|
||||
await installMockBridge(peerPage);
|
||||
await peerPage.goto("/");
|
||||
await peerPage.evaluate(() => localStorage.clear());
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => ({
|
||||
fontSize: document.documentElement.dataset.fontSize,
|
||||
textRemSize: getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--buzz-type-rem")
|
||||
.trim(),
|
||||
textScale: localStorage.getItem("buzz:text-scale"),
|
||||
})),
|
||||
)
|
||||
.toEqual({
|
||||
fontSize: "default",
|
||||
textRemSize: "16px",
|
||||
textScale: null,
|
||||
});
|
||||
|
||||
await page.keyboard.press(
|
||||
process.platform === "darwin" ? "Meta+-" : "Control+-",
|
||||
);
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => ({
|
||||
textRemSize: getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--buzz-type-rem")
|
||||
.trim(),
|
||||
textScale: localStorage.getItem("buzz:text-scale"),
|
||||
})),
|
||||
)
|
||||
.toEqual({ textRemSize: "14.4px", textScale: "0.9" });
|
||||
|
||||
await peerPage.close();
|
||||
});
|
||||
|
||||
test("shows agent runtimes in agent settings", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ const EXPECTED_NAV_CENTER_Y = 23;
|
||||
// The macOS traffic lights are native chrome: with `trafficLightPosition`
|
||||
// x:16 they occupy roughly x 16–68 regardless of the app's Cmd +/- text
|
||||
// zoom. The top-chrome nav row must clear that band in fixed px, so the
|
||||
// clearance cannot shrink when the root font size scales down.
|
||||
// clearance cannot change when text scales.
|
||||
const TRAFFIC_LIGHT_RIGHT_EDGE = 72;
|
||||
|
||||
async function spoofMacPlatform(page: import("@playwright/test").Page) {
|
||||
@@ -90,15 +90,24 @@ async function seedTextScale(
|
||||
}, scale);
|
||||
}
|
||||
|
||||
async function expectRootFontSize(
|
||||
async function expectTextRemSize(
|
||||
page: import("@playwright/test").Page,
|
||||
fontSize: string,
|
||||
) {
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => getComputedStyle(document.documentElement).fontSize),
|
||||
page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--buzz-type-rem")
|
||||
.trim(),
|
||||
),
|
||||
)
|
||||
.toBe(fontSize);
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => getComputedStyle(document.documentElement).fontSize),
|
||||
)
|
||||
.toBe("16px");
|
||||
}
|
||||
|
||||
test.describe("top chrome macOS traffic-light clearance under text zoom", () => {
|
||||
@@ -143,8 +152,8 @@ test.describe("top chrome macOS traffic-light clearance under text zoom", () =>
|
||||
await installMockBridge(page);
|
||||
await page.goto("/");
|
||||
|
||||
// Confirm the zoomed-out scale actually applied to the root font size.
|
||||
await expectRootFontSize(page, "12px");
|
||||
// Confirm the zoomed-out text scale applied without changing the root.
|
||||
await expectTextRemSize(page, "12px");
|
||||
|
||||
expect(await firstNavButtonX(page)).toBeGreaterThanOrEqual(
|
||||
TRAFFIC_LIGHT_RIGHT_EDGE,
|
||||
@@ -161,7 +170,7 @@ test.describe("top chrome macOS traffic-light clearance under text zoom", () =>
|
||||
await installMockBridge(page);
|
||||
await page.goto("/");
|
||||
|
||||
await expectRootFontSize(page, "24px");
|
||||
await expectTextRemSize(page, "24px");
|
||||
|
||||
expect(await firstNavButtonX(page)).toBeGreaterThanOrEqual(
|
||||
TRAFFIC_LIGHT_RIGHT_EDGE,
|
||||
|
||||
Reference in New Issue
Block a user