From bfb892c985cea2bc03b08d53e7dc62c67ccd143d Mon Sep 17 00:00:00 2001
From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
<52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co>
Date: Sun, 14 Jun 2026 20:44:26 -0700
Subject: [PATCH 01/20] fix(desktop): restore zoom on timeline text via rem
tokens
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Cmd +/- zoom scales the root font-size (rem-only by design),
but PR #891 converted the message-timeline + thread render path from
rem tokens to hardcoded px (text-[15px]/text-[13px], font-size: 15px),
freezing that text against zoom.
Preserve Kenny's 15px chat sizing intent but express it in rem so it
scales: add rem-based `text-chat` (0.9375rem === 15px) and `text-code`
(0.8125rem === 13px) Tailwind tokens, and swap the px classes over in
MessageRow, markdown, mentionChip, and globals.css (.mention-highlight).
Codify it: add `pnpm check:px-text` CI guard flagging new px text in the
timeline/thread render path, wired into `pnpm check`, plus an AGENTS.md
note steering future agents to rem tokens. Scoped to the regression
footprint — no app-wide sweep.
Co-authored-by: Taylor Ho
Signed-off-by: Taylor Ho
---
AGENTS.md | 21 ++++
RESEARCH/TIMELINE_ZOOM_REM_REGRESSION.md | 45 +++++++
desktop/package.json | 3 +-
desktop/scripts/check-px-text.mjs | 44 +++++++
.../src/features/messages/ui/MessageRow.tsx | 6 +-
desktop/src/shared/styles/globals.css | 3 +-
desktop/src/shared/ui/markdown.tsx | 6 +-
desktop/src/shared/ui/mentionChip.ts | 2 +-
desktop/tailwind.config.js | 9 ++
scripts/check-px-text-core.mjs | 116 ++++++++++++++++++
10 files changed, 246 insertions(+), 9 deletions(-)
create mode 100644 RESEARCH/TIMELINE_ZOOM_REM_REGRESSION.md
create mode 100644 desktop/scripts/check-px-text.mjs
create mode 100644 scripts/check-px-text-core.mjs
diff --git a/AGENTS.md b/AGENTS.md
index 7fb96adb79..90b531ba76 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -412,6 +412,27 @@ 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:
+
+- ✅ `text-chat` (chat body/author, 15px in rem), `text-code` (inline/block
+ code, 13px in rem), or a stock token (`text-sm`, `text-base`, …). Tokens live
+ in `desktop/tailwind.config.js` under `theme.extend.fontSize`.
+- ❌ `text-[15px]`, `text-[13px]`, or CSS `font-size: 15px`. These opted out of
+ zoom and caused the message-timeline regression (PR #891).
+
+If a design needs a size between stock tokens (e.g. 15px sits between `text-sm`
+14px and `text-base` 16px), **add a rem-based token** rather than an arbitrary
+px value. A CI guard (`pnpm check:px-text`, in `desktop/scripts/check-px-text.mjs`)
+fails on new px text in the message-timeline / thread render path.
+
### Workspace Switching
The desktop app supports multiple workspaces (each backed by a different relay).
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 23d76e679d..2502e25d99 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..27693d843c
--- /dev/null
+++ b/desktop/scripts/check-px-text.mjs
@@ -0,0 +1,44 @@
+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, "..");
+
+// Scoped to the message-timeline / thread render path — the surface where the
+// rem→px zoom regression (PR #891) landed. Readable message text here MUST use
+// rem-based tokens (`text-chat`, `text-code`) so Cmd +/- zoom scales it. We
+// intentionally do NOT sweep the whole app yet (decorative chrome — avatar
+// initials, day dividers, diff-viewer labels — still uses px); widen these
+// roots when that sweep happens.
+const rules = [
+ {
+ root: "src/shared/ui",
+ extensions: new Set([".ts", ".tsx"]),
+ files: new Set(["markdown.tsx", "mentionChip.ts"]),
+ },
+ {
+ root: "src/features/messages/ui",
+ extensions: new Set([".tsx"]),
+ files: new Set(["MessageRow.tsx"]),
+ },
+ {
+ // `.mention-highlight` lives here and was part of the #891 px regression —
+ // guard the `font-size: NNpx` form too, not just the Tailwind utility.
+ root: "src/shared/styles",
+ extensions: new Set([".css"]),
+ files: new Set(["globals.css"]),
+ },
+];
+
+// Decorative / chrome px-text exceptions: `relativePath:lineNumber`. Empty for
+// now — the regression footprint is fully on rem tokens.
+const overrides = new Set();
+
+await runPxTextCheck({
+ projectRoot,
+ rules,
+ overrides,
+ label: "Desktop",
+ scriptPath: "desktop/scripts/check-px-text.mjs",
+});
diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx
index d54b450dd0..ca40f7dec2 100644
--- a/desktop/src/features/messages/ui/MessageRow.tsx
+++ b/desktop/src/features/messages/ui/MessageRow.tsx
@@ -188,7 +188,7 @@ export const MessageRow = React.memo(
+
{message.author}
) : (
-
+
{message.author}
);
diff --git a/desktop/src/shared/styles/globals.css b/desktop/src/shared/styles/globals.css
index bb3fdb2492..95338f2c70 100644
--- a/desktop/src/shared/styles/globals.css
+++ b/desktop/src/shared/styles/globals.css
@@ -615,7 +615,8 @@
border-radius: 4px;
background: hsl(var(--primary) / 0.15);
padding: 3px 4px 2px;
- font-size: 15px;
+ /* 0.9375rem === 15px; rem so it scales with the root-font-size zoom. */
+ font-size: 0.9375rem;
line-height: 1;
color: hsl(var(--primary));
font-weight: 500;
diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx
index b21b45e732..67ce1b63a4 100644
--- a/desktop/src/shared/ui/markdown.tsx
+++ b/desktop/src/shared/ui/markdown.tsx
@@ -78,7 +78,7 @@ const MAX_CACHE_ENTRIES = 100;
const MAX_LOADED_LANGUAGES = 30;
const MAX_HIGHLIGHT_LINES = 150;
const CODE_BLOCK_CLASS =
- "code-block-lines block min-w-full whitespace-pre font-mono text-[13px] leading-6 text-foreground";
+ "code-block-lines block min-w-full whitespace-pre font-mono text-code text-foreground";
const DIFF_ADD_RE = /\s*\/\/\s*\[!code\s*\+\+\]\s*$/;
const DIFF_REMOVE_RE = /\s*\/\/\s*\[!code\s*--\]\s*$/;
@@ -850,7 +850,7 @@ function createMarkdownComponents(
@@ -1221,7 +1221,7 @@ function MarkdownInner({
].join(" ")
: compact
? [
- "max-w-none break-words text-[15px] leading-6 text-foreground/90",
+ "max-w-none break-words text-chat text-foreground/90",
"[&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
"[&>*+*]:mt-2",
"[&>*+h1]:mt-3 [&>*+h2]:mt-3 [&>*+h3]:mt-3",
diff --git a/desktop/src/shared/ui/mentionChip.ts b/desktop/src/shared/ui/mentionChip.ts
index ddb6756fcc..d0c7a3502b 100644
--- a/desktop/src/shared/ui/mentionChip.ts
+++ b/desktop/src/shared/ui/mentionChip.ts
@@ -1,5 +1,5 @@
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";
+ "inline-block rounded-[4px] bg-primary/15 px-1 pt-[3px] pb-[2px] text-chat font-medium leading-none text-primary";
export const MENTION_CHIP_HOVER_CLASSES =
"transition-colors hover:bg-primary/25 hover:text-primary/90";
diff --git a/desktop/tailwind.config.js b/desktop/tailwind.config.js
index 6422fc81ac..93b7d8fe65 100644
--- a/desktop/tailwind.config.js
+++ b/desktop/tailwind.config.js
@@ -2,6 +2,15 @@
export default {
theme: {
extend: {
+ fontSize: {
+ // Chat body/author sizing. 15px sits between Tailwind's stock
+ // `text-sm` (14px) and `text-base` (16px), so we express it as a rem
+ // token instead of a hardcoded px value — px would not scale with the
+ // root-font-size zoom (Cmd +/-). 0.9375rem === 15px at the 16px root.
+ chat: ["0.9375rem", { lineHeight: "1.5rem" }],
+ // Inline & block code inside chat messages (13px → 0.8125rem).
+ code: ["0.8125rem", { lineHeight: "1.5rem" }],
+ },
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
diff --git a/scripts/check-px-text-core.mjs b/scripts/check-px-text-core.mjs
new file mode 100644
index 0000000000..053c6b976b
--- /dev/null
+++ b/scripts/check-px-text-core.mjs
@@ -0,0 +1,116 @@
+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. `text-chat`, `text-code`, `text-sm`).
+ *
+ * It flags:
+ * - Tailwind arbitrary px text utilities: `text-[NNpx]`
+ * - CSS px font sizes: `font-size: NNpx`
+ *
+ * Decorative/chrome exceptions (avatar initials sized to a fixed avatar box,
+ * etc.) live in the `overrides` allowlist supplied by each app.
+ */
+
+const TEXT_PX_RE = /\btext-\[\d+(?:\.\d+)?px\]/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_PX_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. `text-chat`, `text-code`, " +
+ "`text-sm`) so the text scales with Cmd +/- zoom. If this px size is " +
+ "genuinely decorative/chrome (not readable message text), add a " +
+ `narrowly scoped \`relativePath:lineNumber\` exception in \`${scriptPath}\`.`,
+ );
+ process.exit(1);
+ }
+}
From 485d10e0d63b2232eadf0337b455c29bc98f1dfc Mon Sep 17 00:00:00 2001
From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
<52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co>
Date: Sun, 14 Jun 2026 22:38:51 -0700
Subject: [PATCH 02/20] feat(desktop): make chat text the 16px base type scale
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
tho's reframe: chat text is the app's fundamental building block, so it
should *be* the base type size, not a between-the-cracks 15px token wedged
between text-sm (14px) and text-base (16px).
Bump chat to 16px-as-base and re-anchor the timeline/thread type ramp:
- Retire the custom `text-chat` (15px) and `text-code` (13px) rem tokens
added in the zoom fix (#1051) — with chat at the stock base they're
redundant artifacts of the 15px-wedge era.
- Chat body + author → stock `text-base` (16px) in MessageRow, markdown,
mentionChip. `.mention-highlight` follows chat to 1rem.
- Code (inline + block) → stock `text-sm` (14px) — a deliberate,
documented one-notch step down from the 16px base.
Satellite elements (timestamps text-xs, system rows text-sm/xs, thread
summary, reactions) are deliberately left on their stock tokens: against
a 16px base the ratios tighten into the canonical base→sm→xs ramp
(16/14/12) instead of the awkward 12/15 they sat at before. Bumping them
would inflate the UI for no design gain.
Everything stays rem so Cmd +/- zoom keeps scaling it; the check:px-text
guard stays green (its guidance updated to point at the stock scale).
Net: zero custom font tokens, timeline/thread render path only — no
app-wide sweep. Composer chrome (.tiptap pre code) left untouched.
Co-authored-by: Taylor Ho
Signed-off-by: Taylor Ho
---
AGENTS.md | 17 ++--
RESEARCH/CHAT_BASE_TYPE_SCALE.md | 84 +++++++++++++++++++
desktop/scripts/check-px-text.mjs | 8 +-
.../src/features/messages/ui/MessageRow.tsx | 6 +-
desktop/src/shared/styles/globals.css | 4 +-
desktop/src/shared/ui/markdown.tsx | 6 +-
desktop/src/shared/ui/mentionChip.ts | 2 +-
desktop/tailwind.config.js | 9 --
scripts/check-px-text-core.mjs | 7 +-
9 files changed, 111 insertions(+), 32 deletions(-)
create mode 100644 RESEARCH/CHAT_BASE_TYPE_SCALE.md
diff --git a/AGENTS.md b/AGENTS.md
index 90b531ba76..1cda517b62 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -422,16 +422,19 @@ are frozen.**
So for any readable text, reach for rem-based Tailwind tokens, never arbitrary
px:
-- ✅ `text-chat` (chat body/author, 15px in rem), `text-code` (inline/block
- code, 13px in rem), or a stock token (`text-sm`, `text-base`, …). Tokens live
- in `desktop/tailwind.config.js` under `theme.extend.fontSize`.
+- ✅ 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.
- ❌ `text-[15px]`, `text-[13px]`, or CSS `font-size: 15px`. These opted out of
zoom and caused the message-timeline regression (PR #891).
-If a design needs a size between stock tokens (e.g. 15px sits between `text-sm`
-14px and `text-base` 16px), **add a rem-based token** rather than an arbitrary
-px value. A CI guard (`pnpm check:px-text`, in `desktop/scripts/check-px-text.mjs`)
-fails on new px text in the message-timeline / thread render path.
+Prefer stock tokens — they're rem and zoom-safe. Only if a design genuinely
+needs a size the stock scale can't express should you **add a rem-based token**
+(in `desktop/tailwind.config.js` under `theme.extend.fontSize`) rather than an
+arbitrary px value. A CI guard (`pnpm check:px-text`, in
+`desktop/scripts/check-px-text.mjs`) fails on new px text in the
+message-timeline / thread render path.
### Workspace Switching
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/desktop/scripts/check-px-text.mjs b/desktop/scripts/check-px-text.mjs
index 27693d843c..519ec7815d 100644
--- a/desktop/scripts/check-px-text.mjs
+++ b/desktop/scripts/check-px-text.mjs
@@ -7,10 +7,10 @@ const projectRoot = path.resolve(__dirname, "..");
// Scoped to the message-timeline / thread render path — the surface where the
// rem→px zoom regression (PR #891) landed. Readable message text here MUST use
-// rem-based tokens (`text-chat`, `text-code`) so Cmd +/- zoom scales it. We
-// intentionally do NOT sweep the whole app yet (decorative chrome — avatar
-// initials, day dividers, diff-viewer labels — still uses px); widen these
-// roots when that sweep happens.
+// rem-based tokens (the stock `text-base` / `text-sm` scale, chat === base)
+// so Cmd +/- zoom scales it. We intentionally do NOT sweep the whole app yet
+// (decorative chrome — avatar initials, day dividers, diff-viewer labels —
+// still uses px); widen these roots when that sweep happens.
const rules = [
{
root: "src/shared/ui",
diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx
index ca40f7dec2..424410cdc3 100644
--- a/desktop/src/features/messages/ui/MessageRow.tsx
+++ b/desktop/src/features/messages/ui/MessageRow.tsx
@@ -188,7 +188,7 @@ export const MessageRow = React.memo(
+
{message.author}
) : (
-
+
{message.author}
);
diff --git a/desktop/src/shared/styles/globals.css b/desktop/src/shared/styles/globals.css
index 95338f2c70..69961f3fb8 100644
--- a/desktop/src/shared/styles/globals.css
+++ b/desktop/src/shared/styles/globals.css
@@ -615,8 +615,8 @@
border-radius: 4px;
background: hsl(var(--primary) / 0.15);
padding: 3px 4px 2px;
- /* 0.9375rem === 15px; rem so it scales with the root-font-size zoom. */
- font-size: 0.9375rem;
+ /* 1rem === 16px (chat base); rem so it scales with the root-font-size zoom. */
+ font-size: 1rem;
line-height: 1;
color: hsl(var(--primary));
font-weight: 500;
diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx
index 67ce1b63a4..ab298d5817 100644
--- a/desktop/src/shared/ui/markdown.tsx
+++ b/desktop/src/shared/ui/markdown.tsx
@@ -78,7 +78,7 @@ const MAX_CACHE_ENTRIES = 100;
const MAX_LOADED_LANGUAGES = 30;
const MAX_HIGHLIGHT_LINES = 150;
const CODE_BLOCK_CLASS =
- "code-block-lines block min-w-full whitespace-pre font-mono text-code text-foreground";
+ "code-block-lines block min-w-full whitespace-pre font-mono text-sm text-foreground";
const DIFF_ADD_RE = /\s*\/\/\s*\[!code\s*\+\+\]\s*$/;
const DIFF_REMOVE_RE = /\s*\/\/\s*\[!code\s*--\]\s*$/;
@@ -850,7 +850,7 @@ function createMarkdownComponents(
@@ -1221,7 +1221,7 @@ function MarkdownInner({
].join(" ")
: compact
? [
- "max-w-none break-words text-chat text-foreground/90",
+ "max-w-none break-words text-base text-foreground/90",
"[&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
"[&>*+*]:mt-2",
"[&>*+h1]:mt-3 [&>*+h2]:mt-3 [&>*+h3]:mt-3",
diff --git a/desktop/src/shared/ui/mentionChip.ts b/desktop/src/shared/ui/mentionChip.ts
index d0c7a3502b..583ee3ff3d 100644
--- a/desktop/src/shared/ui/mentionChip.ts
+++ b/desktop/src/shared/ui/mentionChip.ts
@@ -1,5 +1,5 @@
export const MENTION_CHIP_BASE_CLASSES =
- "inline-block rounded-[4px] bg-primary/15 px-1 pt-[3px] pb-[2px] text-chat font-medium leading-none text-primary";
+ "inline-block rounded-[4px] bg-primary/15 px-1 pt-[3px] pb-[2px] text-base font-medium leading-none text-primary";
export const MENTION_CHIP_HOVER_CLASSES =
"transition-colors hover:bg-primary/25 hover:text-primary/90";
diff --git a/desktop/tailwind.config.js b/desktop/tailwind.config.js
index 93b7d8fe65..6422fc81ac 100644
--- a/desktop/tailwind.config.js
+++ b/desktop/tailwind.config.js
@@ -2,15 +2,6 @@
export default {
theme: {
extend: {
- fontSize: {
- // Chat body/author sizing. 15px sits between Tailwind's stock
- // `text-sm` (14px) and `text-base` (16px), so we express it as a rem
- // token instead of a hardcoded px value — px would not scale with the
- // root-font-size zoom (Cmd +/-). 0.9375rem === 15px at the 16px root.
- chat: ["0.9375rem", { lineHeight: "1.5rem" }],
- // Inline & block code inside chat messages (13px → 0.8125rem).
- code: ["0.8125rem", { lineHeight: "1.5rem" }],
- },
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
diff --git a/scripts/check-px-text-core.mjs b/scripts/check-px-text-core.mjs
index 053c6b976b..16e7dd6e65 100644
--- a/scripts/check-px-text-core.mjs
+++ b/scripts/check-px-text-core.mjs
@@ -8,7 +8,7 @@ import path from "node:path";
* 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. `text-chat`, `text-code`, `text-sm`).
+ * (e.g. the stock `text-base`, `text-sm`, `text-xs` scale — chat === base).
*
* It flags:
* - Tailwind arbitrary px text utilities: `text-[NNpx]`
@@ -106,8 +106,9 @@ export async function runPxTextCheck({
console.error(`- ${v.relativePath}:${v.lineNumber}: ${v.match}`);
}
console.error(
- "Use a rem-based Tailwind text token (e.g. `text-chat`, `text-code`, " +
- "`text-sm`) so the text scales with Cmd +/- zoom. If this px size is " +
+ "Use a rem-based Tailwind text token (e.g. the stock `text-base`, " +
+ "`text-sm`, `text-xs` scale) so the text scales with Cmd +/- zoom. " +
+ "If this px size is " +
"genuinely decorative/chrome (not readable message text), add a " +
`narrowly scoped \`relativePath:lineNumber\` exception in \`${scriptPath}\`.`,
);
From c439255c69ce292f2a54fab8221092c6ad25126d Mon Sep 17 00:00:00 2001
From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
<52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co>
Date: Sun, 14 Jun 2026 23:19:57 -0700
Subject: [PATCH 03/20] fix(desktop): convert px text literals to rem so zoom
scales app-wide
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Cmd +/- zoom scales the root html font-size (rem-only by design), but
text-[Npx] font literals froze text in pixels and broke zoom outside the
timeline. Convert the 63 non-overlapping UI surfaces to rem so text
scales proportionally everywhere.
Mapping: 12px -> stock text-xs token; sizes with no clean stock home
(7-11px micro-text on avatars, badges, chips) -> 1:1 rem literals to
preserve exact visual hierarchy with zero pixel shift. Also converts the
2 stray leading-[100px] line-height literals to rem.
Deliberately defers the 3 render-path files (MessageRow.tsx, markdown.tsx,
mentionChip.ts) to PR #1052, which already converts them and is awaiting
merge — touching them here on a main base would manufacture a conflict.
The check:px-text guard lands with #1052; once it merges, the guard
covers this branch's 63 files plus #1052's 3.
Co-authored-by: Taylor Ho
Signed-off-by: Taylor Ho
---
.../src/features/agent-memory/ui/MemorySection.tsx | 4 ++--
.../features/agents/ui/AddTeamToChannelDialog.tsx | 2 +-
.../features/agents/ui/AgentSessionToolItem.tsx | 6 +++---
.../agents/ui/AgentSessionTranscriptList.tsx | 6 +++---
.../features/agents/ui/ManagedAgentLogPanel.tsx | 4 ++--
desktop/src/features/agents/ui/ManagedAgentRow.tsx | 4 ++--
desktop/src/features/agents/ui/ModelPicker.tsx | 2 +-
.../agents/ui/PersonaCatalogSelectionBadge.tsx | 2 +-
desktop/src/features/agents/ui/RawEventRail.tsx | 4 ++--
.../features/agents/ui/RelayDirectorySection.tsx | 4 ++--
desktop/src/features/agents/ui/RespondToField.tsx | 4 ++--
desktop/src/features/agents/ui/TeamDialog.tsx | 2 +-
desktop/src/features/agents/ui/TeamsSection.tsx | 6 +++---
.../channels/ui/AddChannelBotPersonasSection.tsx | 12 ++++++------
.../channels/ui/AddChannelBotTeamsSection.tsx | 8 ++++----
.../channels/ui/AgentSessionThreadPanel.tsx | 4 ++--
.../src/features/channels/ui/BotActivityBar.tsx | 8 ++++----
.../channels/ui/ChannelMemberInviteCard.tsx | 4 ++--
.../features/channels/ui/ChannelScreenHeader.tsx | 2 +-
.../src/features/channels/ui/MembersSidebar.tsx | 6 +++---
.../channels/ui/MembersSidebarMemberCard.tsx | 4 ++--
desktop/src/features/channels/ui/QuickBotBar.tsx | 2 +-
desktop/src/features/home/ui/FeedSection.tsx | 10 +++++-----
desktop/src/features/home/ui/InboxDetailPane.tsx | 2 +-
desktop/src/features/home/ui/InboxListPane.tsx | 6 +++---
.../src/features/home/ui/RecentNotesSection.tsx | 4 ++--
.../src/features/huddle/components/HuddleBar.tsx | 2 +-
.../features/huddle/components/HuddleIndicator.tsx | 2 +-
.../src/features/huddle/components/MicControls.tsx | 2 +-
.../features/huddle/components/ParticipantList.tsx | 6 +++---
.../features/messages/ui/ComposerAttachments.tsx | 2 +-
desktop/src/features/messages/ui/DayDivider.tsx | 2 +-
desktop/src/features/messages/ui/DiffViewer.tsx | 6 +++---
.../src/features/messages/ui/MessageComposer.tsx | 4 ++--
.../features/messages/ui/MessageThreadPanel.tsx | 2 +-
.../messages/ui/MessageThreadSummaryRow.tsx | 2 +-
.../src/features/messages/ui/MessageTimeline.tsx | 2 +-
.../src/features/messages/ui/SystemMessageRow.tsx | 6 +++---
.../features/messages/ui/TypingIndicatorRow.tsx | 4 ++--
desktop/src/features/onboarding/ui/AvatarStep.tsx | 2 +-
.../features/onboarding/ui/MembershipDenied.tsx | 2 +-
.../features/onboarding/ui/NostrKeyImportForm.tsx | 2 +-
.../src/features/profile/ui/UserProfilePopover.tsx | 2 +-
.../src/features/pulse/ui/AgentActivityCard.tsx | 4 ++--
desktop/src/features/pulse/ui/NoteCard.tsx | 2 +-
desktop/src/features/pulse/ui/PulseTabBar.tsx | 2 +-
desktop/src/features/search/ui/TopbarSearch.tsx | 6 +++---
.../settings/ui/ChannelTemplatesSettingsCard.tsx | 7 +++++--
.../features/settings/ui/DoctorSettingsPanel.tsx | 14 +++++++-------
.../settings/ui/NotificationSettingsCard.tsx | 2 +-
.../features/settings/ui/ProfileSettingsCard.tsx | 2 +-
desktop/src/features/settings/ui/SoundPicker.tsx | 2 +-
desktop/src/features/sidebar/ui/AppSidebar.tsx | 4 ++--
.../src/features/sidebar/ui/MoreUnreadButton.tsx | 2 +-
.../features/sidebar/ui/NewDirectMessageDialog.tsx | 2 +-
.../src/features/sidebar/ui/SidebarProfileCard.tsx | 5 ++++-
desktop/src/features/sidebar/ui/SidebarSection.tsx | 8 ++++----
.../features/user-status/ui/SetStatusDialog.tsx | 2 +-
desktop/src/features/workflows/ui/WorkflowCard.tsx | 2 +-
.../features/workflows/ui/WorkflowDetailPanel.tsx | 6 +++---
.../src/features/workflows/ui/WorkflowRunTrace.tsx | 6 +++---
.../features/workspaces/ui/WorkspaceSwitcher.tsx | 2 +-
desktop/src/shared/ui/UserAvatar.tsx | 4 ++--
desktop/src/shared/ui/VideoPlayer.tsx | 14 +++++++-------
desktop/src/shared/ui/badge.tsx | 2 +-
65 files changed, 139 insertions(+), 133 deletions(-)
diff --git a/desktop/src/features/agent-memory/ui/MemorySection.tsx b/desktop/src/features/agent-memory/ui/MemorySection.tsx
index 0b2d4f4a45..df8025159b 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.