diff --git a/AGENTS.md b/AGENTS.md index 7fb96adb79..7b56c359d5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -412,6 +412,40 @@ just desktop-dev # web-only dev server (faster iteration) just dev # full Tauri app with native shell ``` +### Text sizing & zoom (use rem, never px) + +The desktop app implements Cmd +/- zoom by scaling the root `` +font-size (`desktop/src/app/useWebviewZoomShortcuts.ts`) and pinning the native +webview zoom. **Only rem-based text scales with zoom — hardcoded px text sizes +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 + 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 + apart pixel-by-pixel; keep meta text on these two tokens, not new arbitrary + values. +- ❌ `text-[15px]`, `text-[13px]`, CSS `font-size: 15px` — px froze against zoom + and caused the message-timeline regression (PR #891). +- ❌ Arbitrary rem literals too: `text-[0.6875rem]`, `text-[0.9rem]`, etc. They + zoom fine but re-fragment the scale we consolidated. Use a named token. + +Prefer stock tokens — they're rem and zoom-safe. Only if a design genuinely +needs a size the stock/`2xs`/`3xs` scale can't express should you **add a +rem-based token** (in `desktop/tailwind.config.js` under `theme.extend.fontSize`) +rather than an arbitrary literal. A CI guard (`pnpm check:px-text`, in +`desktop/scripts/check-px-text.mjs`) scans all of `desktop/src` and fails on any +new arbitrary text-size literal — px **or** rem/em. Genuinely decorative glyphs +(e.g. the `text-[6rem]` avatar emoji) are allowlisted by `path:line` in that +script. + ### Workspace Switching The desktop app supports multiple workspaces (each backed by a different relay). diff --git a/RESEARCH/CHAT_BASE_TYPE_SCALE.md b/RESEARCH/CHAT_BASE_TYPE_SCALE.md new file mode 100644 index 0000000000..e71c7eca9a --- /dev/null +++ b/RESEARCH/CHAT_BASE_TYPE_SCALE.md @@ -0,0 +1,84 @@ +--- +title: "Chat-as-base type scale: re-anchoring the timeline type ramp" +tags: [desktop, typography, design-tokens, tailwind, zoom] +status: active +created: 2026-06-15 +--- + +# Chat-as-base type scale + +## Why this exists +tho's reframe (2026-06-15): **chat text *is* the base size** — the fundamental +building block of the app. It should not be a special between-the-cracks token +(`text-chat` = 15px, wedged between Tailwind's `text-sm` 14px and `text-base` +16px). Direction: **bump chat to 16px and make it the base**, then re-anchor the +*other* timeline/thread elements that currently sit relative to the old ~15px +chat assumption. This is a **deliberate, wider pass** — a design decision, not a +surgical bugfix. + +Builds on the zoom-regression work (PR #1051, branch `tho/rem-font-zoom-fix`) +which introduced the `text-chat`/`text-code` rem tokens. That fix preserved +Kenny's 15px intent; this pass *changes* that intent to 16px-as-base. + +## Non-negotiables (carry forward from the zoom fix) +- **Everything stays rem.** Cmd +/- zoom scales the root `` font-size + (rem-only by design, from #573). px would freeze against zoom — that was the + original bug. The `check:px-text` guard already enforces this; keep it happy. +- No `html { font-size }` override exists, so root is browser-default 16px and + rem math is standard (1rem = 16px). The `--font-size: 14px` var in globals.css + is scoped to the emoji-picker component, NOT the app root. + +## Current type landscape (timeline/thread render path) +`text-chat` (15px) is used in: +- `MessageRow.tsx` — markdown body wrapper, author name (`` and `

`) +- `markdown.tsx` — chat body wrapper +- `mentionChip.ts` — mention chip text +- (`text-code` 13px — inline/block code in `markdown.tsx`) +- `globals.css` `.mention-highlight` — 0.9375rem + +Satellite elements currently on the **stock** scale (these are the "other +elements that sit relative to chat" tho means — review each for whether it still +reads right once chat = 16px base): +- `MessageTimestamp.tsx` — text-xs +- `SystemMessageRow.tsx` — text-xs / text-sm +- `MessageThreadSummaryRow.tsx` — text-xs +- `MessageThreadPanel.tsx` — text-sm / text-xs +- `MessageReactions.tsx` — text-xs / text-sm +- `MessageActionBar.tsx` — text-xs +- `TypingIndicatorRow.tsx` — text-xs / text-sm +- `MessageTimeline.tsx` — text-base / text-sm / text-xl (empty states / headers) +- `MessageRow.tsx` (secondary bits) — text-sm / text-xs + +## The design question for the build +If chat === base (16px), the type ramp around it should be *intentional*, not +incidental. The relationships that matter: +- chat body / author = base (16px) +- timestamps, metadata, system rows, reactions = one or two steps down, + consistently (don't leave them as ad-hoc text-xs/text-sm if the ratio now + looks off against a 16px anchor) +- code = its own deliberate step down from chat + +Open for Bart to decide the cleanest expression: lean on stock Tailwind tokens +where they land right now that base is the anchor, retire/rename `text-chat` if +it's redundant with `text-base`, and only define custom rem tokens where the +stock scale genuinely can't express the intended step. + +## Out of scope +- App-wide px sweep (the ~130 px classes elsewhere). Stay in the + timeline/thread render path + the type tokens that serve it. +- Don't widen the `check:px-text` guard roots in this pass (Marge's noted + follow-up) unless a new render-path file demands it. + +## Decision (built) +Chat === `text-base` (16px, stock). Both custom tokens **retired** — `text-chat` +and `text-code` were artifacts of the 15px-wedge era; with chat at the stock +base they're redundant. Code (inline + block) re-anchored to stock `text-sm` +(14px) — a deliberate, documented one-notch step down from the 16px base. + +Satellites left on their stock tokens **deliberately**: against a 16px base the +ratios actually tighten into the standard ramp (e.g. timestamps `text-xs` 12px = +clean base→sm→xs drop, was the awkward 12/15 before). Bumping them would inflate +the UI for no design gain — the right call is to keep them and document why. + +Net: zero custom font tokens. `.mention-highlight` (composer) follows chat to +`1rem` (16px). Everything rem, guard stays green. diff --git a/RESEARCH/TIMELINE_ZOOM_REM_REGRESSION.md b/RESEARCH/TIMELINE_ZOOM_REM_REGRESSION.md new file mode 100644 index 0000000000..8e56cd5ffc --- /dev/null +++ b/RESEARCH/TIMELINE_ZOOM_REM_REGRESSION.md @@ -0,0 +1,45 @@ +# Timeline Zoom Regression — Findings (for Bart) + +## The bug +Cmd +/- zoom no longer scales message-timeline & thread text. + +## Why (mechanism) +`desktop/src/app/useWebviewZoomShortcuts.ts` scales the ROOT `` font-size +(rem-based scaling) and pins native `webview.setZoom(DEFAULT)`. So only **rem** +sizes scale; hardcoded **px** sizes are frozen. This is the intended approach +("only text should scale" — keeps webview coordinate system stable). Do NOT +revert to native webview zoom. + +## Two-layer history +1. **#573** (9e76a08a, May 14) — switched zoom native→root-font-size/rem-only. +2. **#891** (45f3dfe5, Jun 8, "Tune chat text sizing", klopez4212) — the recent + timeline regression. Converted timeline rem→px. + +## Kenny's intent in #891 (PRESERVE THIS) +He bumped chat text up from `text-sm` (0.875rem=14px) to **15px** because sm felt +too small. Conversions made: +- MessageRow author name `` & `

