mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Add conversation density preference
Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
@@ -24,7 +24,7 @@ export const THREAD_FOCUS_SLIVER_WIDTH_PX = 72;
|
||||
* with auto horizontal margins so the reading measure stays comfortable no
|
||||
* matter how wide the window gets.
|
||||
*/
|
||||
export const THREAD_FOCUS_COLUMN_MAX_WIDTH_PX = 880;
|
||||
export const THREAD_FOCUS_COLUMN_MAX_WIDTH_PX = 820;
|
||||
|
||||
/**
|
||||
* Horizontal distance the focus drawer travels on enter/exit.
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
DraftsPanel,
|
||||
type DraftViewItem,
|
||||
} from "@/features/messages/ui/DraftsPanel";
|
||||
import { MessageAuthorText } from "@/features/messages/ui/MessageHeader";
|
||||
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
|
||||
import type { Reminder } from "@/features/reminders/lib/reminderTypes";
|
||||
import { isDue } from "@/features/reminders/lib/reminderFilters";
|
||||
@@ -351,7 +352,7 @@ export function InboxListPane({
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
<span
|
||||
className="flex min-w-0 flex-1 items-start leading-4"
|
||||
className="flex min-w-0 flex-1 items-start leading-message-author"
|
||||
data-inbox-profile-trigger="true"
|
||||
>
|
||||
<UserProfilePopover
|
||||
@@ -360,14 +361,14 @@ export function InboxListPane({
|
||||
role={profileRole}
|
||||
triggerElement="span"
|
||||
>
|
||||
<span className="block max-w-full truncate rounded text-sm font-semibold leading-4 text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring">
|
||||
<MessageAuthorText className="block max-w-full">
|
||||
{item.senderLabel}
|
||||
</span>
|
||||
</MessageAuthorText>
|
||||
</UserProfilePopover>
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"flex shrink-0 items-center gap-1.5 text-xs leading-4 text-muted-foreground/70 transition-opacity group-hover/inbox-item:opacity-0 group-focus-within/inbox-item:opacity-0",
|
||||
"flex shrink-0 items-center gap-1.5 text-message-timestamp text-muted-foreground/70 transition-opacity group-hover/inbox-item:opacity-0 group-focus-within/inbox-item:opacity-0",
|
||||
isDone ? "font-normal" : "font-medium",
|
||||
)}
|
||||
>
|
||||
@@ -402,14 +403,14 @@ export function InboxListPane({
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"mt-1.5 text-sm leading-5 [&_a]:font-medium [&_a]:text-current",
|
||||
"mt-1.5 text-message [&_a]:font-medium [&_a]:text-current",
|
||||
isDone
|
||||
? "font-normal text-muted-foreground"
|
||||
: "font-semibold text-foreground",
|
||||
)}
|
||||
>
|
||||
<Markdown
|
||||
className="inbox-preview-markdown text-inherit leading-5"
|
||||
className="inbox-preview-markdown text-inherit"
|
||||
content={item.preview}
|
||||
interactive={false}
|
||||
mentionNames={item.mentionNames}
|
||||
|
||||
@@ -8,6 +8,10 @@ import type { TimelineMessage } from "@/features/messages/types";
|
||||
import { getConfigNudgeAuthorPubkey } from "@/features/messages/ui/configNudgeAuthPubkey";
|
||||
import { MessageActionBar } from "@/features/messages/ui/MessageActionBar";
|
||||
import { MessageAgentOwner } from "@/features/messages/ui/MessageAgentOwner";
|
||||
import {
|
||||
MessageAuthorText,
|
||||
MessageHeaderRow,
|
||||
} from "@/features/messages/ui/MessageHeader";
|
||||
import { MessageReactions } from "@/features/messages/ui/MessageReactions";
|
||||
import { UnreadDivider } from "@/features/messages/ui/UnreadDivider";
|
||||
import { useReactionHandler } from "@/features/messages/ui/useReactionHandler";
|
||||
@@ -109,7 +113,7 @@ export function InboxMessageRow({
|
||||
) : null}
|
||||
<article
|
||||
className={cn(
|
||||
"group/message relative z-10 mx-1 flex gap-2.5 rounded-2xl px-2 py-1 transition-colors hover:bg-muted/50 focus-within:bg-muted/50",
|
||||
"group/message relative z-10 mx-1 flex gap-2.5 rounded-2xl px-2 py-conversation-row transition-colors hover:bg-muted/50 focus-within:bg-muted/50",
|
||||
isContinuation ? "items-center" : "items-start",
|
||||
)}
|
||||
data-message-id={message.id}
|
||||
@@ -151,7 +155,10 @@ export function InboxMessageRow({
|
||||
className="flex w-9 shrink-0 self-stretch items-start justify-end pt-0.5"
|
||||
title={message.fullTimestampLabel}
|
||||
>
|
||||
<p className="shrink-0 cursor-default whitespace-nowrap text-xs font-normal leading-4 tabular-nums text-muted-foreground/55 opacity-0 transition-opacity group-hover/message:opacity-100 group-focus-within/message:opacity-100">
|
||||
<p
|
||||
className="shrink-0 cursor-default whitespace-nowrap text-message-timestamp font-normal tabular-nums text-muted-foreground/55 opacity-0 transition-opacity group-hover/message:opacity-100 group-focus-within/message:opacity-100"
|
||||
data-testid="inbox-message-timestamp"
|
||||
>
|
||||
{hoverTimestampLabel}
|
||||
</p>
|
||||
</div>
|
||||
@@ -175,18 +182,18 @@ export function InboxMessageRow({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
{isContinuation ? null : (
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-0">
|
||||
<MessageHeaderRow className="gap-x-2">
|
||||
<UserProfilePopover
|
||||
botIdenticonValue={message.authorLabel}
|
||||
pubkey={message.authorPubkey}
|
||||
role={profileRole}
|
||||
triggerElement="span"
|
||||
>
|
||||
<span className="block max-w-full truncate rounded text-sm font-semibold text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring">
|
||||
<MessageAuthorText className="block max-w-full">
|
||||
{message.authorLabel}
|
||||
</span>
|
||||
</MessageAuthorText>
|
||||
</UserProfilePopover>
|
||||
{message.isAgent ? (
|
||||
<MessageAgentOwner
|
||||
@@ -194,16 +201,19 @@ export function InboxMessageRow({
|
||||
ownerPubkey={message.ownerPubkey}
|
||||
/>
|
||||
) : null}
|
||||
<p className="shrink-0 text-xs font-normal tabular-nums text-muted-foreground/55">
|
||||
<p
|
||||
className="shrink-0 text-message-timestamp font-normal tabular-nums text-muted-foreground/55"
|
||||
data-testid="inbox-message-timestamp"
|
||||
>
|
||||
{message.fullTimestampLabel}
|
||||
</p>
|
||||
</div>
|
||||
</MessageHeaderRow>
|
||||
)}
|
||||
|
||||
<div className={isContinuation ? "mt-0" : "mt-0.5"}>
|
||||
<div className={isContinuation ? "mt-0" : "mt-conversation-body"}>
|
||||
<Markdown
|
||||
className={cn(
|
||||
"max-w-full text-left text-sm text-foreground",
|
||||
"max-w-full text-left text-message text-foreground",
|
||||
emojiOnly &&
|
||||
"text-4xl leading-tight [&_p]:leading-tight [&_img[data-custom-emoji]]:h-[1.45em] [&_img[data-custom-emoji]]:align-middle [&_button:has(img[data-custom-emoji])]:align-middle",
|
||||
)}
|
||||
|
||||
@@ -4,15 +4,15 @@
|
||||
*/
|
||||
|
||||
/** Inline gutter around thread message rows. */
|
||||
export const THREAD_PANEL_MESSAGE_GUTTER_CLASS = "px-2";
|
||||
export const THREAD_PANEL_MESSAGE_GUTTER_CLASS = "px-4";
|
||||
|
||||
/** Inline gutter around the thread composer and its activity row. */
|
||||
export const THREAD_PANEL_COMPOSER_GUTTER_CLASS = "px-5";
|
||||
|
||||
/**
|
||||
* Centers the reading column when a `columnMaxWidthPx` is supplied (focus-mode
|
||||
* drawer). `px-10` (40px) is the inline gutter between the column and the drawer
|
||||
* edges; the max-width itself is applied inline since it is a caller-provided
|
||||
* pixel value.
|
||||
* drawer). The responsive inline gutter keeps compact overlays usable while
|
||||
* retaining the calmer 40px edge at desktop widths; the max-width itself is
|
||||
* applied inline since it is a caller-provided pixel value.
|
||||
*/
|
||||
export const THREAD_PANEL_COLUMN_CLASS = "mx-auto w-full px-10";
|
||||
export const THREAD_PANEL_COLUMN_CLASS = "mx-auto w-full px-6 sm:px-10";
|
||||
|
||||
@@ -510,7 +510,7 @@ export function useRichTextEditor({
|
||||
attributes: {
|
||||
autocapitalize: "none",
|
||||
autocorrect: "off",
|
||||
class: `${MESSAGE_MARKDOWN_CLASS} min-h-0 resize-none overflow-y-hidden border-0 bg-transparent px-0 py-0 text-sm leading-5 text-foreground shadow-none focus-visible:ring-0 caret-foreground outline-hidden max-w-none`,
|
||||
class: `${MESSAGE_MARKDOWN_CLASS} min-h-0 resize-none overflow-y-hidden border-0 bg-transparent px-0 py-0 text-message font-normal tracking-normal text-foreground shadow-none focus-visible:ring-0 caret-foreground outline-hidden max-w-none`,
|
||||
"data-testid": "message-input",
|
||||
spellcheck: "true",
|
||||
},
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { Bot } from "lucide-react";
|
||||
|
||||
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
|
||||
|
||||
export function MessageAgentOwner({
|
||||
@@ -11,7 +9,7 @@ export function MessageAgentOwner({
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className="inline-flex min-w-0 max-w-56 items-baseline gap-1 text-xs leading-4 text-muted-foreground/65"
|
||||
className="inline-flex min-w-0 max-w-56 items-baseline gap-1 text-2xs leading-4 text-muted-foreground"
|
||||
data-testid="message-agent-owner"
|
||||
>
|
||||
<span className="sr-only">
|
||||
@@ -19,30 +17,22 @@ export function MessageAgentOwner({
|
||||
</span>
|
||||
{ownerPubkey && ownerLabel ? (
|
||||
<>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="inline-flex shrink-0 items-baseline gap-1 leading-4"
|
||||
>
|
||||
<Bot className="relative -top-px h-3.5 w-3.5 self-center" />
|
||||
<span>managed by</span>
|
||||
<span aria-hidden="true" className="shrink-0 leading-4">
|
||||
managed by
|
||||
</span>
|
||||
<UserProfilePopover
|
||||
pubkey={ownerPubkey}
|
||||
triggerAriaLabel={ownerLabel}
|
||||
triggerElement="span"
|
||||
>
|
||||
<span className="min-w-0 truncate rounded font-semibold text-foreground/85 hover:text-foreground hover:underline focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring">
|
||||
<span className="min-w-0 truncate rounded font-medium text-muted-foreground hover:text-foreground hover:underline focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring">
|
||||
{ownerLabel}
|
||||
</span>
|
||||
</UserProfilePopover>
|
||||
</>
|
||||
) : (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="inline-flex min-w-0 items-center gap-1"
|
||||
>
|
||||
<Bot className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">owner unavailable</span>
|
||||
<span aria-hidden="true" className="min-w-0 truncate">
|
||||
owner unavailable
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
@@ -14,7 +14,7 @@ export function MessageHeaderRow({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 flex-wrap items-baseline gap-x-1.5 gap-y-0 leading-4",
|
||||
"flex min-w-0 flex-wrap items-baseline gap-x-1.5 gap-y-0 leading-message-author",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
@@ -39,7 +39,7 @@ export function MessageAuthorText({
|
||||
return (
|
||||
<Component
|
||||
className={cn(
|
||||
"truncate text-sm font-bold leading-4 tracking-tight",
|
||||
"truncate text-message font-semibold leading-message-author tracking-normal",
|
||||
hoverUnderline && "hover:underline",
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -294,7 +294,7 @@ export const MessageRow = React.memo(
|
||||
message.body,
|
||||
message.tags,
|
||||
);
|
||||
const bodyOffsetClass = emojiOnly ? "mt-1" : "-mt-0.5";
|
||||
const bodyOffsetClass = emojiOnly ? "mt-1" : "mt-conversation-body";
|
||||
|
||||
const { nonDmChannelNames: channelNames } = useChannelNavigation();
|
||||
const openVideoReviewAt = useOpenVideoReviewAt();
|
||||
@@ -415,7 +415,7 @@ export const MessageRow = React.memo(
|
||||
<Markdown
|
||||
channelNames={channelNames}
|
||||
className={cn(
|
||||
"max-w-full text-sm",
|
||||
"max-w-full text-message",
|
||||
emojiOnly &&
|
||||
"text-4xl leading-tight [&_p]:leading-tight [&_img[data-custom-emoji]]:h-[1.45em] [&_img[data-custom-emoji]]:align-middle [&_button:has(img[data-custom-emoji])]:align-middle",
|
||||
)}
|
||||
@@ -648,7 +648,7 @@ export const MessageRow = React.memo(
|
||||
botIdenticonValue={message.author}
|
||||
>
|
||||
<button
|
||||
className="truncate rounded leading-4 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
|
||||
className="truncate rounded leading-message-author focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
|
||||
type="button"
|
||||
>
|
||||
{authorNode}
|
||||
@@ -881,7 +881,7 @@ export const MessageRow = React.memo(
|
||||
className={cn(
|
||||
"group/message relative z-10 rounded-2xl transition-colors",
|
||||
playEntrance && "motion-enter-conversation",
|
||||
"py-1",
|
||||
"py-conversation-row",
|
||||
hoverBackground
|
||||
? "mx-1 px-2 hover:bg-muted/50 focus-within:bg-muted/50"
|
||||
: isThreadReplyLayout
|
||||
|
||||
@@ -578,7 +578,7 @@ export function MessageThreadPanel({
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(THREAD_PANEL_MESSAGE_GUTTER_CLASS, "pb-1 pt-0")}
|
||||
className={cn(THREAD_PANEL_MESSAGE_GUTTER_CLASS, "pb-2 pt-1")}
|
||||
data-testid="message-thread-head"
|
||||
>
|
||||
<div className="rounded-2xl">
|
||||
@@ -636,7 +636,7 @@ export function MessageThreadPanel({
|
||||
|
||||
{showThreadHeadDivider ? (
|
||||
<div
|
||||
className={cn(THREAD_PANEL_MESSAGE_GUTTER_CLASS, "pb-3 pt-2")}
|
||||
className={cn(THREAD_PANEL_MESSAGE_GUTTER_CLASS, "pb-4 pt-3")}
|
||||
data-testid="message-thread-head-divider"
|
||||
>
|
||||
<Separator className="bg-border/60" />
|
||||
@@ -644,7 +644,7 @@ export function MessageThreadPanel({
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className={cn(THREAD_PANEL_MESSAGE_GUTTER_CLASS, "pb-3 pt-0")}
|
||||
className={cn(THREAD_PANEL_MESSAGE_GUTTER_CLASS, "pb-4 pt-0")}
|
||||
data-testid="message-thread-replies"
|
||||
>
|
||||
{threadRepliesPending && !isHuddleTranscript ? (
|
||||
|
||||
@@ -131,14 +131,14 @@ export function MessageThreadPanelSkeleton({
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={cn(THREAD_PANEL_MESSAGE_GUTTER_CLASS, "pb-1 pt-0")}
|
||||
className={cn(THREAD_PANEL_MESSAGE_GUTTER_CLASS, "pb-2 pt-1")}
|
||||
data-testid="message-thread-head-loading"
|
||||
>
|
||||
<ThreadMessageSkeleton isHead />
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"space-y-2.5 pb-3 pt-1",
|
||||
"space-y-2.5 pb-4 pt-2",
|
||||
THREAD_PANEL_MESSAGE_GUTTER_CLASS,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -34,7 +34,7 @@ export function MessageTimestamp({
|
||||
<TooltipTrigger asChild>
|
||||
<p
|
||||
className={cn(
|
||||
"shrink-0 cursor-default whitespace-nowrap text-xs font-normal leading-4 tabular-nums text-muted-foreground/55",
|
||||
"shrink-0 cursor-default whitespace-nowrap text-message-timestamp font-normal tabular-nums text-muted-foreground/55",
|
||||
className,
|
||||
)}
|
||||
data-testid="message-timestamp"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import {
|
||||
@@ -13,6 +14,12 @@ import {
|
||||
useLinkPreviewStyle,
|
||||
type LinkPreviewStyle,
|
||||
} from "@/shared/lib/linkPreviewStylePreference";
|
||||
import {
|
||||
previewConversationDensity,
|
||||
setConversationDensity,
|
||||
useConversationDensity,
|
||||
type ConversationDensity,
|
||||
} from "@/shared/lib/conversationDensityPreference";
|
||||
import {
|
||||
ACCENT_COLORS,
|
||||
DEFAULT_GLASS_OPACITY,
|
||||
@@ -31,6 +38,7 @@ import {
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import { Switch } from "@/shared/ui/switch";
|
||||
import { SettingsOptionRow } from "./SettingsOptionGroup";
|
||||
import { SettingsSegmentedControl } from "./SettingsSegmentedControl";
|
||||
|
||||
/** Buzz navigation can use either its production tint or a stronger tab. */
|
||||
export function ProminentActiveTabSetting() {
|
||||
@@ -79,6 +87,127 @@ const LINK_PREVIEW_STYLE_OPTIONS: {
|
||||
},
|
||||
];
|
||||
|
||||
const CONVERSATION_DENSITY_OPTIONS: readonly {
|
||||
value: ConversationDensity;
|
||||
label: string;
|
||||
}[] = [
|
||||
{
|
||||
value: "compact",
|
||||
label: "Compact",
|
||||
},
|
||||
{
|
||||
value: "comfortable",
|
||||
label: "Comfy",
|
||||
},
|
||||
{
|
||||
value: "spacious",
|
||||
label: "Spacious",
|
||||
},
|
||||
];
|
||||
|
||||
function ConversationDensityPreviewMessage({
|
||||
avatar,
|
||||
author,
|
||||
children,
|
||||
timestamp,
|
||||
}: {
|
||||
avatar: string;
|
||||
author: string;
|
||||
children: ReactNode;
|
||||
timestamp: string;
|
||||
}) {
|
||||
return (
|
||||
<article className="flex gap-2.5 py-conversation-row">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold text-muted-foreground">
|
||||
{avatar}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 flex-wrap items-baseline gap-x-1.5 leading-message-author">
|
||||
<span className="text-message font-semibold leading-message-author tracking-normal text-foreground">
|
||||
{author}
|
||||
</span>
|
||||
<span className="text-message-timestamp font-normal text-muted-foreground/65">
|
||||
{timestamp}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-conversation-body text-message font-normal tracking-normal text-foreground">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function ConversationDensityPreview() {
|
||||
return (
|
||||
<div className="px-4 py-3" data-testid="conversation-density-preview">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="relative overflow-hidden rounded-xl border border-border/65 bg-muted/45"
|
||||
data-testid="conversation-density-preview-surface"
|
||||
>
|
||||
<span className="absolute right-3 top-3 inline-flex rounded-full border border-border/65 bg-background/70 px-2.5 py-1 text-xs font-medium text-muted-foreground">
|
||||
Preview
|
||||
</span>
|
||||
<div className="p-4" data-testid="conversation-density-preview-content">
|
||||
<ConversationDensityPreviewMessage
|
||||
avatar="M"
|
||||
author="Maya"
|
||||
timestamp="9:41"
|
||||
>
|
||||
The revised conversation layout is ready to review.
|
||||
</ConversationDensityPreviewMessage>
|
||||
<ConversationDensityPreviewMessage
|
||||
avatar="T"
|
||||
author="Theo"
|
||||
timestamp="9:43"
|
||||
>
|
||||
<p>
|
||||
I added a longer message so you can compare line height and text
|
||||
spacing.
|
||||
</p>
|
||||
<p className="mt-conversation-paragraph">
|
||||
The same rhythm carries through channels, threads, DMs, and Inbox.
|
||||
</p>
|
||||
</ConversationDensityPreviewMessage>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Message sizing and spacing shared by every conversation surface. */
|
||||
export function ConversationDensitySetting() {
|
||||
const density = useConversationDensity();
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsOptionRow data-testid="conversation-density-row">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">Conversation density</p>
|
||||
<p
|
||||
className="text-sm font-normal text-muted-foreground/70"
|
||||
data-settings-subcopy
|
||||
>
|
||||
Adjust message size and spacing.
|
||||
</p>
|
||||
</div>
|
||||
<SettingsSegmentedControl
|
||||
className="w-72"
|
||||
legend="Conversation density"
|
||||
onPreviewChange={previewConversationDensity}
|
||||
onValueChange={setConversationDensity}
|
||||
optionTestIdPrefix="conversation-density"
|
||||
options={CONVERSATION_DENSITY_OPTIONS}
|
||||
testId="conversation-density-control"
|
||||
value={density}
|
||||
/>
|
||||
</SettingsOptionRow>
|
||||
<ConversationDensityPreview />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function LinkPreviewStyleSetting() {
|
||||
const style = useLinkPreviewStyle();
|
||||
const activeOption =
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
import { appearanceCommunityLabel } from "../lib/appearanceScopeCopy";
|
||||
import {
|
||||
AccentPickerContent,
|
||||
ConversationDensitySetting,
|
||||
GlassBackgroundSetting,
|
||||
LinkPreviewStyleSetting,
|
||||
ProminentActiveTabSetting,
|
||||
@@ -70,6 +71,7 @@ import { NotificationSettingsCard } from "./NotificationSettingsCard";
|
||||
import { AgentsSettingsPanel } from "./AgentsSettingsPanel";
|
||||
import { HostedCommunitiesSettingsCard } from "./HostedCommunitiesSettingsCard";
|
||||
import { SettingsOptionGroup, SettingsOptionRow } from "./SettingsOptionGroup";
|
||||
import { SettingsSegmentedControl } from "./SettingsSegmentedControl";
|
||||
import { ProfileSettingsCard } from "./ProfileSettingsCard";
|
||||
import { UpdateChecker } from "../UpdateChecker";
|
||||
import { SettingsSectionHeader } from "./SettingsSectionHeader";
|
||||
@@ -395,9 +397,9 @@ function SingleThemeTile({
|
||||
type AppearanceMode = "system" | "light" | "dark";
|
||||
|
||||
const APPEARANCE_MODE_OPTIONS = [
|
||||
{ mode: "system" as const, label: "System", Icon: SunMoon },
|
||||
{ mode: "light" as const, label: "Light", Icon: Sun },
|
||||
{ mode: "dark" as const, label: "Dark", Icon: Moon },
|
||||
{ value: "system" as const, label: "System", Icon: SunMoon },
|
||||
{ value: "light" as const, label: "Light", Icon: Sun },
|
||||
{ value: "dark" as const, label: "Dark", Icon: Moon },
|
||||
] as const;
|
||||
|
||||
// Reveal/hide motion for the accent picker: a small translate + opacity fade.
|
||||
@@ -670,39 +672,15 @@ function ThemeSettingsCard() {
|
||||
Follow your system or choose a light or dark appearance.
|
||||
</p>
|
||||
</div>
|
||||
<fieldset
|
||||
className="relative isolate grid h-8 w-[15rem] shrink-0 grid-cols-3 overflow-hidden rounded-md bg-muted/45 p-0.5"
|
||||
data-testid="appearance-color-mode-control"
|
||||
>
|
||||
<legend className="sr-only">Color mode</legend>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute bottom-0.5 left-0.5 top-0.5 z-0 rounded-md bg-background shadow-sm transition-transform duration-[250ms] ease-out motion-reduce:transition-none"
|
||||
data-testid="appearance-color-mode-indicator"
|
||||
style={{
|
||||
transform: `translateX(${APPEARANCE_MODE_OPTIONS.findIndex((option) => option.mode === selectedMode) * 100}%)`,
|
||||
width: "calc((100% - 4px) / 3)",
|
||||
}}
|
||||
/>
|
||||
{APPEARANCE_MODE_OPTIONS.map(({ mode, label, Icon }) => (
|
||||
<button
|
||||
aria-pressed={selectedMode === mode}
|
||||
className={cn(
|
||||
"relative z-10 flex h-full items-center justify-center gap-1.5 rounded-md bg-transparent px-2.5 text-xs font-medium transition-colors duration-[250ms] ease-out focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none",
|
||||
selectedMode === mode
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
data-testid={`appearance-mode-${mode}`}
|
||||
key={mode}
|
||||
onClick={() => handleModeSelect(mode)}
|
||||
type="button"
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</fieldset>
|
||||
<SettingsSegmentedControl
|
||||
indicatorTestId="appearance-color-mode-indicator"
|
||||
legend="Color mode"
|
||||
onValueChange={handleModeSelect}
|
||||
optionTestIdPrefix="appearance-mode"
|
||||
options={APPEARANCE_MODE_OPTIONS}
|
||||
testId="appearance-color-mode-control"
|
||||
value={selectedMode}
|
||||
/>
|
||||
</SettingsOptionRow>
|
||||
|
||||
<SettingsOptionRow data-testid="theme-style-row">
|
||||
@@ -804,6 +782,7 @@ function ThemeSettingsCard() {
|
||||
data-testid="appearance-preferences-card"
|
||||
title="Preferences"
|
||||
>
|
||||
<ConversationDensitySetting />
|
||||
<LinkPreviewStyleSetting />
|
||||
<ThreadLayoutSetting />
|
||||
</SettingsOptionGroup>
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
|
||||
type SegmentOption<Value extends string> = {
|
||||
value: Value;
|
||||
label: string;
|
||||
Icon?: React.ComponentType<{ className?: string }>;
|
||||
};
|
||||
|
||||
export function SettingsSegmentedControl<Value extends string>({
|
||||
className,
|
||||
indicatorTestId,
|
||||
legend,
|
||||
onPreviewChange,
|
||||
onValueChange,
|
||||
optionTestIdPrefix,
|
||||
options,
|
||||
testId,
|
||||
value,
|
||||
}: {
|
||||
className?: string;
|
||||
indicatorTestId?: string;
|
||||
legend: string;
|
||||
onPreviewChange?: (value: Value | null) => void;
|
||||
onValueChange: (value: Value) => void;
|
||||
optionTestIdPrefix: string;
|
||||
options: readonly SegmentOption<Value>[];
|
||||
testId: string;
|
||||
value: Value;
|
||||
}) {
|
||||
const [previewValue, setPreviewValue] = React.useState<Value | null>(null);
|
||||
const pointerStartXRef = React.useRef<number | null>(null);
|
||||
const pointerStartValueRef = React.useRef<Value | null>(null);
|
||||
const scrubValueRef = React.useRef<Value | null>(null);
|
||||
const skipPointerClickRef = React.useRef(false);
|
||||
const displayedValue = previewValue ?? value;
|
||||
const selectedIndex = Math.max(
|
||||
0,
|
||||
options.findIndex((option) => option.value === displayedValue),
|
||||
);
|
||||
|
||||
const getValueAtPointer = React.useCallback(
|
||||
(element: HTMLFieldSetElement, clientX: number): Value => {
|
||||
const bounds = element.getBoundingClientRect();
|
||||
const position = Math.max(
|
||||
0,
|
||||
Math.min(bounds.width - 1, clientX - bounds.left),
|
||||
);
|
||||
const index = Math.min(
|
||||
options.length - 1,
|
||||
Math.floor((position / bounds.width) * options.length),
|
||||
);
|
||||
return options[index]?.value ?? value;
|
||||
},
|
||||
[options, value],
|
||||
);
|
||||
|
||||
const preview = React.useCallback(
|
||||
(nextValue: Value | null) => {
|
||||
scrubValueRef.current = nextValue;
|
||||
setPreviewValue(nextValue);
|
||||
onPreviewChange?.(nextValue);
|
||||
},
|
||||
[onPreviewChange],
|
||||
);
|
||||
|
||||
React.useEffect(
|
||||
() => () => {
|
||||
onPreviewChange?.(null);
|
||||
},
|
||||
[onPreviewChange],
|
||||
);
|
||||
|
||||
const handlePointerDown = (
|
||||
event: React.PointerEvent<HTMLFieldSetElement>,
|
||||
) => {
|
||||
if (!onPreviewChange || event.button !== 0) return;
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
pointerStartXRef.current = event.clientX;
|
||||
pointerStartValueRef.current = getValueAtPointer(
|
||||
event.currentTarget,
|
||||
event.clientX,
|
||||
);
|
||||
scrubValueRef.current = null;
|
||||
skipPointerClickRef.current = true;
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
const handlePointerMove = (
|
||||
event: React.PointerEvent<HTMLFieldSetElement>,
|
||||
) => {
|
||||
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return;
|
||||
const nextValue = getValueAtPointer(event.currentTarget, event.clientX);
|
||||
const pointerStartX = pointerStartXRef.current;
|
||||
const pointerStartValue = pointerStartValueRef.current;
|
||||
const crossedDragThreshold =
|
||||
pointerStartX != null && Math.abs(event.clientX - pointerStartX) >= 4;
|
||||
if (
|
||||
scrubValueRef.current == null &&
|
||||
!crossedDragThreshold &&
|
||||
nextValue === pointerStartValue
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (nextValue !== scrubValueRef.current) preview(nextValue);
|
||||
};
|
||||
|
||||
const handlePointerUp = (event: React.PointerEvent<HTMLFieldSetElement>) => {
|
||||
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return;
|
||||
const nextValue = getValueAtPointer(event.currentTarget, event.clientX);
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
pointerStartXRef.current = null;
|
||||
pointerStartValueRef.current = null;
|
||||
onValueChange(nextValue);
|
||||
preview(null);
|
||||
globalThis.setTimeout(() => {
|
||||
skipPointerClickRef.current = false;
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const handlePointerCancel = (
|
||||
event: React.PointerEvent<HTMLFieldSetElement>,
|
||||
) => {
|
||||
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return;
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
pointerStartXRef.current = null;
|
||||
pointerStartValueRef.current = null;
|
||||
skipPointerClickRef.current = false;
|
||||
preview(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<fieldset
|
||||
className={cn(
|
||||
"relative isolate grid h-8 w-60 shrink-0 grid-cols-3 overflow-hidden rounded-md bg-muted/45 p-0.5",
|
||||
onPreviewChange && "touch-none select-none cursor-ew-resize",
|
||||
className,
|
||||
)}
|
||||
data-testid={testId}
|
||||
onPointerCancel={handlePointerCancel}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
>
|
||||
<legend className="sr-only">{legend}</legend>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"absolute bottom-0.5 left-0.5 top-0.5 z-0 rounded-md bg-background shadow-sm transition-transform duration-200 ease-in-out motion-reduce:transition-none",
|
||||
previewValue && "duration-0",
|
||||
)}
|
||||
data-testid={indicatorTestId ?? `${testId}-indicator`}
|
||||
style={{
|
||||
transform: `translateX(${selectedIndex * 100}%)`,
|
||||
width: `calc((100% - 0.25rem) / ${options.length})`,
|
||||
}}
|
||||
/>
|
||||
{options.map(({ value: optionValue, label, Icon }) => (
|
||||
<button
|
||||
aria-pressed={value === optionValue}
|
||||
className={cn(
|
||||
"relative z-10 flex h-full items-center justify-center gap-1.5 rounded-md bg-transparent px-2.5 text-xs font-medium transition-colors duration-150 ease-out focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none",
|
||||
displayedValue === optionValue
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
data-testid={`${optionTestIdPrefix}-${optionValue}`}
|
||||
key={optionValue}
|
||||
onClick={(event) => {
|
||||
if (event.detail > 0 && skipPointerClickRef.current) {
|
||||
skipPointerClickRef.current = false;
|
||||
return;
|
||||
}
|
||||
onValueChange(optionValue);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{Icon ? <Icon className="h-3.5 w-3.5" /> : null}
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,8 @@ import ReactDOM from "react-dom/client";
|
||||
import { App } from "@/app/App";
|
||||
import { RootErrorBoundary } from "@/app/RootErrorBoundary";
|
||||
import { NostrBindConsentDialog } from "@/features/profile/ui/NostrBindConsentDialog";
|
||||
import "@fontsource-variable/inter/wght.css";
|
||||
import "@fontsource-variable/inter/opsz.css";
|
||||
import "@fontsource-variable/inter/opsz-italic.css";
|
||||
import "@fontsource/jetbrains-mono/400.css";
|
||||
import "@fontsource/jetbrains-mono/700.css";
|
||||
import "@/shared/styles/globals.css";
|
||||
@@ -19,6 +20,7 @@ import { Toaster } from "@/shared/ui/sonner";
|
||||
import { TooltipProvider } from "@/shared/ui/tooltip";
|
||||
import { recoverLocalStorageQuotaOnStartup } from "@/shared/lib/localStorageQuota";
|
||||
import { startLocalStorageSweep } from "@/shared/lib/localStorageSweep";
|
||||
import { initializeConversationDensityPreference } from "@/shared/lib/conversationDensityPreference";
|
||||
|
||||
type E2eWindow = Window & {
|
||||
__BUZZ_E2E__?: unknown;
|
||||
@@ -123,6 +125,7 @@ async function bootstrap() {
|
||||
resetDevWebviewStateFromUrl();
|
||||
configureDevE2eBridgeFromUrl();
|
||||
recoverLocalStorageQuotaOnStartup();
|
||||
initializeConversationDensityPreference();
|
||||
startLocalStorageSweep();
|
||||
await installE2eBridgeIfConfigured();
|
||||
await migrateLegacyCommunityStorageBeforeRender();
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { extendTailwindMerge } from "tailwind-merge";
|
||||
|
||||
const mergeClassNames = extendTailwindMerge({
|
||||
extend: {
|
||||
classGroups: {
|
||||
"font-size": [
|
||||
{
|
||||
text: ["message", "message-timestamp"],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
return mergeClassNames(clsx(inputs));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
const values = new Map();
|
||||
const attributes = new Map();
|
||||
|
||||
globalThis.localStorage = {
|
||||
getItem: (key) => values.get(key) ?? null,
|
||||
setItem: (key, value) => values.set(key, String(value)),
|
||||
};
|
||||
globalThis.document = {
|
||||
documentElement: {
|
||||
setAttribute: (name, value) => attributes.set(name, value),
|
||||
},
|
||||
};
|
||||
|
||||
const preference = await import("./conversationDensityPreference.ts");
|
||||
|
||||
test("defaults invalid and missing conversation densities to comfortable", () => {
|
||||
assert.equal(preference.parseConversationDensity(null), "comfortable");
|
||||
assert.equal(preference.parseConversationDensity("dense"), "comfortable");
|
||||
assert.equal(preference.parseConversationDensity("compact"), "compact");
|
||||
assert.equal(
|
||||
preference.parseConversationDensity("comfortable"),
|
||||
"comfortable",
|
||||
);
|
||||
assert.equal(preference.parseConversationDensity("spacious"), "spacious");
|
||||
});
|
||||
|
||||
test("persists and applies the selected conversation density", () => {
|
||||
preference.setConversationDensity("compact");
|
||||
assert.equal(preference.getConversationDensity(), "compact");
|
||||
assert.equal(
|
||||
values.get(preference.CONVERSATION_DENSITY_STORAGE_KEY),
|
||||
"compact",
|
||||
);
|
||||
assert.equal(attributes.get("data-conversation-density"), "compact");
|
||||
});
|
||||
|
||||
test("previews a density without changing the saved preference", () => {
|
||||
preference.setConversationDensity("compact");
|
||||
preference.previewConversationDensity("spacious");
|
||||
assert.equal(preference.getConversationDensity(), "compact");
|
||||
assert.equal(
|
||||
values.get(preference.CONVERSATION_DENSITY_STORAGE_KEY),
|
||||
"compact",
|
||||
);
|
||||
assert.equal(attributes.get("data-conversation-density"), "spacious");
|
||||
|
||||
preference.previewConversationDensity(null);
|
||||
assert.equal(attributes.get("data-conversation-density"), "compact");
|
||||
});
|
||||
|
||||
test("initializes from the persisted conversation density", () => {
|
||||
values.set(preference.CONVERSATION_DENSITY_STORAGE_KEY, "spacious");
|
||||
preference.initializeConversationDensityPreference();
|
||||
assert.equal(preference.getConversationDensity(), "spacious");
|
||||
assert.equal(attributes.get("data-conversation-density"), "spacious");
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import * as React from "react";
|
||||
|
||||
/** Device-level message sizing and spacing used across conversation surfaces. */
|
||||
export type ConversationDensity = "compact" | "comfortable" | "spacious";
|
||||
|
||||
export const CONVERSATION_DENSITY_STORAGE_KEY =
|
||||
"buzz.appearance.conversationDensity";
|
||||
export const DEFAULT_CONVERSATION_DENSITY: ConversationDensity = "comfortable";
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
let conversationDensity: ConversationDensity = DEFAULT_CONVERSATION_DENSITY;
|
||||
|
||||
export function parseConversationDensity(
|
||||
value: string | null | undefined,
|
||||
): ConversationDensity {
|
||||
return value === "compact" || value === "comfortable" || value === "spacious"
|
||||
? value
|
||||
: DEFAULT_CONVERSATION_DENSITY;
|
||||
}
|
||||
|
||||
function readStoredConversationDensity(): ConversationDensity {
|
||||
try {
|
||||
return parseConversationDensity(
|
||||
globalThis.localStorage?.getItem(CONVERSATION_DENSITY_STORAGE_KEY),
|
||||
);
|
||||
} catch {
|
||||
return DEFAULT_CONVERSATION_DENSITY;
|
||||
}
|
||||
}
|
||||
|
||||
function applyConversationDensity(density: ConversationDensity): void {
|
||||
globalThis.document?.documentElement?.setAttribute(
|
||||
"data-conversation-density",
|
||||
density,
|
||||
);
|
||||
}
|
||||
|
||||
function notifyListeners(): void {
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
/** Apply the persisted preference before React renders to avoid a layout jump. */
|
||||
export function initializeConversationDensityPreference(): void {
|
||||
const nextDensity = readStoredConversationDensity();
|
||||
const changed = nextDensity !== conversationDensity;
|
||||
conversationDensity = nextDensity;
|
||||
applyConversationDensity(nextDensity);
|
||||
if (changed) notifyListeners();
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
export function getConversationDensity(): ConversationDensity {
|
||||
return conversationDensity;
|
||||
}
|
||||
|
||||
export function setConversationDensity(density: ConversationDensity): void {
|
||||
conversationDensity = density;
|
||||
applyConversationDensity(density);
|
||||
try {
|
||||
globalThis.localStorage?.setItem(CONVERSATION_DENSITY_STORAGE_KEY, density);
|
||||
} catch {
|
||||
// Persistence is best-effort; the live preference still applies.
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/** Temporarily apply a density without changing the saved preference. */
|
||||
export function previewConversationDensity(
|
||||
density: ConversationDensity | null,
|
||||
): void {
|
||||
applyConversationDensity(density ?? conversationDensity);
|
||||
}
|
||||
|
||||
export function useConversationDensity(): ConversationDensity {
|
||||
return React.useSyncExternalStore(
|
||||
subscribe,
|
||||
getConversationDensity,
|
||||
() => DEFAULT_CONVERSATION_DENSITY,
|
||||
);
|
||||
}
|
||||
@@ -93,10 +93,9 @@
|
||||
}
|
||||
|
||||
.rich-text-composer .tiptap {
|
||||
@apply text-message font-normal tracking-normal;
|
||||
outline: none;
|
||||
min-height: 1lh;
|
||||
font-size: var(--text-sm);
|
||||
line-height: var(--text-sm--line-height);
|
||||
}
|
||||
|
||||
.rich-text-composer .tiptap p {
|
||||
|
||||
@@ -2,6 +2,20 @@
|
||||
:root {
|
||||
/* Catppuccin Latte (mauve accent) */
|
||||
--radius: 0.625rem;
|
||||
/*
|
||||
* Comfortable conversation rhythm for channels, DMs, threads, Inbox, and
|
||||
* the composer. The Appearance preference overrides this complete scale
|
||||
* below, while rem units keep Cmd +/- text scaling intact.
|
||||
*/
|
||||
--conversation-message-font-size: 0.9375rem;
|
||||
--conversation-message-line-height: 1.25rem;
|
||||
--conversation-author-line-height: 1.0625rem;
|
||||
--conversation-body-gap: 0.125rem;
|
||||
--conversation-row-padding-block: 0.375rem;
|
||||
--conversation-paragraph-gap: 0.5rem;
|
||||
--conversation-list-item-gap: 0.375rem;
|
||||
--conversation-timestamp-font-size: 0.6875rem;
|
||||
--conversation-timestamp-line-height: 1rem;
|
||||
--background: 220 23.08% 94.9%;
|
||||
--foreground: 234 16.02% 35.49%;
|
||||
--card: 220 23.08% 94.9%;
|
||||
@@ -65,6 +79,30 @@
|
||||
--buzz-hosted-community-identity-bg: 255 255 255;
|
||||
}
|
||||
|
||||
:root[data-conversation-density="compact"] {
|
||||
--conversation-message-font-size: 0.875rem;
|
||||
--conversation-message-line-height: 1.1875rem;
|
||||
--conversation-author-line-height: 1rem;
|
||||
--conversation-body-gap: 0rem;
|
||||
--conversation-row-padding-block: 0.25rem;
|
||||
--conversation-paragraph-gap: 0.375rem;
|
||||
--conversation-list-item-gap: 0.25rem;
|
||||
--conversation-timestamp-font-size: 0.625rem;
|
||||
--conversation-timestamp-line-height: 0.875rem;
|
||||
}
|
||||
|
||||
:root[data-conversation-density="spacious"] {
|
||||
--conversation-message-font-size: 1rem;
|
||||
--conversation-message-line-height: 1.375rem;
|
||||
--conversation-author-line-height: 1.125rem;
|
||||
--conversation-body-gap: 0.25rem;
|
||||
--conversation-row-padding-block: 0.5rem;
|
||||
--conversation-paragraph-gap: 0.625rem;
|
||||
--conversation-list-item-gap: 0.5rem;
|
||||
--conversation-timestamp-font-size: 0.75rem;
|
||||
--conversation-timestamp-line-height: 1.125rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
/* Catppuccin Macchiato (mauve accent) */
|
||||
--radius: 0.625rem;
|
||||
@@ -153,6 +191,10 @@
|
||||
body {
|
||||
@apply bg-background text-foreground antialiased;
|
||||
font-family: "Inter Variable", Inter, "Avenir Next", "Segoe UI", sans-serif;
|
||||
font-synthesis-weight: none;
|
||||
font-variant-emoji: unicode;
|
||||
font-variant-ligatures: no-contextual;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -1893,10 +1893,10 @@ function MarkdownInner({
|
||||
className={cn(
|
||||
MESSAGE_MARKDOWN_CLASS,
|
||||
[
|
||||
"max-w-none wrap-anywhere text-sm leading-5 text-foreground",
|
||||
"max-w-none wrap-anywhere text-message font-normal tracking-normal text-foreground",
|
||||
"[&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
|
||||
"[&>*+*]:mt-3",
|
||||
"[&>p+p]:mt-1.5",
|
||||
"[&>p+p]:mt-conversation-paragraph [&>ol]:space-y-conversation-list [&>ul]:space-y-conversation-list",
|
||||
"[&>*+h1]:mt-3.5 [&>*+h2]:mt-3.5 [&>*+h3]:mt-3.5 [&>*+h4]:mt-3.5 [&>*+h5]:mt-3.5 [&>*+h6]:mt-3.5",
|
||||
"[&>h1+*]:mt-0.5 [&>h2+*]:mt-0.5 [&>h3+*]:mt-0.5 [&>h4+*]:mt-0.5 [&>h5+*]:mt-0.5 [&>h6+*]:mt-0.5",
|
||||
"[&>h1+h2]:mt-1.5! [&>h2+h3]:mt-1.5! [&>h3+h4]:mt-1.5! [&>h4+h5]:mt-1.5! [&>h5+h6]:mt-1.5!",
|
||||
|
||||
Reference in New Issue
Block a user