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 b9312858e334..9d77c475155c 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 000000000000..a8d0fa4f6d3e --- /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 48f8e5955e7e..659b9e9f0f9b 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 cd6d2f4fcb8d..5c172287131a 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 a8d0fa4f6d3e..9bdc5c2ef986 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 8acc312321b8..294cee900f07 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 659b9e9f0f9b..e7c2867cb279 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 294cee900f07..8acc312321b8 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;