`: `text-sm` → `text-[15px]` +- markdown body / mentionChip: `text-sm` → `text-[15px]` +- `globals.css` `.mention-highlight`: added `font-size: 15px`, radius 0.375rem→4px +- also touched MessageTimeline, SystemMessageRow, MessageThreadSummaryRow + +## The crux +Tailwind v4 here uses STOCK text tokens (no `@theme` override). Stock scale: +text-sm=14px, text-base=16px. **15px sits between them — no stock token exists.** +That's WHY Kenny reached for arbitrary px. + +## tho's directive +- Use rem + Tailwind tokens wherever possible. +- Preserve Kenny's visual intent (the 15px chat sizing). +- Outcome MAY be to define/update a text-size token to yield 15px in rem — but + only if stock tokens genuinely can't deliver the look. Take a critical pass: + prefer a stock token if it looks right; introduce a custom token only if needed. + +## Scope: timeline + thread render path only +MessageRow, markdown.tsx, mentionChip.ts, SystemMessageRow, +MessageThreadSummaryRow, MessageTimeline, and the relevant globals.css rules +(incl. `.mention-highlight` px font-size). Verify zoom works after. + +## Codify (fix #2) +No custom lint exists. Add a guard (Biome rule or CI grep) flagging new +`text-[NNpx]` / px `fontSize` in the desktop app + a note in AGENTS.md/CLAUDE.md. diff --git a/desktop/package.json b/desktop/package.json index 3717b303cd..c28742815e 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -8,8 +8,9 @@ "build": "tsc && vite build", "typecheck": "tsc --noEmit", "check:file-sizes": "node ./scripts/check-file-sizes.mjs", + "check:px-text": "node ./scripts/check-px-text.mjs", "lint": "biome lint .", - "check": "biome check . && pnpm check:file-sizes", + "check": "biome check . && pnpm check:file-sizes && pnpm check:px-text", "format": "biome format --write .", "test": "node --import ./test-loader.mjs --experimental-strip-types --test 'src/**/*.test.mjs'", "preview": "vite preview", diff --git a/desktop/scripts/check-px-text.mjs b/desktop/scripts/check-px-text.mjs new file mode 100644 index 0000000000..ea70ca56d5 --- /dev/null +++ b/desktop/scripts/check-px-text.mjs @@ -0,0 +1,36 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { runPxTextCheck } from "../../scripts/check-px-text-core.mjs"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const projectRoot = path.resolve(__dirname, ".."); + +// Enforces the rem-token text scale app-wide. The rem→px zoom regression +// (PR #891) landed in the message-timeline render path, but arbitrary text +// literals (`text-[…px]`, `text-[…rem]`) had drifted across the whole desktop +// app — so the guard now scans all of `src`. Readable text MUST use a rem-based +// token (the stock `text-base`/`text-sm`/`text-xs` scale, or the `text-2xs` / +// `text-3xs` meta-text tokens) so Cmd +/- zoom scales it and the size stays on +// one consolidated scale. Genuine decorative glyphs are allowlisted below. +const rules = [ + { + root: "src", + extensions: new Set([".ts", ".tsx", ".css"]), + }, +]; + +// Decorative / chrome exceptions: `relativePath:lineNumber`. The avatar emoji +// glyph is a fixed display size sized to its avatar box (not readable message +// text), so it stays as the lone documented `text-[6rem]` literal. +const overrides = new Set([ + "src/features/settings/ui/ProfileSettingsCard.tsx:573", + "src/features/onboarding/ui/AvatarStep.tsx:89", +]); + +await runPxTextCheck({ + projectRoot, + rules, + overrides, + label: "Desktop", + scriptPath: "desktop/scripts/check-px-text.mjs", +}); diff --git a/desktop/src/features/agent-memory/ui/MemorySection.tsx b/desktop/src/features/agent-memory/ui/MemorySection.tsx index 46ff006e50..a35b262c37 100644 --- a/desktop/src/features/agent-memory/ui/MemorySection.tsx +++ b/desktop/src/features/agent-memory/ui/MemorySection.tsx @@ -253,8 +253,8 @@ function MemoryGraphView({ className="text-xs italic text-muted-foreground" data-testid="agent-memory-no-core" > - No core memory yet — - agent identity is unrooted. + No core memory yet — agent + identity is unrooted.

) : null} diff --git a/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx b/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx index c54c3e6e64..c6f9f5eb7d 100644 --- a/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx +++ b/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx @@ -178,7 +178,7 @@ export function AddTeamToChannelDialog({ > diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem.tsx index 78dd98304f..f2aa0247cc 100644 --- a/desktop/src/features/agents/ui/AgentSessionToolItem.tsx +++ b/desktop/src/features/agents/ui/AgentSessionToolItem.tsx @@ -198,7 +198,7 @@ function ToolTimestamp({ return ( - + {time} {duration ? ` · ${duration}` : null} @@ -260,7 +260,7 @@ function BuzzToolInlineAction({ if (action.onClick) { return ( @@ -465,7 +465,7 @@ export function DeviceList({ })} {showChangeHint && ( -

+

Change takes effect on next huddle

)} diff --git a/desktop/src/features/huddle/components/ParticipantList.tsx b/desktop/src/features/huddle/components/ParticipantList.tsx index 2fe565a816..33ac281d1d 100644 --- a/desktop/src/features/huddle/components/ParticipantList.tsx +++ b/desktop/src/features/huddle/components/ParticipantList.tsx @@ -54,7 +54,7 @@ export function HuddleParticipantsControl({ > {participants.length > 1 && ( - + {participants.length} )} @@ -90,7 +90,7 @@ export function HuddleParticipantsControl({ avatarUrl={profile.avatarUrl ?? null} label={profile.displayName || pubkey.slice(0, 6)} className={cn( - "h-8 w-8 rounded-full text-[10px]", + "h-8 w-8 rounded-full text-2xs", isActive && "ring-2 ring-green-500 ring-offset-1 ring-offset-background", )} @@ -150,7 +150,7 @@ function HexAvatar({ role="img" className={cn( "flex items-center justify-center rounded-full font-semibold shadow-xs", - size === "lg" ? "h-8 w-8 text-[10px]" : "h-7 w-7 text-[9px]", + size === "lg" ? "h-8 w-8 text-2xs" : "h-7 w-7 text-2xs", isActive && "ring-2 ring-green-500 ring-offset-1 ring-offset-background", )} diff --git a/desktop/src/features/messages/lib/mentionHighlightExtension.ts b/desktop/src/features/messages/lib/mentionHighlightExtension.ts index 53f7636744..cee06b6a4a 100644 --- a/desktop/src/features/messages/lib/mentionHighlightExtension.ts +++ b/desktop/src/features/messages/lib/mentionHighlightExtension.ts @@ -5,7 +5,7 @@ import { Decoration, DecorationSet } from "@tiptap/pm/view"; export const mentionHighlightKey = new PluginKey("mentionHighlight"); /** - * TipTap extension that applies inline `mention-highlight` decorations + * TipTap extension that applies inline `mention-chip` decorations * to `@Name` and `#channel-name` patterns in the document. * * Accepts `names` (display names) and `channelNames` storage options. @@ -264,14 +264,14 @@ function buildDecorations( node.text, pos, mentionPatterns, - "mention-highlight", + "mention-chip", ); addMatchesForPatterns( decorations, node.text, pos, agentMentionPatterns, - "mention-highlight agent-mention-highlight", + "mention-chip agent-mention-highlight", { hideMentionPrefix: true }, ); addMatchesForPatterns( @@ -279,7 +279,7 @@ function buildDecorations( node.text, pos, channelPatterns, - "mention-highlight", + "mention-chip", ); }); diff --git a/desktop/src/features/messages/lib/useRichTextEditor.ts b/desktop/src/features/messages/lib/useRichTextEditor.ts index 0fd30b897a..d54e3c8a23 100644 --- a/desktop/src/features/messages/lib/useRichTextEditor.ts +++ b/desktop/src/features/messages/lib/useRichTextEditor.ts @@ -11,6 +11,8 @@ import { Selection, TextSelection } from "@tiptap/pm/state"; import { isMacPlatform } from "@/shared/lib/platform"; import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji"; +import { MESSAGE_MARKDOWN_CLASS } from "@/shared/ui/mentionChip"; + import { MentionHighlightExtension, mentionHighlightKey, @@ -325,8 +327,7 @@ export function useRichTextEditor({ attributes: { autocapitalize: "none", autocorrect: "off", - class: - "min-h-0 resize-none overflow-y-hidden border-0 bg-transparent px-0 py-0 text-sm leading-6 text-foreground md:leading-6 shadow-none focus-visible:ring-0 caret-foreground outline-hidden prose-sm max-w-none", + class: `${MESSAGE_MARKDOWN_CLASS} min-h-0 resize-none overflow-y-hidden border-0 bg-transparent px-0 py-0 text-sm leading-relaxed text-foreground shadow-none focus-visible:ring-0 caret-foreground outline-hidden max-w-none`, "data-testid": "message-input", spellcheck: "false", }, diff --git a/desktop/src/features/messages/ui/ComposerAttachments.tsx b/desktop/src/features/messages/ui/ComposerAttachments.tsx index 7bfc0b8146..c00ec5ffb6 100644 --- a/desktop/src/features/messages/ui/ComposerAttachments.tsx +++ b/desktop/src/features/messages/ui/ComposerAttachments.tsx @@ -116,7 +116,7 @@ export const ComposerAttachments = React.memo(function ComposerAttachments({ >
- + {label}
diff --git a/desktop/src/features/messages/ui/DayDivider.tsx b/desktop/src/features/messages/ui/DayDivider.tsx index e04fd0edbe..a76b8a7f1e 100644 --- a/desktop/src/features/messages/ui/DayDivider.tsx +++ b/desktop/src/features/messages/ui/DayDivider.tsx @@ -6,7 +6,7 @@ export function DayDivider({ label }: { label: string }) { data-testid="message-timeline-day-divider" data-day-label={label} > -

+

{label}

diff --git a/desktop/src/features/messages/ui/DiffViewer.css b/desktop/src/features/messages/ui/DiffViewer.css index d6747173dd..dcc97fb788 100644 --- a/desktop/src/features/messages/ui/DiffViewer.css +++ b/desktop/src/features/messages/ui/DiffViewer.css @@ -52,7 +52,7 @@ } .buzz-diff-theme .diff { - font-size: 12px; + font-size: 0.75rem; } .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: 11px; + font-size: 0.6875rem; } .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: 11px; + font-size: 0.6875rem; } .buzz-diff-theme .diff-gutter-omit::before { diff --git a/desktop/src/features/messages/ui/DiffViewer.tsx b/desktop/src/features/messages/ui/DiffViewer.tsx index 4c9e501e45..239fd02110 100644 --- a/desktop/src/features/messages/ui/DiffViewer.tsx +++ b/desktop/src/features/messages/ui/DiffViewer.tsx @@ -36,7 +36,7 @@ function FileChangeBadge({ return ( {showFileHeader ? (
- + {label} - + {DIFF_TYPE_LABELS[diffType]}
diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 3d7bd2e64a..8c02ae079e 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -849,7 +849,7 @@ export function MessageComposer({ data-testid="edit-target" >
-

+

Editing message

@@ -872,7 +872,7 @@ export function MessageComposer({ data-testid="reply-target" >

-

+

Replying to {replyTarget.author}

diff --git a/desktop/src/features/messages/ui/MessageHeader.tsx b/desktop/src/features/messages/ui/MessageHeader.tsx new file mode 100644 index 0000000000..c9a5f219ba --- /dev/null +++ b/desktop/src/features/messages/ui/MessageHeader.tsx @@ -0,0 +1,50 @@ +import type * as React from "react"; + +import { cn } from "@/shared/lib/cn"; + +type MessageHeaderRowProps = { + children: React.ReactNode; + className?: string; +}; + +export function MessageHeaderRow({ + children, + className, +}: MessageHeaderRowProps) { + return ( +

+ {children} +
+ ); +} + +type MessageAuthorTextProps = { + as?: "div" | "h3" | "span"; + children: React.ReactNode; + className?: string; + hoverUnderline?: boolean; +}; + +export function MessageAuthorText({ + as: Component = "span", + children, + className, + hoverUnderline = false, +}: MessageAuthorTextProps) { + return ( + + {children} + + ); +} diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 4c3ead6c5f..d5ab5000dd 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -20,6 +20,7 @@ import { import { Markdown } from "@/shared/ui/markdown"; import type { VideoReviewContext } from "@/shared/ui/VideoPlayer"; import { MessageActionBar } from "./MessageActionBar"; +import { MessageAuthorText, MessageHeaderRow } from "./MessageHeader"; import { MessageTimestamp } from "./MessageTimestamp"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; @@ -193,7 +194,7 @@ export const MessageRow = React.memo( ); @@ -213,7 +213,7 @@ export const MessageRow = React.memo( const isThreadReplyLayout = layoutVariant === "thread-reply"; const guideBleedPx = isThreadReplyLayout ? 4 : 0; - const avatarSizeClass = "!h-9 !w-9"; + const avatarSizeClass = "!h-10 !w-10"; const avatarButtonRadiusClass = "rounded-full"; const respondToDotColor = @@ -250,13 +250,9 @@ export const MessageRow = React.memo( ); const authorNode = message.pubkey ? ( - - {message.author} - + {message.author} ) : ( -

- {message.author} -

+ {message.author} ); const actionBarNode = ( @@ -414,8 +410,8 @@ export const MessageRow = React.memo( ) : (
{avatarNode}
)} -
-
+
+ {message.pubkey ? (
+
{messageBodyNode}
@@ -464,8 +460,8 @@ export const MessageRow = React.memo( ) : (
{avatarNode}
)} -
-
+
+ {message.pubkey ? (
+
{messageBodyNode}
diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx index 9307540eba..358ebbce99 100644 --- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx +++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx @@ -514,7 +514,7 @@ export function MessageThreadPanel({ {!isAtBottom ? (
@@ -345,7 +345,7 @@ export const SystemMessageRow = React.memo(function SystemMessageRow({ />
-
+
{description.title}
-

+

{description.action}

diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index 64a900082c..9f66b161e0 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -84,7 +84,6 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ isSendingVideoReviewComment = false, onSendVideoReviewComment, onToggleReaction, - personaLookup, profiles, searchActiveMessageId = null, searchMatchingMessageIds, @@ -184,10 +183,8 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
{footer} diff --git a/desktop/src/features/messages/ui/TypingIndicatorRow.tsx b/desktop/src/features/messages/ui/TypingIndicatorRow.tsx index 890407153b..009392409e 100644 --- a/desktop/src/features/messages/ui/TypingIndicatorRow.tsx +++ b/desktop/src/features/messages/ui/TypingIndicatorRow.tsx @@ -113,8 +113,8 @@ export function TypingIndicatorRow({ label={label} className={cn( isActivityVariant - ? "h-[18px] w-[18px] text-[7px]" - : "h-5 w-5 text-[8px]", + ? "h-[18px] w-[18px] text-3xs" + : "h-5 w-5 text-3xs", )} iconClassName={ isActivityVariant ? "h-2.5 w-2.5" : "h-4 w-4" diff --git a/desktop/src/features/messages/ui/UnreadDivider.tsx b/desktop/src/features/messages/ui/UnreadDivider.tsx index 5994f08fc2..e07213fab7 100644 --- a/desktop/src/features/messages/ui/UnreadDivider.tsx +++ b/desktop/src/features/messages/ui/UnreadDivider.tsx @@ -11,7 +11,7 @@ export function UnreadDivider() { data-testid="message-unread-divider" >
- + New
diff --git a/desktop/src/features/onboarding/ui/AvatarStep.tsx b/desktop/src/features/onboarding/ui/AvatarStep.tsx index 9938c96b58..15deee89ad 100644 --- a/desktop/src/features/onboarding/ui/AvatarStep.tsx +++ b/desktop/src/features/onboarding/ui/AvatarStep.tsx @@ -86,7 +86,7 @@ function AvatarPreview({ > 0 && "buzz-avatar-squish", )} data-testid="onboarding-avatar-preview-emoji" diff --git a/desktop/src/features/onboarding/ui/MembershipDenied.tsx b/desktop/src/features/onboarding/ui/MembershipDenied.tsx index a3bfc5c879..99f46701f0 100644 --- a/desktop/src/features/onboarding/ui/MembershipDenied.tsx +++ b/desktop/src/features/onboarding/ui/MembershipDenied.tsx @@ -177,7 +177,7 @@ export function MembershipDenied({

This will use this Nostr identity:

-

+

{shortenNpub(previewNpub)}

diff --git a/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx b/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx index 305d7c6d5e..d105ad1816 100644 --- a/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx +++ b/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx @@ -227,7 +227,7 @@ export function NostrKeyImportForm({

This will use this Nostr identity:

-

+

{shortenNpub(previewNpub)}

diff --git a/desktop/src/features/profile/ui/UserProfilePopover.tsx b/desktop/src/features/profile/ui/UserProfilePopover.tsx index 8dc516cd2c..070f7da754 100644 --- a/desktop/src/features/profile/ui/UserProfilePopover.tsx +++ b/desktop/src/features/profile/ui/UserProfilePopover.tsx @@ -222,7 +222,7 @@ export function UserProfilePopover({

) : null} {profile?.displayName ? ( -

+

{truncatePubkey(pubkey)}

) : null} diff --git a/desktop/src/features/pulse/ui/AgentActivityCard.tsx b/desktop/src/features/pulse/ui/AgentActivityCard.tsx index 3c2e8a7a3b..c5a7aa84e0 100644 --- a/desktop/src/features/pulse/ui/AgentActivityCard.tsx +++ b/desktop/src/features/pulse/ui/AgentActivityCard.tsx @@ -75,7 +75,7 @@ export function AgentActivityCard({ {displayName} {agentStatus ? : null} - + {formatRelativeTime(group.latestAt)}
@@ -113,7 +113,7 @@ export function AgentActivityCard({
-

+

{formatRelativeTime(note.createdAt)}

diff --git a/desktop/src/features/pulse/ui/NoteCard.tsx b/desktop/src/features/pulse/ui/NoteCard.tsx index 7942376674..2785bf7e66 100644 --- a/desktop/src/features/pulse/ui/NoteCard.tsx +++ b/desktop/src/features/pulse/ui/NoteCard.tsx @@ -191,7 +191,7 @@ export function NoteCard({ {isAgent ? ( - + bot ) : null} @@ -213,7 +213,7 @@ export function NoteCard({ ) : null}
- +
diff --git a/desktop/src/features/pulse/ui/PulseTabBar.tsx b/desktop/src/features/pulse/ui/PulseTabBar.tsx index 937682c7ed..3ba3b0777a 100644 --- a/desktop/src/features/pulse/ui/PulseTabBar.tsx +++ b/desktop/src/features/pulse/ui/PulseTabBar.tsx @@ -13,7 +13,7 @@ type PulseTabBarProps = { }; const tabButtonClassName = - "h-7 rounded-full border border-transparent px-1.5 text-[10.5px] font-medium text-muted-foreground data-[active=true]:border-border/70 data-[active=true]:bg-background/80 data-[active=true]:text-foreground data-[active=true]:shadow-xs data-[active=true]:backdrop-blur-sm"; + "h-7 rounded-full border border-transparent px-1.5 text-2xs font-medium text-muted-foreground data-[active=true]:border-border/70 data-[active=true]:bg-background/80 data-[active=true]:text-foreground data-[active=true]:shadow-xs data-[active=true]:backdrop-blur-sm"; export function PulseTabBar({ activeTab, @@ -103,7 +103,7 @@ export function PulseTabBar({ > Agents {relayAgents.length > 0 ? ( - + {relayAgents.length} ) : null} diff --git a/desktop/src/features/search/ui/TopbarSearch.tsx b/desktop/src/features/search/ui/TopbarSearch.tsx index f830c74d04..2d3ec49958 100644 --- a/desktop/src/features/search/ui/TopbarSearch.tsx +++ b/desktop/src/features/search/ui/TopbarSearch.tsx @@ -266,7 +266,7 @@ export function TopbarSearch({ spellCheck={false} value={query} /> - + ⌘K
@@ -283,7 +283,7 @@ export function TopbarSearch({ style={POPOVER_SHADOW_STYLE} > {debouncedQuery.length < MIN_SEARCH_QUERY_LENGTH ? ( -
+

Type at least two characters for live suggestions.

) : searchQuery.isLoading && results.length === 0 ? ( @@ -359,7 +359,7 @@ export function TopbarSearch({ : truncateResultText(result.hit.content)} - + {result.kind === "channel" ? "Channel" : `${describeSearchHit(result.hit)} · ${formatRelativeTime(result.hit.createdAt)}`} diff --git a/desktop/src/features/settings/ui/ChannelTemplatesSettingsCard.tsx b/desktop/src/features/settings/ui/ChannelTemplatesSettingsCard.tsx index b933c61a2e..de2527bfcc 100644 --- a/desktop/src/features/settings/ui/ChannelTemplatesSettingsCard.tsx +++ b/desktop/src/features/settings/ui/ChannelTemplatesSettingsCard.tsx @@ -216,7 +216,7 @@ function TemplateRow({
{template.name} {template.isBuiltin ? ( - + built-in ) : null} @@ -756,7 +756,7 @@ function RuntimeRow({ ) : ( )} diff --git a/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx b/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx index 4b7022baff..d78eab3f97 100644 --- a/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx +++ b/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx @@ -109,7 +109,7 @@ function RuntimeRow({

{runtime.label}

{runtime.command ? ( - + {runtime.command} ) : null} @@ -134,21 +134,21 @@ function RuntimeRow({ {runtime.underlyingCliPath && runtime.underlyingCliPath !== runtime.binaryPath ? (
-

+

CLI:{" "} {runtime.underlyingCliPath}

-

+

ACP adapter:{" "} {runtime.binaryPath}

) : ( <> -

+

{runtime.binaryPath}

-

+

ACP support built-in — no separate adapter needed.

@@ -158,7 +158,7 @@ function RuntimeRow({ <>

CLI detected at{" "} - + {runtime.underlyingCliPath ?? "unknown path"} {" "} but ACP adapter not found. @@ -176,7 +176,7 @@ function RuntimeRow({ <>

ACP adapter found at{" "} - + {runtime.binaryPath ?? "unknown path"} {" "} but the {runtime.label} CLI is not installed. diff --git a/desktop/src/features/settings/ui/NotificationSettingsCard.tsx b/desktop/src/features/settings/ui/NotificationSettingsCard.tsx index 05a47e03c9..3e765ebe2b 100644 --- a/desktop/src/features/settings/ui/NotificationSettingsCard.tsx +++ b/desktop/src/features/settings/ui/NotificationSettingsCard.tsx @@ -179,7 +179,7 @@ export function NotificationSettingsCard({ {SLOT_LABELS[slot]} {comingSoon ? ( - + Coming soon ) : null} diff --git a/desktop/src/features/settings/ui/ProfileSettingsCard.tsx b/desktop/src/features/settings/ui/ProfileSettingsCard.tsx index ddd10cee00..70a15d900d 100644 --- a/desktop/src/features/settings/ui/ProfileSettingsCard.tsx +++ b/desktop/src/features/settings/ui/ProfileSettingsCard.tsx @@ -570,7 +570,7 @@ export function ProfileSettingsCard({ > 0 && "buzz-avatar-squish", )} data-testid="profile-avatar-preview-emoji" diff --git a/desktop/src/features/settings/ui/SoundPicker.tsx b/desktop/src/features/settings/ui/SoundPicker.tsx index 716290f335..f09aa556cc 100644 --- a/desktop/src/features/settings/ui/SoundPicker.tsx +++ b/desktop/src/features/settings/ui/SoundPicker.tsx @@ -113,7 +113,7 @@ export function SoundPicker({ {name} {name === recommended ? ( - + rec. ) : null} diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index 8a7303e2d0..302d4f93a9 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -465,7 +465,7 @@ export function AppSidebar({ {homeBadgeCount > 0 ? ( {Math.min(homeBadgeCount, 99)} @@ -513,7 +513,7 @@ export function AppSidebar({ {shouldShowAgentCount ? ( {totalAgentCount} diff --git a/desktop/src/features/sidebar/ui/MoreUnreadButton.tsx b/desktop/src/features/sidebar/ui/MoreUnreadButton.tsx index 22f9119f02..e0b2d027b1 100644 --- a/desktop/src/features/sidebar/ui/MoreUnreadButton.tsx +++ b/desktop/src/features/sidebar/ui/MoreUnreadButton.tsx @@ -3,7 +3,7 @@ import type * as React from "react"; import { Button } from "@/shared/ui/button"; const MORE_UNREAD_BUTTON_CLASS = - "h-7 min-h-7 gap-1.5 rounded-full border-0 bg-primary px-2.5 text-[11px] font-medium text-primary-foreground shadow-md hover:bg-primary/90 [&_svg]:size-4"; + "h-7 min-h-7 gap-1.5 rounded-full border-0 bg-primary px-2.5 text-2xs font-medium text-primary-foreground shadow-md hover:bg-primary/90 [&_svg]:size-4"; export function MoreUnreadButton({ bottomClassName = "bottom-0", diff --git a/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx b/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx index 300662b542..5d010fa313 100644 --- a/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx +++ b/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx @@ -88,7 +88,7 @@ export function SidebarProfileCard({ const workspaceLabel = activeWorkspace?.name ?? "No workspace"; const readonlyWorkspaceLabel = ( -

-
+
{channelName ? {channelName} : null} {triggerSummary ? {triggerSummary} : null} diff --git a/desktop/src/features/workflows/ui/WorkflowDetailPanel.tsx b/desktop/src/features/workflows/ui/WorkflowDetailPanel.tsx index f610caee12..e727cf9e8c 100644 --- a/desktop/src/features/workflows/ui/WorkflowDetailPanel.tsx +++ b/desktop/src/features/workflows/ui/WorkflowDetailPanel.tsx @@ -186,7 +186,7 @@ export function WorkflowDetailPanel({
-
+
{new Date( run.createdAt * 1000, @@ -216,10 +216,10 @@ export function WorkflowDetailPanel({ {isSelected ? (
-
+
Execution Trace {approvalsQuery.isFetching ? ( - + Refreshing approvals... ) : null} diff --git a/desktop/src/features/workflows/ui/WorkflowRunTrace.tsx b/desktop/src/features/workflows/ui/WorkflowRunTrace.tsx index a1e6b2775e..76fc9ecf88 100644 --- a/desktop/src/features/workflows/ui/WorkflowRunTrace.tsx +++ b/desktop/src/features/workflows/ui/WorkflowRunTrace.tsx @@ -94,7 +94,7 @@ export function WorkflowRunTrace({
{Object.keys(step.output).length > 0 ? (
-

+

Output

@@ -104,7 +104,7 @@ export function WorkflowRunTrace({
             ) : null}
             {step.error ? (
               
-

+

Error

@@ -114,7 +114,7 @@ export function WorkflowRunTrace({
             ) : null}
             {pendingApproval ? (
               
-

+

Pending approval

diff --git a/desktop/src/features/workspaces/ui/WorkspaceSwitcher.tsx b/desktop/src/features/workspaces/ui/WorkspaceSwitcher.tsx index 73bf8b1ae6..851236b146 100644 --- a/desktop/src/features/workspaces/ui/WorkspaceSwitcher.tsx +++ b/desktop/src/features/workspaces/ui/WorkspaceSwitcher.tsx @@ -132,7 +132,7 @@ export function WorkspaceSwitcher({ diff --git a/desktop/src/shared/styles/globals.css b/desktop/src/shared/styles/globals.css index 8963b6d038..2c93537290 100644 --- a/desktop/src/shared/styles/globals.css +++ b/desktop/src/shared/styles/globals.css @@ -512,9 +512,9 @@ /* ── Tiptap rich-text composer ──────────────────────────────────────── */ .rich-text-composer .tiptap { outline: none; - min-height: 1.5rem; /* single line height */ - font-size: 0.875rem; - line-height: 1.5rem; + min-height: calc(var(--text-sm) * var(--leading-relaxed)); + font-size: var(--text-sm); + line-height: var(--leading-relaxed); } .rich-text-composer .tiptap p { @@ -555,16 +555,8 @@ text-decoration: line-through; } -.rich-text-composer .tiptap code { - border-radius: 0.375rem; - background: hsl(var(--muted)); - padding: 0.125rem 0.375rem; - font-family: ui-monospace, monospace; - font-size: 0.8125rem; -} - .rich-text-composer .tiptap pre { - border-radius: 0.75rem; + border-radius: var(--radius); border: 1px solid hsl(var(--border) / 0.7); background: hsl(var(--muted) / 0.6); padding: 0.375rem 0.75rem; @@ -572,14 +564,6 @@ margin: 0.25rem 0; } -.rich-text-composer .tiptap pre code { - background: none; - padding: 0; - border-radius: 0; - font-size: 0.8125rem; - line-height: 1.5rem; -} - .rich-text-composer .tiptap blockquote { border-left: 2px solid hsl(var(--border)); padding-left: 1rem; @@ -620,45 +604,110 @@ margin: 0.5rem 0; } -.rich-text-composer .tiptap .mention-highlight { - display: inline-block; - border-radius: 4px; - background: hsl(var(--primary) / 0.15); - padding: 3px 4px 2px; - font-size: 15px; +/* Inline chips — timeline markdown + composer share `.message-markdown` scope. + Body copy is text-sm; inline code labels sit one step down (text-xs). + All chip variants share one box height so mono labels stay balanced. */ +.message-markdown { + --inline-chip-padding-block-start: 0.1875rem; + --inline-chip-padding-block-end: 0.125rem; + --inline-chip-padding-inline: 0.25rem; + --inline-chip-min-height: calc( + var(--text-sm) + + var(--inline-chip-padding-block-start) + + var(--inline-chip-padding-block-end) + ); + --inline-code-font-size: var(--text-xs); + --agent-icon-size: 0.95em; + --agent-icon-gap: 0.125rem; + --agent-icon-top: 0.125rem; +} + +.message-markdown .mention-chip, +.message-markdown .inline-code-chip, +.message-markdown :not(pre) > code { + box-sizing: border-box; + display: inline-flex; + align-items: center; + min-height: var(--inline-chip-min-height); + padding: var(--inline-chip-padding-block-start) + var(--inline-chip-padding-inline) var(--inline-chip-padding-block-end); + border-radius: calc(var(--radius) - 4px); + font-weight: 500; line-height: 1; + vertical-align: baseline; + box-decoration-break: clone; + -webkit-box-decoration-break: clone; +} + +.message-markdown .mention-chip { + font-size: inherit; + background: hsl(var(--primary) / 0.15); color: hsl(var(--primary)); +} + +.message-markdown .inline-code-chip, +.message-markdown :not(pre) > code { + font-size: var(--inline-code-font-size); + font-family: ui-monospace, monospace; + background: hsl(var(--muted)); + color: inherit; +} + +/* Code block content — higher specificity than the inline chips above; kept + after them so descending-specificity ordering stays clean. */ +.rich-text-composer .tiptap pre code { + background: none; + padding: 0; + border-radius: 0; + font-size: var(--text-sm); + line-height: var(--text-sm--line-height); font-weight: 500; } -.rich-text-composer .tiptap .agent-mention-at-hidden { - color: transparent; - font-size: 0; - line-height: 0; +.mention-chip-hover { + transition-property: + color, background-color, border-color, outline-color, text-decoration-color, + fill, stroke; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; } -.rich-text-composer .tiptap .agent-mention-highlight, -[data-mention].agent-mention-highlight { - display: inline-flex; - align-items: center; - gap: 2px; - background: hsl(var(--primary) / 0.18); - color: hsl(var(--primary) / 0.92); +.mention-chip-hover:hover { + background: hsl(var(--primary) / 0.25); + color: hsl(var(--primary) / 0.9); } -[data-mention].agent-mention-highlight { - transform: translateY(2px); +.message-markdown .agent-mention-at-hidden { + display: inline-block; + width: 0; + max-width: 0; + overflow: hidden; + opacity: 0; + padding: 0; + margin: 0; + border: 0; + vertical-align: baseline; + line-height: 1; } -.rich-text-composer .tiptap .agent-mention-highlight::before, -[data-mention].agent-mention-highlight::before { +.message-markdown .agent-mention-highlight { + position: relative; + padding-left: calc( + var(--inline-chip-padding-inline) + + var(--agent-icon-size) + + var(--agent-icon-gap) + ); +} + +.message-markdown .agent-mention-highlight::before { content: ""; - display: inline-block; - width: 0.85em; - height: 0.85em; - flex: 0 0 auto; + position: absolute; + top: var(--agent-icon-top); + left: var(--inline-chip-padding-inline); + width: var(--agent-icon-size); + height: var(--agent-icon-size); background: currentColor; - transform: translateY(-0.25px); + pointer-events: none; -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 8V4H8'/%3E%3Crect width='16' height='12' x='4' y='8' rx='2'/%3E%3Cpath d='M2 14h2'/%3E%3Cpath d='M20 14h2'/%3E%3Cpath d='M15 13v2'/%3E%3Cpath d='M9 13v2'/%3E%3C/svg%3E") center / contain no-repeat; diff --git a/desktop/src/shared/ui/UserAvatar.tsx b/desktop/src/shared/ui/UserAvatar.tsx index 5b9e980da4..4c1971c5cb 100644 --- a/desktop/src/shared/ui/UserAvatar.tsx +++ b/desktop/src/shared/ui/UserAvatar.tsx @@ -9,8 +9,8 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/shared/ui/avatar"; type UserAvatarSize = "xs" | "sm" | "md"; const sizeClasses: Record = { - xs: "h-5 w-5 text-[8px]", - sm: "h-6 w-6 text-[9px]", + xs: "h-5 w-5 text-3xs", + sm: "h-6 w-6 text-2xs", md: "h-10 w-10 text-xs", }; diff --git a/desktop/src/shared/ui/VideoPlayer.tsx b/desktop/src/shared/ui/VideoPlayer.tsx index cdc23b5421..b7ace0b928 100644 --- a/desktop/src/shared/ui/VideoPlayer.tsx +++ b/desktop/src/shared/ui/VideoPlayer.tsx @@ -535,7 +535,7 @@ function VideoScrubber({ style={{ left: `${hoverRatio * 100}%` }} >
- + {formatTimecode(hoverRatio * duration)} / {formatTimecode(duration)}
@@ -970,7 +970,7 @@ export function VideoPlayer({ )} {formatTimecode(currentTime)} @@ -984,7 +984,7 @@ export function VideoPlayer({ testIdPrefix="video-inline" /> {formatTimecode(duration)} @@ -1773,12 +1773,12 @@ function VideoReviewDialog({ {formatTimecode(currentTime)} {replyTarget ? ( - + Replying to {replyTarget.comment.author} ) : (

), + h4: ({ children }) => ( +

+ {children} +

+ ), + h5: ({ children }) => ( +
+ {children} +
+ ), + h6: ({ children }) => ( +
+ {children} +
+ ), hr: () =>
, img: ({ alt, src }) => { const { imetaByUrl } = runtimeRef.current; @@ -1268,7 +1265,11 @@ function createMarkdownComponents( type="button" data-channel-link="" aria-label={`Open channel ${channelName}`} - className="rounded-md bg-primary/15 px-1 py-0.5 text-sm font-medium text-primary cursor-pointer hover:bg-primary/25 transition-colors" + className={cn( + "cursor-pointer", + MENTION_CHIP_BASE_CLASSES, + MENTION_CHIP_HOVER_CLASSES, + )} onClick={() => { onOpenChannel(channel.id); }} @@ -1279,10 +1280,7 @@ function createMarkdownComponents( } return ( - + {children} ); @@ -1316,7 +1314,11 @@ function createMarkdownComponents( data-message-link="" aria-label={`Open message in ${channelLabel}`} title={href} - className="rounded-md bg-primary/15 px-1 py-0.5 text-sm font-medium text-primary cursor-pointer hover:bg-primary/25 transition-colors" + className={cn( + "cursor-pointer", + MENTION_CHIP_BASE_CLASSES, + MENTION_CHIP_HOVER_CLASSES, + )} onClick={() => { onOpenMessageLink(parsed.value); }} @@ -1331,7 +1333,6 @@ function createMarkdownComponents( function MarkdownInner({ channelNames, className, - compact = false, content, customEmoji, imetaByUrl, @@ -1340,14 +1341,8 @@ function MarkdownInner({ mentionNames, mentionPubkeysByName, searchQuery, - tight = false, videoReviewContext, }: MarkdownProps) { - const variant: MarkdownVariant = tight - ? "tight" - : compact - ? "compact" - : "default"; const { channels: rawChannels } = useChannelNavigation(); const channels = useStableArray(rawChannels); const { goChannel } = useAppNavigation(); @@ -1383,8 +1378,8 @@ function MarkdownInner({ }); const components = React.useMemo( - () => createMarkdownComponents(variant, runtimeRef, interactive), - [variant, runtimeRef, interactive], + () => createMarkdownComponents(runtimeRef, interactive), + [runtimeRef, interactive], ); // biome-ignore lint/suspicious/noExplicitAny: PluggableList type not directly importable @@ -1435,52 +1430,21 @@ function MarkdownInner({ return (
*:first-child]:mt-0 [&>*:last-child]:mb-0", - // Base owl: p+p, list+p, etc. - "[&>*+*]:mt-2", - // Headings: flat push/pull — size does the hierarchy work - "[&>*+h1]:mt-2.5 [&>*+h2]:mt-2.5 [&>*+h3]:mt-2.5", - "[&>h1+*]:mt-0.5 [&>h2+*]:mt-0.5 [&>h3+*]:mt-0.5", - // Blockquotes: breathe above and below - "[&>*+blockquote]:mt-3 [&>blockquote+*]:mt-3", - // Code blocks: breathe above and below - "[&>*+[data-code-block]]:mt-3 [&>[data-code-block]+*]:mt-3", - // Tables: breathe above and below - "[&>*+[data-table-block]]:mt-3 [&>[data-table-block]+*]:mt-3", - // hr: clear section divider - "[&>*+hr]:mt-3.5 [&>hr+*]:mt-3.5", - // Lists after paragraphs: tighter to feel related - "[&>p+ul]:mt-1 [&>p+ol]:mt-1 [&>div+ul]:mt-1 [&>div+ol]:mt-1", - ].join(" ") - : compact - ? [ - "max-w-none break-words text-[15px] leading-6 text-foreground/90", - "[&>*:first-child]:mt-0 [&>*:last-child]:mb-0", - "[&>*+*]:mt-2", - "[&>*+h1]:mt-3 [&>*+h2]:mt-3 [&>*+h3]:mt-3", - "[&>h1+*]:mt-0.5 [&>h2+*]:mt-0.5 [&>h3+*]:mt-0.5", - "[&>*+blockquote]:mt-3 [&>blockquote+*]:mt-3", - "[&>*+[data-code-block]]:mt-3 [&>[data-code-block]+*]:mt-3", - "[&>*+[data-table-block]]:mt-3 [&>[data-table-block]+*]:mt-3", - "[&>*+hr]:mt-3.5 [&>hr+*]:mt-3.5", - "[&>p+ul]:mt-1 [&>p+ol]:mt-1 [&>div+ul]:mt-1 [&>div+ol]:mt-1", - ].join(" ") - : [ - "max-w-none break-words text-sm leading-7 text-foreground/90", - "[&>*:first-child]:mt-0 [&>*:last-child]:mb-0", - "[&>*+*]:mt-3", - "[&>*+h1]:mt-3.5 [&>*+h2]:mt-3.5 [&>*+h3]:mt-3.5", - "[&>h1+*]:mt-0.5 [&>h2+*]:mt-0.5 [&>h3+*]:mt-0.5", - "[&>*+blockquote]:mt-3.5 [&>blockquote+*]:mt-3.5", - "[&>*+[data-code-block]]:mt-3.5 [&>[data-code-block]+*]:mt-3.5", - "[&>*+[data-table-block]]:mt-3.5 [&>[data-table-block]+*]:mt-3.5", - "[&>*+hr]:mt-4 [&>hr+*]:mt-4", - "[&>p+ul]:mt-1.5 [&>p+ol]:mt-1.5 [&>div+ul]:mt-1.5 [&>div+ol]:mt-1.5", - ].join(" "), + MESSAGE_MARKDOWN_CLASS, + [ + "max-w-none break-words text-sm leading-relaxed text-foreground", + "[&>*:first-child]:mt-0 [&>*:last-child]:mb-0", + "[&>*+*]:mt-3", + "[&>p+p]:mt-1.5", + "[&>*+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!", + "[&>*+blockquote]:mt-3.5 [&>blockquote+*]:mt-3.5", + "[&>*+[data-code-block]]:mt-3.5 [&>[data-code-block]+*]:mt-3.5", + "[&>*+[data-table-block]]:mt-3.5 [&>[data-table-block]+*]:mt-3.5", + "[&>*+hr]:mt-4 [&>hr+*]:mt-4", + "[&>p+ul]:mt-1.5 [&>p+ol]:mt-1.5 [&>div+ul]:mt-1.5 [&>div+ol]:mt-1.5", + ].join(" "), className, )} > @@ -1496,10 +1460,8 @@ export const Markdown = React.memo( (prev, next) => prev.content === next.content && prev.className === next.className && - prev.compact === next.compact && prev.customEmoji === next.customEmoji && prev.interactive === next.interactive && - prev.tight === next.tight && prev.agentMentionPubkeysByName === next.agentMentionPubkeysByName && prev.mentionPubkeysByName === next.mentionPubkeysByName && shallowArrayEqual(prev.mentionNames, next.mentionNames) && diff --git a/desktop/src/shared/ui/mentionChip.ts b/desktop/src/shared/ui/mentionChip.ts index ddb6756fcc..f980367dc3 100644 --- a/desktop/src/shared/ui/mentionChip.ts +++ b/desktop/src/shared/ui/mentionChip.ts @@ -1,5 +1,9 @@ -export const MENTION_CHIP_BASE_CLASSES = - "inline-block rounded-[4px] bg-primary/15 px-1 pt-[3px] pb-[2px] text-[15px] font-medium leading-none text-primary"; +export const MENTION_CHIP_BASE_CLASSES = "mention-chip"; -export const MENTION_CHIP_HOVER_CLASSES = - "transition-colors hover:bg-primary/25 hover:text-primary/90"; +export const MENTION_CHIP_HOVER_CLASSES = "mention-chip-hover"; + +/** Wrapper on rendered message Markdown — scopes inline chip CSS. */ +export const MESSAGE_MARKDOWN_CLASS = "message-markdown"; + +/** Inline `` `code` `` chip — matches mention chip rhythm in message bodies. */ +export const INLINE_CODE_CHIP_CLASS = "inline-code-chip"; diff --git a/desktop/tailwind.config.js b/desktop/tailwind.config.js index 6422fc81ac..221cbe8ed5 100644 --- a/desktop/tailwind.config.js +++ b/desktop/tailwind.config.js @@ -2,6 +2,15 @@ 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 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)… + fontSize: { + "2xs": "0.6875rem", // 11px — meta-text workhorse (timestamps, badges) + "3xs": "0.5rem", // 8px — tiny glyphs / micro labels + }, borderRadius: { lg: "var(--radius)", md: "calc(var(--radius) - 2px)", diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index cbfc8b1ced..75faaee205 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -197,7 +197,7 @@ test("selecting a person mention inserts @Name into input", async ({ await dropdown.getByText("bob").click(); await expect(input).toHaveText("Hey @bob "); - const mentionChip = input.locator(".mention-highlight", { + const mentionChip = input.locator(".mention-chip", { hasText: "@bob", }); await expect(mentionChip).toBeVisible(); @@ -415,7 +415,7 @@ test("profile-only agents without public respond-to are hidden from mentions", a const dropdown = autocomplete(page); await expect(dropdown).not.toBeVisible(); - await expect(input.locator(".mention-highlight")).toHaveCount(0); + await expect(input.locator(".mention-chip")).toHaveCount(0); }); test("mentioning an in-channel stopped managed agent starts it before sending", async ({ @@ -712,11 +712,11 @@ test("selecting a non-member agent from a DM inserts @Name into input", async ({ const dropdown = autocomplete(page); await expect(dropdown.getByText("charlie")).toBeVisible(); await expect(autocomplete(page)).toHaveCount(1); - await expect(input.locator(".mention-highlight")).toHaveCount(0); + await expect(input.locator(".mention-chip")).toHaveCount(0); await input.press("Enter"); await expect(input).toHaveText("@charlie "); - await expect(input.locator(".mention-highlight")).toBeVisible(); + await expect(input.locator(".mention-chip")).toBeVisible(); }); test("do nothing sends a non-member mention without inviting", async ({ diff --git a/scripts/check-px-text-core.mjs b/scripts/check-px-text-core.mjs new file mode 100644 index 0000000000..2c122763eb --- /dev/null +++ b/scripts/check-px-text-core.mjs @@ -0,0 +1,126 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; + +/** + * Shared "no hardcoded px text size" guard. + * + * Zoom (Cmd +/-) scales the root font-size, so only **rem**-based text + * scales. Hardcoded px text sizes (`text-[15px]`, `font-size: 15px`) freeze + * against zoom — that's the timeline regression we fixed. This guard stops new + * px text sizes from creeping back in. Use a rem-based Tailwind token instead + * (e.g. the stock `text-base`, `text-sm`, `text-xs` scale — chat === base). + * + * It flags: + * - Tailwind arbitrary text-size utilities: `text-[NNpx]`, `text-[N.NNrem]`, + * `text-[N.NNem]` — any arbitrary font-size literal. Use a named token + * (`text-2xs`, `text-3xs`, or the stock `text-base`/`text-sm`/`text-xs` + * scale) so the size lives in `tailwind.config.js` as rem and stays + * consistent. px literals freeze against zoom; arbitrary rem literals + * re-fragment the scale we just consolidated. + * - CSS px font sizes: `font-size: NNpx` + * + * Decorative/chrome exceptions (avatar initials sized to a fixed avatar box, + * the `text-[6rem]` emoji glyph, etc.) live in the `overrides` allowlist + * supplied by each app. + */ + +// Any arbitrary Tailwind text-size literal — px, rem, or em. Color literals +// like `text-[#fff]` or `text-[var(--x)]` don't match (no unit-bearing number). +const TEXT_ARBITRARY_RE = /\btext-\[\d+(?:\.\d+)?(?:px|rem|em)\]/g; +// Match the CSS `font-size` property, but NOT custom properties like +// `--font-size:` (third-party widget vars) which merely contain the substring. +const FONT_SIZE_PX_RE = /(? { + const fullPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + return walkFiles(fullPath); + } + return [fullPath]; + }), + ); + return files.flat(); +} + +/** + * @param {object} options + * @param {string} options.projectRoot Absolute path the rule roots resolve against. + * @param {Array<{root: string, extensions: Set}>} options.rules Where to scan. + * @param {string} options.label Human label for the failure header. + * @param {Set} [options.overrides] Allowlisted "relativePath:lineNumber" entries. + * @param {string} options.scriptPath Path mentioned in the failure hint. + */ +export async function runPxTextCheck({ + projectRoot, + rules, + label, + overrides = new Set(), + scriptPath, +}) { + const candidateFiles = ( + await Promise.all( + rules.map((rule) => { + const dir = path.join(projectRoot, rule.root); + return fs + .access(dir) + .then(() => walkFiles(dir)) + .catch(() => []); + }), + ) + ).flat(); + + const violations = []; + + for (const filePath of candidateFiles) { + const relativePath = path.relative(projectRoot, filePath); + const rule = rules.find((r) => + relativePath.startsWith(`${r.root}${path.sep}`), + ); + if (!rule) { + continue; + } + if (!rule.extensions.has(path.extname(relativePath))) { + continue; + } + // Optional per-rule basename allowlist — scopes the scan to specific files. + if (rule.files && !rule.files.has(path.basename(relativePath))) { + continue; + } + + const content = await fs.readFile(filePath, "utf8"); + const lines = content.split(/\r?\n/); + lines.forEach((line, index) => { + const lineNumber = index + 1; + const key = `${relativePath}:${lineNumber}`; + if (overrides.has(key)) { + return; + } + const matches = [ + ...(line.match(TEXT_ARBITRARY_RE) ?? []), + ...(line.match(FONT_SIZE_PX_RE) ?? []), + ]; + for (const match of matches) { + violations.push({ relativePath, lineNumber, match }); + } + }); + } + + if (violations.length > 0) { + console.error(`${label} px-text check failed:`); + for (const v of violations) { + console.error(`- ${v.relativePath}:${v.lineNumber}: ${v.match}`); + } + console.error( + "Use a rem-based Tailwind text token (e.g. the stock `text-base`, " + + "`text-sm`, `text-xs` scale, or the `text-2xs` / `text-3xs` meta-text " + + "tokens) so the text scales with Cmd +/- zoom and stays on one scale. " + + "If this size is " + + "genuinely decorative/chrome (not readable message text), add a " + + `narrowly scoped \`relativePath:lineNumber\` exception in \`${scriptPath}\`.`, + ); + process.exit(1); + } +}