From 2615366f8ffb05006615688cccf2b6a7eb3d18a7 Mon Sep 17 00:00:00 2001 From: chief-archivist Date: Wed, 28 Oct 2026 17:19:39 +0100 Subject: [PATCH 1/3] fix(web): the prompt box follows the interface font size Without "Separate fonts", Typography shows only the interface and monospace rows, but the composer kept its own hidden 14px prompt size and font. The prompt now follows the interface font and size until separate fonts are on, the same way the terminal follows the code font. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/appInit.tsx | 17 ++++++- apps/web/src/appearanceFonts.browser.test.ts | 42 +++++++++++++++++ apps/web/src/appearanceFonts.ts | 16 +++++-- apps/web/src/hooks/useLocalStorage.ts | 49 ++++++++++++-------- 4 files changed, 100 insertions(+), 24 deletions(-) create mode 100644 apps/web/src/appearanceFonts.browser.test.ts diff --git a/apps/web/src/appInit.tsx b/apps/web/src/appInit.tsx index b9312858e334b..9d77c475155cb 100644 --- a/apps/web/src/appInit.tsx +++ b/apps/web/src/appInit.tsx @@ -1,9 +1,10 @@ import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; import type { ClientSettings } from "@t3tools/contracts/settings"; import { createBrowserHistory, createHashHistory } from "@tanstack/react-router"; +import * as Schema from "effect/Schema"; import { applyAppearanceContrast } from "./appearanceContrast"; -import { applyAppearanceFontVariables } from "./appearanceFonts"; +import { applyAppearanceFontVariables, TYPOGRAPHY_ADVANCED_STORAGE_KEY } from "./appearanceFonts"; import { startElectronBrowserHost } from "./browser/ElectronBrowserHost"; import { startBrowserProfileReporting } from "./browser/BrowserProfileReporter"; import { startPreviewTitleRecording } from "./browserHistoryStore"; @@ -30,6 +31,7 @@ import { } from "./composerDraftStore"; import { startDefaultThemeAdoption } from "./hooks/useDefaultTheme"; import { startEnvironmentThemeRepaint } from "./hooks/useEnvironmentTheme"; +import { readLocalStorageValue, subscribeLocalStorageKey } from "./hooks/useLocalStorage"; import { getClientSettings, startModelPreferencesMigration, @@ -58,10 +60,18 @@ import { resolveThreadRouteTarget } from "./threadRoutes"; function startClientAppearance(): void { const root = document.documentElement; let applied: ClientSettings | null = null; + let appliedSeparateFonts: boolean | null = null; const apply = () => { const settings = getClientSettings(); const previous = applied; applied = settings; + const separateFonts = readLocalStorageValue( + TYPOGRAPHY_ADVANCED_STORAGE_KEY, + false, + Schema.Boolean, + ); + const previousSeparateFonts = appliedSeparateFonts; + appliedSeparateFonts = separateFonts; if (previous?.diffColorScheme !== settings.diffColorScheme) { root.dataset.diffColorScheme = settings.diffColorScheme; } @@ -86,7 +96,8 @@ function startClientAppearance(): void { previous.fontSizeInterface !== settings.fontSizeInterface || previous.fontSizePrompt !== settings.fontSizePrompt || previous.fontSizeCode !== settings.fontSizeCode || - previous.fontSmoothing !== settings.fontSmoothing + previous.fontSmoothing !== settings.fontSmoothing || + previousSeparateFonts !== separateFonts ) { applyAppearanceFontVariables(root, { sans: settings.fontFamilySans, @@ -96,10 +107,12 @@ function startClientAppearance(): void { sizePrompt: settings.fontSizePrompt, sizeCode: settings.fontSizeCode, smoothing: settings.fontSmoothing, + separate: separateFonts, }); } }; subscribeClientSettings(apply); + subscribeLocalStorageKey(TYPOGRAPHY_ADVANCED_STORAGE_KEY, apply); apply(); } diff --git a/apps/web/src/appearanceFonts.browser.test.ts b/apps/web/src/appearanceFonts.browser.test.ts new file mode 100644 index 0000000000000..a8d0fa4f6d3e3 --- /dev/null +++ b/apps/web/src/appearanceFonts.browser.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vite-plus/test"; + +import "~/index.css"; +import { applyAppearanceFontVariables } from "./appearanceFonts"; + +function composerFontSize(): string { + const probe = document.createElement("div"); + // The composer editor's own size class. + probe.className = "text-(length:--font-size-prompt,var(--text-sm))"; + document.body.append(probe); + try { + return getComputedStyle(probe).fontSize; + } finally { + probe.remove(); + } +} + +describe("applyAppearanceFontVariables", () => { + it("sizes the composer from the interface size until fonts are set separately", () => { + const root = document.documentElement; + const style = root.getAttribute("style"); + const preferences = { + sans: "", + code: "", + composer: "", + sizeInterface: 20, + sizePrompt: 14, + sizeCode: 13, + smoothing: true, + }; + try { + applyAppearanceFontVariables(root, { ...preferences, separate: false }); + expect(composerFontSize()).toBe("17.5px"); + + applyAppearanceFontVariables(root, { ...preferences, separate: true }); + expect(composerFontSize()).toBe("14px"); + } 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 48f8e5955e7e1..659b9e9f0f9b0 100644 --- a/apps/web/src/appearanceFonts.ts +++ b/apps/web/src/appearanceFonts.ts @@ -66,9 +66,11 @@ export interface AppearanceFontPreferences { readonly sizePrompt: number; readonly sizeCode: number; readonly smoothing: boolean; + /** Without separate fonts the prompt follows the interface, as the terminal follows code. */ + readonly separate: boolean; } -/** Always writes sizes: interface size sets the root; prompt and code use pixels to avoid double scaling. */ +/** Interface size sets the root; prompt and code use pixels to avoid double scaling. */ export function applyAppearanceFontVariables( root: HTMLElement, preferences: AppearanceFontPreferences, @@ -76,7 +78,7 @@ export function applyAppearanceFontVariables( const families: ReadonlyArray = [ ["--font-sans", preferences.sans, DEFAULT_SANS_FONT_STACK], ["--font-mono", preferences.code, DEFAULT_CODE_FONT_STACK], - ["--font-composer", preferences.composer, "var(--font-sans)"], + ["--font-composer", preferences.separate ? preferences.composer : "", "var(--font-sans)"], ]; for (const [variable, custom, fallback] of families) { const list = cssFontFamilies(custom); @@ -88,7 +90,15 @@ export function applyAppearanceFontVariables( } root.style.fontSize = `${clampInterfaceFontSize(preferences.sizeInterface)}px`; - root.style.setProperty("--font-size-prompt", `${clampPromptFontSize(preferences.sizePrompt)}px`); + if (preferences.separate) { + root.style.setProperty( + "--font-size-prompt", + `${clampPromptFontSize(preferences.sizePrompt)}px`, + ); + } else { + // The composer then falls back to text-sm, which scales with the interface size. + root.style.removeProperty("--font-size-prompt"); + } 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/hooks/useLocalStorage.ts b/apps/web/src/hooks/useLocalStorage.ts index cd6d2f4fcb8db..5c172287131af 100644 --- a/apps/web/src/hooks/useLocalStorage.ts +++ b/apps/web/src/hooks/useLocalStorage.ts @@ -123,6 +123,35 @@ function decodeOrInitial( } } +export function readLocalStorageValue( + key: string, + initialValue: T, + schema: Schema.Codec, +): T { + return decodeOrInitial(key, schema, readSerializedValue(key), initialValue); +} + +/** Fires on writes from this tab and from other tabs. */ +export function subscribeLocalStorageKey(key: string, onChange: () => void): () => void { + const handleStorageChange = (event: StorageEvent) => { + if (event.key === key) { + onChange(); + } + }; + const handleLocalChange = (event: CustomEvent) => { + if (event.detail.key === key) { + onChange(); + } + }; + + window.addEventListener("storage", handleStorageChange); + window.addEventListener(LOCAL_STORAGE_CHANGE_EVENT, handleLocalChange as EventListener); + return () => { + window.removeEventListener("storage", handleStorageChange); + window.removeEventListener(LOCAL_STORAGE_CHANGE_EVENT, handleLocalChange as EventListener); + }; +} + export function writeLocalStorageValue( key: string, value: T | ((val: T) => T), @@ -169,25 +198,7 @@ export function useLocalStorage( ): [T, (value: T | ((val: T) => T)) => void] { const getSnapshot = () => readSerializedValue(key); - const subscribe = (onStoreChange: () => void) => { - const handleStorageChange = (event: StorageEvent) => { - if (event.key === key) { - onStoreChange(); - } - }; - const handleLocalChange = (event: CustomEvent) => { - if (event.detail.key === key) { - onStoreChange(); - } - }; - - window.addEventListener("storage", handleStorageChange); - window.addEventListener(LOCAL_STORAGE_CHANGE_EVENT, handleLocalChange as EventListener); - return () => { - window.removeEventListener("storage", handleStorageChange); - window.removeEventListener(LOCAL_STORAGE_CHANGE_EVENT, handleLocalChange as EventListener); - }; - }; + const subscribe = (onStoreChange: () => void) => subscribeLocalStorageKey(key, onStoreChange); const serializedValue = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); const storedValue = decodeOrInitial(key, schema, serializedValue, initialValue); From 35ea45b07930d36442718f1b237c384301c93dbd Mon Sep 17 00:00:00 2001 From: chief-archivist Date: Wed, 28 Oct 2026 17:44:36 +0100 Subject: [PATCH 2/3] fix(web): the prompt keeps the interface-scaled size on touch screens Co-Authored-By: Claude Opus 5.5 --- apps/web/src/appearanceFonts.browser.test.ts | 19 ++++++++++++++++--- apps/web/src/index.css | 2 +- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/apps/web/src/appearanceFonts.browser.test.ts b/apps/web/src/appearanceFonts.browser.test.ts index a8d0fa4f6d3e3..9bdc5c2ef9864 100644 --- a/apps/web/src/appearanceFonts.browser.test.ts +++ b/apps/web/src/appearanceFonts.browser.test.ts @@ -3,10 +3,9 @@ import { describe, expect, it } from "vite-plus/test"; import "~/index.css"; import { applyAppearanceFontVariables } from "./appearanceFonts"; -function composerFontSize(): string { +function renderedFontSize(style: (probe: HTMLElement) => void): string { const probe = document.createElement("div"); - // The composer editor's own size class. - probe.className = "text-(length:--font-size-prompt,var(--text-sm))"; + style(probe); document.body.append(probe); try { return getComputedStyle(probe).fontSize; @@ -15,6 +14,17 @@ function composerFontSize(): string { } } +const composerFontSize = () => + renderedFontSize((probe) => { + // The composer editor's own size class. + probe.className = "text-(length:--font-size-prompt,var(--text-sm))"; + }); + +const touchComposerFontSize = () => + renderedFontSize((probe) => { + probe.style.fontSize = "var(--font-size-prompt-touch)"; + }); + describe("applyAppearanceFontVariables", () => { it("sizes the composer from the interface size until fonts are set separately", () => { const root = document.documentElement; @@ -31,9 +41,12 @@ describe("applyAppearanceFontVariables", () => { try { applyAppearanceFontVariables(root, { ...preferences, separate: false }); expect(composerFontSize()).toBe("17.5px"); + expect(touchComposerFontSize()).toBe("17.5px"); applyAppearanceFontVariables(root, { ...preferences, separate: true }); expect(composerFontSize()).toBe("14px"); + // iOS zooms into inputs under 16px. + expect(touchComposerFontSize()).toBe("16px"); } finally { if (style === null) root.removeAttribute("style"); else root.setAttribute("style", style); diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 8acc312321b8f..294cee900f07b 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -131,7 +131,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --chat-content-max-width: 46rem; --thread-details-panel-width: 17.5rem; /* The prompt on phones: iOS zooms into any input under 16px on focus. */ - --font-size-prompt-touch: max(var(--font-size-prompt, 1rem), 16px); + --font-size-prompt-touch: max(var(--font-size-prompt, var(--text-sm)), 16px); /* Horizontal gutter shared by the page header and the chat column, clear of the device's safe area. */ --workspace-gutter: 0.75rem; From 4c93adc5714be19d3ba4e8010e51e84a8cae3599 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 30 Oct 2026 06:37:03 +0000 Subject: [PATCH 3/3] fix(web): simplify the prompt font size fallback --- apps/web/src/appearanceFonts.ts | 14 ++++---------- apps/web/src/index.css | 2 +- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/apps/web/src/appearanceFonts.ts b/apps/web/src/appearanceFonts.ts index 659b9e9f0f9b0..e7c2867cb279f 100644 --- a/apps/web/src/appearanceFonts.ts +++ b/apps/web/src/appearanceFonts.ts @@ -66,7 +66,6 @@ export interface AppearanceFontPreferences { readonly sizePrompt: number; readonly sizeCode: number; readonly smoothing: boolean; - /** Without separate fonts the prompt follows the interface, as the terminal follows code. */ readonly separate: boolean; } @@ -90,15 +89,10 @@ export function applyAppearanceFontVariables( } root.style.fontSize = `${clampInterfaceFontSize(preferences.sizeInterface)}px`; - if (preferences.separate) { - root.style.setProperty( - "--font-size-prompt", - `${clampPromptFontSize(preferences.sizePrompt)}px`, - ); - } else { - // The composer then falls back to text-sm, which scales with the interface size. - root.style.removeProperty("--font-size-prompt"); - } + root.style.setProperty( + "--font-size-prompt", + preferences.separate ? `${clampPromptFontSize(preferences.sizePrompt)}px` : "var(--text-sm)", + ); 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/index.css b/apps/web/src/index.css index 294cee900f07b..8acc312321b8f 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -131,7 +131,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --chat-content-max-width: 46rem; --thread-details-panel-width: 17.5rem; /* The prompt on phones: iOS zooms into any input under 16px on focus. */ - --font-size-prompt-touch: max(var(--font-size-prompt, var(--text-sm)), 16px); + --font-size-prompt-touch: max(var(--font-size-prompt, 1rem), 16px); /* Horizontal gutter shared by the page header and the chat column, clear of the device's safe area. */ --workspace-gutter: 0.75rem;