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..9bdc5c2ef9864 --- /dev/null +++ b/apps/web/src/appearanceFonts.browser.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vite-plus/test"; + +import "~/index.css"; +import { applyAppearanceFontVariables } from "./appearanceFonts"; + +function renderedFontSize(style: (probe: HTMLElement) => void): string { + const probe = document.createElement("div"); + style(probe); + document.body.append(probe); + try { + return getComputedStyle(probe).fontSize; + } finally { + probe.remove(); + } +} + +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; + 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"); + 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/appearanceFonts.ts b/apps/web/src/appearanceFonts.ts index 48f8e5955e7e1..e7c2867cb279f 100644 --- a/apps/web/src/appearanceFonts.ts +++ b/apps/web/src/appearanceFonts.ts @@ -66,9 +66,10 @@ export interface AppearanceFontPreferences { readonly sizePrompt: number; readonly sizeCode: number; readonly smoothing: boolean; + 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 +77,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 +89,10 @@ export function applyAppearanceFontVariables( } root.style.fontSize = `${clampInterfaceFontSize(preferences.sizeInterface)}px`; - root.style.setProperty("--font-size-prompt", `${clampPromptFontSize(preferences.sizePrompt)}px`); + 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/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);