feat(app): align interface typography to base size

Treat the appearance setting as the rendered base size so the type ramp scales predictably. Native starts one pixel larger while web and desktop preserve the authored 14px base, with legacy values migrated on load.
This commit is contained in:
Mohamed Boudra
2026-08-17 22:55:46 +02:00
parent df3e646fbe
commit 5ec26f0c09
171 changed files with 709 additions and 645 deletions
+6 -6
View File
@@ -17,13 +17,13 @@ import {
DEFAULT_CLIENT_SETTINGS,
DEFAULT_CODE_FONT_SIZE,
DEFAULT_TERMINAL_SCROLLBACK_LINES,
DEFAULT_UI_FONT_SIZE,
DEFAULT_UI_BASE_FONT_SIZE,
MAX_CODE_FONT_SIZE,
MAX_TERMINAL_SCROLLBACK_LINES,
MAX_UI_FONT_SIZE,
MAX_UI_BASE_FONT_SIZE,
MIN_CODE_FONT_SIZE,
MIN_TERMINAL_SCROLLBACK_LINES,
MIN_UI_FONT_SIZE,
MIN_UI_BASE_FONT_SIZE,
loadAppSettingsFromStorage as loadAppSettingsFromStoragePure,
loadSettingsFromStorage as loadSettingsFromStoragePure,
normalizeAppSettings,
@@ -49,13 +49,13 @@ export {
DEFAULT_CLIENT_SETTINGS,
DEFAULT_CODE_FONT_SIZE,
DEFAULT_TERMINAL_SCROLLBACK_LINES,
DEFAULT_UI_FONT_SIZE,
DEFAULT_UI_BASE_FONT_SIZE,
MAX_CODE_FONT_SIZE,
MAX_TERMINAL_SCROLLBACK_LINES,
MAX_UI_FONT_SIZE,
MAX_UI_BASE_FONT_SIZE,
MIN_CODE_FONT_SIZE,
MIN_TERMINAL_SCROLLBACK_LINES,
MIN_UI_FONT_SIZE,
MIN_UI_BASE_FONT_SIZE,
parseClampedFontSize,
parseTerminalScrollbackLines,
sanitizeFontFamily,
@@ -6,7 +6,8 @@ import {
DEFAULT_APP_SETTINGS,
DEFAULT_CLIENT_SETTINGS,
DEFAULT_CODE_FONT_SIZE,
DEFAULT_UI_FONT_SIZE,
DEFAULT_UI_BASE_FONT_SIZE,
defaultUiBaseFontSize,
loadAppSettingsFromStorage,
loadSettingsFromStorage,
parseClampedFontSize,
@@ -387,7 +388,7 @@ describe("appearance settings", () => {
expect(result.uiFontFamily).toBe("");
expect(result.monoFontFamily).toBe("");
expect(result.uiFontSize).toBe(DEFAULT_UI_FONT_SIZE);
expect(result.uiBaseFontSize).toBe(DEFAULT_UI_BASE_FONT_SIZE);
expect(result.codeFontSize).toBe(DEFAULT_CODE_FONT_SIZE);
expect(result.syntaxTheme).toBe("one");
expect(result.toolCallDetailLevel).toBe("detailed");
@@ -436,27 +437,66 @@ describe("appearance settings", () => {
expect((await loadAppSettingsFromStorage(deps)).sidebarChecksDisplay).toBe("icon");
});
it("clamps the UI font size into range and rejects non-numeric values", async () => {
it("uses a 15px mobile base and a 14px web base", () => {
expect(defaultUiBaseFontSize(true)).toBe(15);
expect(defaultUiBaseFontSize(false)).toBe(14);
});
it.each([
{ legacySize: 16, baseSize: 14 },
{ legacySize: 17, baseSize: 15 },
{ legacySize: 18, baseSize: 16 },
])(
"migrates legacy interface size $legacySize to base size $baseSize",
async ({ legacySize, baseSize }) => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({ uiFontSize: legacySize }),
}),
});
const result = await loadAppSettingsFromStorage(deps);
const persisted = JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "null");
expect(result.uiBaseFontSize).toBe(baseSize);
expect(persisted).toMatchObject({ uiBaseFontSize: baseSize });
expect(persisted).not.toHaveProperty("uiFontSize");
},
);
it("lets an explicit base size win over the legacy interface scale", async () => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({ uiFontSize: 999 }),
[APP_SETTINGS_KEY]: JSON.stringify({ uiBaseFontSize: 16, uiFontSize: 17 }),
}),
});
expect((await loadAppSettingsFromStorage(deps)).uiFontSize).toBe(24);
expect((await loadAppSettingsFromStorage(deps)).uiBaseFontSize).toBe(16);
});
it("clamps the UI base font size into range and rejects non-numeric values", async () => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({ uiBaseFontSize: 999 }),
}),
});
expect((await loadAppSettingsFromStorage(deps)).uiBaseFontSize).toBe(21);
const low = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({ uiFontSize: 8 }),
[APP_SETTINGS_KEY]: JSON.stringify({ uiBaseFontSize: 8 }),
}),
});
expect((await loadAppSettingsFromStorage(low)).uiFontSize).toBe(11);
expect((await loadAppSettingsFromStorage(low)).uiBaseFontSize).toBe(10);
const bogus = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({ uiFontSize: "abc" }),
[APP_SETTINGS_KEY]: JSON.stringify({ uiBaseFontSize: "abc" }),
}),
});
expect((await loadAppSettingsFromStorage(bogus)).uiFontSize).toBe(DEFAULT_UI_FONT_SIZE);
expect((await loadAppSettingsFromStorage(bogus)).uiBaseFontSize).toBe(
DEFAULT_UI_BASE_FONT_SIZE,
);
});
it("clamps the code font size into range and rejects non-numeric values", async () => {
+31 -12
View File
@@ -14,7 +14,8 @@ import {
parseSidebarRowItems,
type SidebarRowItems,
} from "@/components/sidebar/display-preferences/row-items";
import { THEME_OPTIONS, type ThemePreference } from "@/styles/theme";
import { isNative } from "@/constants/platform";
import { FONT_SIZE, THEME_OPTIONS, type ThemePreference } from "@/styles/theme";
import { z } from "zod";
import { readValidatedJson } from "@/storage/validated-storage";
@@ -43,9 +44,13 @@ const VALID_TOOL_CALL_DETAIL_LEVELS = new Set<ToolCallDetailLevel>(["overview",
export const DEFAULT_TERMINAL_SCROLLBACK_LINES = 10_000;
export const MIN_TERMINAL_SCROLLBACK_LINES = 0;
export const MAX_TERMINAL_SCROLLBACK_LINES = 1_000_000;
export const DEFAULT_UI_FONT_SIZE = 16; // == FONT_SIZE.base
export const MIN_UI_FONT_SIZE = 11;
export const MAX_UI_FONT_SIZE = 24;
export function defaultUiBaseFontSize(native: boolean): number {
return native ? 15 : FONT_SIZE.base;
}
export const DEFAULT_UI_BASE_FONT_SIZE = defaultUiBaseFontSize(isNative);
export const MIN_UI_BASE_FONT_SIZE = 10;
export const MAX_UI_BASE_FONT_SIZE = 21;
export const DEFAULT_CODE_FONT_SIZE = 12; // == FONT_SIZE.code
export const MIN_CODE_FONT_SIZE = 9;
export const MAX_CODE_FONT_SIZE = 22; // line-height 1.5×22=33 stays safe
@@ -60,7 +65,7 @@ export interface AppSettings {
useLegacyTerminalRenderer: boolean;
uiFontFamily: string; // "" = platform default UI stack
monoFontFamily: string; // "" = platform default mono stack
uiFontSize: number; // clamped px, default 16
uiBaseFontSize: number; // clamped px, platform default 14 or 15
codeFontSize: number; // clamped px, default 12
syntaxTheme: SyntaxThemeId; // default "one"
workspaceTitleSource: WorkspaceTitleSource;
@@ -99,6 +104,8 @@ const StoredAppSettingsSchema = z.strictObject({
useLegacyTerminalRenderer: z.boolean().optional(),
uiFontFamily: z.string().optional(),
monoFontFamily: z.string().optional(),
uiBaseFontSize: z.union([z.number(), z.string()]).optional(),
// COMPAT(uiFontSizeScale): replaced by the literal base size in v0.4, remove after 2027-08-17.
uiFontSize: z.union([z.number(), z.string()]).optional(),
codeFontSize: z.union([z.number(), z.string()]).optional(),
syntaxTheme: z.string().refine(isSyntaxThemeId).optional(),
@@ -129,7 +136,7 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
useLegacyTerminalRenderer: false,
uiFontFamily: "",
monoFontFamily: "",
uiFontSize: DEFAULT_UI_FONT_SIZE,
uiBaseFontSize: DEFAULT_UI_BASE_FONT_SIZE,
codeFontSize: DEFAULT_CODE_FONT_SIZE,
syntaxTheme: "one",
workspaceTitleSource: "title",
@@ -186,7 +193,11 @@ export async function loadAppSettingsFromStorage(deps: SettingsDeps): Promise<Ap
try {
const stored = await readValidatedJson(deps.storage, APP_SETTINGS_KEY, StoredAppSettingsSchema);
if (stored) {
return normalizeAppSettings(stored);
const normalized = normalizeAppSettings(stored);
if (stored.uiBaseFontSize === undefined && stored.uiFontSize !== undefined) {
await deps.storage.setItem(APP_SETTINGS_KEY, JSON.stringify(normalized));
}
return normalized;
}
const legacyStored = await readValidatedJson(
@@ -354,12 +365,20 @@ function pickAppSettings(stored: StoredAppSettings): Partial<AppSettings> {
if (monoFontFamily !== null) {
result.monoFontFamily = monoFontFamily;
}
const uiFontSize = parseClampedFontSize(stored.uiFontSize, {
min: MIN_UI_FONT_SIZE,
max: MAX_UI_FONT_SIZE,
const uiBaseFontSize = parseClampedFontSize(stored.uiBaseFontSize, {
min: MIN_UI_BASE_FONT_SIZE,
max: MAX_UI_BASE_FONT_SIZE,
});
if (uiFontSize !== null) {
result.uiFontSize = uiFontSize;
if (uiBaseFontSize !== null) {
result.uiBaseFontSize = uiBaseFontSize;
} else {
const legacyUiFontSize = parseClampedFontSize(stored.uiFontSize, {
min: 11,
max: 24,
});
if (legacyUiFontSize !== null) {
result.uiBaseFontSize = Math.round((FONT_SIZE.base * legacyUiFontSize) / 16);
}
}
const codeFontSize = parseClampedFontSize(stored.codeFontSize, {
min: MIN_CODE_FONT_SIZE,