From 53c4037294ce684ed1b2f25032fb3e59a277e68f Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Tue, 10 Nov 2026 05:44:39 +0530 Subject: [PATCH] feat(web): add independent chat font size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an optional 12–32 px size for ordinary user messages and agent replies in Appearance. Keep legacy interface-size inheritance until explicitly changed and restore it on reset. Refs #32790 Implemented by GPT-6.1 Sol through the Codex harness in T3 Code. --- apps/web/src/appInit.tsx | 2 + apps/web/src/appearanceFonts.browser.test.ts | 12 ++-- apps/web/src/appearanceFonts.ts | 11 ++++ .../components/chat/TimelineAssistantRows.tsx | 1 + .../chat/TimelineUserMessageRow.tsx | 2 +- .../settings/SettingsPanels.logic.ts | 2 + .../components/settings/SettingsPanels.tsx | 1 + .../settings/TypographySettings.tsx | 61 +++++++++++++++++++ .../src/components/settings/settingsSearch.ts | 6 ++ apps/web/src/index.css | 25 +++++--- docs/user/appearance.md | 7 +++ packages/contracts/src/settings.test.ts | 11 ++++ packages/contracts/src/settings.ts | 9 +++ 13 files changed, 136 insertions(+), 14 deletions(-) diff --git a/apps/web/src/appInit.tsx b/apps/web/src/appInit.tsx index faa71137db049..a2f80534d3e12 100644 --- a/apps/web/src/appInit.tsx +++ b/apps/web/src/appInit.tsx @@ -103,6 +103,7 @@ function startClientAppearance(): void { previous.fontFamilyCode !== settings.fontFamilyCode || previous.fontFamilyComposer !== settings.fontFamilyComposer || previous.fontSizeInterface !== settings.fontSizeInterface || + previous.fontSizeChat !== settings.fontSizeChat || previous.fontSizePrompt !== settings.fontSizePrompt || previous.fontSizeCode !== settings.fontSizeCode || previous.fontSmoothing !== settings.fontSmoothing || @@ -113,6 +114,7 @@ function startClientAppearance(): void { code: settings.fontFamilyCode, composer: settings.fontFamilyComposer, sizeInterface: settings.fontSizeInterface, + sizeChat: settings.fontSizeChat, sizePrompt: settings.fontSizePrompt, sizeCode: settings.fontSizeCode, smoothing: settings.fontSmoothing, diff --git a/apps/web/src/appearanceFonts.browser.test.ts b/apps/web/src/appearanceFonts.browser.test.ts index 289e48ecad050..0e8fb739f611c 100644 --- a/apps/web/src/appearanceFonts.browser.test.ts +++ b/apps/web/src/appearanceFonts.browser.test.ts @@ -34,6 +34,7 @@ describe("applyAppearanceFontVariables", () => { code: "", composer: "", sizeInterface: 20, + sizeChat: null, sizePrompt: 14, sizeCode: 13, smoothing: true, @@ -56,20 +57,20 @@ describe("applyAppearanceFontVariables", () => { it("widens the numbered-list gutter only when the interface font's marker would clip", () => { const root = document.documentElement; const style = root.getAttribute("style"); - const gutter = (sans: string) => { + const gutter = (sans: string, sizeChat: number | null = null) => { applyAppearanceFontVariables(root, { sans, code: "", composer: "", sizeInterface: 16, + sizeChat, sizePrompt: 14, sizeCode: 13, smoothing: true, separate: false, }); const markdown = document.createElement("div"); - markdown.className = "chat-markdown"; - markdown.style.fontSize = "14px"; + markdown.className = "chat-markdown chat-message-markdown"; const list = document.createElement("ol"); markdown.append(list); document.body.append(markdown); @@ -81,8 +82,11 @@ describe("applyAppearanceFontVariables", () => { }; try { // `0. ` in a 14px monospace font is about 25px, past the 20px default. - expect(gutter("monospace")).toBeGreaterThan(24); + const monospaceGutter = gutter("monospace"); + expect(monospaceGutter).toBeGreaterThan(24); expect(gutter("sans-serif")).toBe(20); + expect(gutter("monospace", 32)).toBeCloseTo(monospaceGutter * (32 / 14)); + expect(gutter("sans-serif", 32)).toBeCloseTo((32 / 0.875) * 1.25); } finally { if (style === null) root.removeAttribute("style"); else root.setAttribute("style", style); diff --git a/apps/web/src/appearanceFonts.ts b/apps/web/src/appearanceFonts.ts index 19f2d88d3c29d..09c39f7b8bd67 100644 --- a/apps/web/src/appearanceFonts.ts +++ b/apps/web/src/appearanceFonts.ts @@ -2,9 +2,11 @@ import { DEFAULT_CODE_FONT_SIZE, DEFAULT_INTERFACE_FONT_SIZE, DEFAULT_PROMPT_FONT_SIZE, + MAX_CHAT_FONT_SIZE, MAX_CODE_FONT_SIZE, MAX_INTERFACE_FONT_SIZE, MAX_PROMPT_FONT_SIZE, + MIN_CHAT_FONT_SIZE, MIN_CODE_FONT_SIZE, MIN_INTERFACE_FONT_SIZE, MIN_PROMPT_FONT_SIZE, @@ -63,6 +65,7 @@ export interface AppearanceFontPreferences { readonly code: string; readonly composer: string; readonly sizeInterface: number; + readonly sizeChat: number | null; readonly sizePrompt: number; readonly sizeCode: number; readonly smoothing: boolean; @@ -103,6 +106,14 @@ export function applyAppearanceFontVariables( "--font-size-prompt", preferences.separate ? `${clampPromptFontSize(preferences.sizePrompt)}px` : "var(--text-sm)", ); + if (preferences.sizeChat === null) { + root.style.removeProperty("--font-size-chat"); + } else { + root.style.setProperty( + "--font-size-chat", + `${clampFontSize(preferences.sizeChat, MIN_CHAT_FONT_SIZE, MAX_CHAT_FONT_SIZE, MIN_CHAT_FONT_SIZE)}px`, + ); + } const code = clampCodeFontSize(preferences.sizeCode); root.style.setProperty("--font-size-code", `${code}px`); root.style.setProperty("--diffs-font-size", `${code}px`); diff --git a/apps/web/src/components/chat/TimelineAssistantRows.tsx b/apps/web/src/components/chat/TimelineAssistantRows.tsx index 36c04da893275..9fd06c32ddc38 100644 --- a/apps/web/src/components/chat/TimelineAssistantRows.tsx +++ b/apps/web/src/components/chat/TimelineAssistantRows.tsx @@ -55,6 +55,7 @@ export function AssistantTimelineRow({ row }: { row: Extract void) { fontFamilyCode: DEFAULT_UNIFIED_SETTINGS.fontFamilyCode, fontFamilyTerminal: DEFAULT_UNIFIED_SETTINGS.fontFamilyTerminal, fontSizeInterface: DEFAULT_UNIFIED_SETTINGS.fontSizeInterface, + fontSizeChat: DEFAULT_UNIFIED_SETTINGS.fontSizeChat, fontSizePrompt: DEFAULT_UNIFIED_SETTINGS.fontSizePrompt, fontSizeCode: DEFAULT_UNIFIED_SETTINGS.fontSizeCode, fontSizeTerminal: DEFAULT_UNIFIED_SETTINGS.fontSizeTerminal, diff --git a/apps/web/src/components/settings/TypographySettings.tsx b/apps/web/src/components/settings/TypographySettings.tsx index 0d65e5c4988e7..4063b42940ca8 100644 --- a/apps/web/src/components/settings/TypographySettings.tsx +++ b/apps/web/src/components/settings/TypographySettings.tsx @@ -2,10 +2,12 @@ import type { ReactNode } from "react"; import { useEffect, useEffectEvent, useRef, useState } from "react"; import { DEFAULT_UNIFIED_SETTINGS, + MAX_CHAT_FONT_SIZE, MAX_CODE_FONT_SIZE, MAX_INTERFACE_FONT_SIZE, MAX_PROMPT_FONT_SIZE, MAX_TERMINAL_FONT_SIZE, + MIN_CHAT_FONT_SIZE, MIN_CODE_FONT_SIZE, MIN_INTERFACE_FONT_SIZE, MIN_PROMPT_FONT_SIZE, @@ -96,6 +98,64 @@ function InterfaceFontRow({ preview }: { preview?: ReactNode }) { ); } +function ChatFontSizeRow() { + const settings = useScopedSettings(); + const updateSettings = useUpdateScopedSettings(); + return ( + updateSettings({ fontSizeChat: DEFAULT_UNIFIED_SETTINGS.fontSizeChat })} + /> + ) : null + } + control={ + + } + /> + ); +} + function PromptFontRow() { const settings = useScopedSettings(); const updateSettings = useUpdateScopedSettings(); @@ -346,6 +406,7 @@ export function TypographySection() { return ( {showAdvanced ? : } + :first-child { margin-top: 0; } @@ -1903,21 +1908,21 @@ code { } :is(.chat-markdown, .composer-tiptap) h1 { - font-size: 1.25rem; + font-size: calc(var(--chat-markdown-font-unit, 1rem) * 1.25); } :is(.chat-markdown, .composer-tiptap) h2 { - font-size: 1.125rem; + font-size: calc(var(--chat-markdown-font-unit, 1rem) * 1.125); } :is(.chat-markdown, .composer-tiptap) h3 { - font-size: 1rem; + font-size: calc(var(--chat-markdown-font-unit, 1rem) * 1); } :is(.chat-markdown, .composer-tiptap) h4, :is(.chat-markdown, .composer-tiptap) h5, :is(.chat-markdown, .composer-tiptap) h6 { - font-size: 0.875rem; + font-size: calc(var(--chat-markdown-font-unit, 1rem) * 0.875); } :is(.chat-markdown, .composer-tiptap) h6 { @@ -1928,8 +1933,8 @@ code { /* Reset for nested uls under a widened ol. --list-gutter is an inherited custom property, so without this a task-list under a multi-digit ordered list would inherit the outer gutter instead of its own default. */ - --list-gutter: 1.25rem; - padding-inline-start: 1.25rem; + --list-gutter: calc(var(--chat-markdown-font-unit, 1rem) * 1.25); + padding-inline-start: var(--list-gutter); list-style-type: disc; } @@ -1937,7 +1942,10 @@ code { measured per font in appearanceFonts. ChatMarkdown widens it inline for multi-digit markers; setting it here stops a nested ol inheriting that. */ :is(.chat-markdown, .composer-tiptap) ol { - --list-gutter: max(1.25rem, var(--list-marker-sans, 0px)); + --list-gutter: max( + calc(var(--chat-markdown-font-unit, 1rem) * 1.25), + var(--list-marker-sans, 0px) + ); padding-inline-start: var(--list-gutter, 1.25rem); list-style-type: decimal; } @@ -2265,8 +2273,7 @@ code { border-collapse: collapse; overflow-wrap: normal; word-break: normal; - /* One step under 15px prose. */ - font-size: 0.875rem; + font-size: calc(var(--chat-markdown-font-unit, 1rem) * 0.875); } /* Top-aligned so wrapped rows share a first line, and the outer columns sit diff --git a/docs/user/appearance.md b/docs/user/appearance.md index b27173dd60ff9..bbb91b3621250 100644 --- a/docs/user/appearance.md +++ b/docs/user/appearance.md @@ -21,6 +21,13 @@ Android uses **Material You Layout** by default unless you have turned it off in It changes shapes, spacing, and controls independently of the selected theme. +## Chat text size + +On web and desktop, use **Settings → Appearance → Typography → Chat font size** to set user +messages and agent replies from 12 to 32 px without enlarging the sidebar or composer. Code +blocks keep their own font size. **Default** or resetting the setting restores chat text that +follows the interface font size. + ## Composer context Git-backed projects show branch and worktree controls below the composer while you create a thread. diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 4c194f473c7b0..f203616f25d5e 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -303,6 +303,17 @@ describe("resolveIdleCompaction", () => { }); }); +describe("ClientSettings chat font size", () => { + it("preserves inherited sizes for older settings and round-trips explicit sizes and reset", () => { + const legacy = decodeClientSettings({}); + expect(legacy.fontSizeChat).toBeNull(); + for (const fontSizeChat of [32, null]) { + const stored = encodeClientSettings({ ...legacy, fontSizeChat }); + expect(decodeClientSettings(stored).fontSizeChat).toBe(fontSizeChat); + } + }); +}); + describe("ClientSettings notifications", () => { it("requires opt-in when existing settings omit notification preferences", () => { expect(decodeClientSettings({}).notificationMode).toBe("off"); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 49470541d90be..cb47787fe3dec 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -171,6 +171,13 @@ export const InterfaceFontSize = Schema.Int.check( export type InterfaceFontSize = typeof InterfaceFontSize.Type; export const DEFAULT_INTERFACE_FONT_SIZE: InterfaceFontSize = 16; +export const MIN_CHAT_FONT_SIZE = 12; +export const MAX_CHAT_FONT_SIZE = 32; +export const ChatFontSize = Schema.Int.check( + Schema.isBetween({ minimum: MIN_CHAT_FONT_SIZE, maximum: MAX_CHAT_FONT_SIZE }), +); +export type ChatFontSize = typeof ChatFontSize.Type; + export const MIN_PROMPT_FONT_SIZE = 12; export const MAX_PROMPT_FONT_SIZE = 20; export const PromptFontSize = Schema.Int.check( @@ -477,6 +484,7 @@ export const ClientSettingsSchema = Schema.Struct({ fontSizeInterface: InterfaceFontSize.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_INTERFACE_FONT_SIZE)), ), + fontSizeChat: Schema.NullOr(ChatFontSize).pipe(Schema.withDecodingDefault(Effect.succeed(null))), fontSizePrompt: PromptFontSize.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_PROMPT_FONT_SIZE)), ), @@ -2034,6 +2042,7 @@ export const ClientSettingsPatch = Schema.Struct({ glassOpacity: Schema.optionalKey(GlassOpacity), onboardingCompletedAt: Schema.optionalKey(Schema.NullOr(Schema.String)), fontSizeInterface: Schema.optionalKey(InterfaceFontSize), + fontSizeChat: Schema.optionalKey(Schema.NullOr(ChatFontSize)), fontSizePrompt: Schema.optionalKey(PromptFontSize), fontSizeCode: Schema.optionalKey(CodeFontSize), fontSizeTerminal: Schema.optionalKey(TerminalFontSize),