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:
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user