Give readable content its own text size (#3637)

* feat(app): separate content typography from interface size

Readable prose benefits from its own scale without inflating navigation and controls. Preserve existing users’ rendered size during migration while giving fresh desktop installs a 15px content default.

* test(app): cover independent content typography

The appearance journey now verifies that changing interface size leaves content size intact. Timeline traversal yields when a test deliberately holds the requested page, so the helper no longer waits on its own gate.

* test(desktop): cover independent content typography

The desktop appearance regression follows the renamed Interface control and verifies it does not alter Content size.
This commit is contained in:
Mohamed Boudra
2026-08-21 12:35:23 +02:00
committed by GitHub
parent c032df5b3d
commit 602860625c
31 changed files with 419 additions and 99 deletions
@@ -16,13 +16,16 @@ import {
DEFAULT_APP_SETTINGS,
DEFAULT_CLIENT_SETTINGS,
DEFAULT_CODE_FONT_SIZE,
DEFAULT_CONTENT_FONT_SIZE,
DEFAULT_TERMINAL_SCROLLBACK_LINES,
DEFAULT_THEME_PREFERENCE,
DEFAULT_UI_BASE_FONT_SIZE,
MAX_CODE_FONT_SIZE,
MAX_CONTENT_FONT_SIZE,
MAX_TERMINAL_SCROLLBACK_LINES,
MAX_UI_BASE_FONT_SIZE,
MIN_CODE_FONT_SIZE,
MIN_CONTENT_FONT_SIZE,
MIN_TERMINAL_SCROLLBACK_LINES,
MIN_UI_BASE_FONT_SIZE,
loadAppSettingsFromStorage as loadAppSettingsFromStoragePure,
@@ -49,13 +52,16 @@ export {
DEFAULT_APP_SETTINGS,
DEFAULT_CLIENT_SETTINGS,
DEFAULT_CODE_FONT_SIZE,
DEFAULT_CONTENT_FONT_SIZE,
DEFAULT_TERMINAL_SCROLLBACK_LINES,
DEFAULT_THEME_PREFERENCE,
DEFAULT_UI_BASE_FONT_SIZE,
MAX_CODE_FONT_SIZE,
MAX_CONTENT_FONT_SIZE,
MAX_TERMINAL_SCROLLBACK_LINES,
MAX_UI_BASE_FONT_SIZE,
MIN_CODE_FONT_SIZE,
MIN_CONTENT_FONT_SIZE,
MIN_TERMINAL_SCROLLBACK_LINES,
MIN_UI_BASE_FONT_SIZE,
parseClampedFontSize,
@@ -6,8 +6,10 @@ import {
DEFAULT_APP_SETTINGS,
DEFAULT_CLIENT_SETTINGS,
DEFAULT_CODE_FONT_SIZE,
DEFAULT_CONTENT_FONT_SIZE,
DEFAULT_UI_BASE_FONT_SIZE,
defaultUiBaseFontSize,
defaultContentFontSize,
loadAppSettingsFromStorage,
loadSettingsFromStorage,
parseClampedFontSize,
@@ -231,10 +233,45 @@ describe("loadAppSettingsFromStorage", () => {
expect(result).toEqual({
...DEFAULT_CLIENT_SETTINGS,
theme: "dark",
contentFontSize: DEFAULT_UI_BASE_FONT_SIZE,
});
expect(deps.storage.entries.get(APP_SETTINGS_KEY)).toBe(JSON.stringify(result));
});
it("preserves the legacy key's explicit interface size as content size", async () => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
[LEGACY_SETTINGS_KEY]: JSON.stringify({ uiBaseFontSize: 17 }),
}),
});
const result = await loadAppSettingsFromStorage(deps);
expect(result.uiBaseFontSize).toBe(17);
expect(result.contentFontSize).toBe(17);
expect(JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "null")).toMatchObject({
uiBaseFontSize: 17,
contentFontSize: 17,
});
});
it("preserves the legacy interface scale as content size", async () => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
[LEGACY_SETTINGS_KEY]: JSON.stringify({ uiFontSize: 17 }),
}),
});
const result = await loadAppSettingsFromStorage(deps);
expect(result.uiBaseFontSize).toBe(15);
expect(result.contentFontSize).toBe(15);
expect(JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "null")).toMatchObject({
uiBaseFontSize: 15,
contentFontSize: 15,
});
});
it("loads a persisted explicit language", async () => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
@@ -292,6 +329,7 @@ describe("loadSettingsFromStorage", () => {
expect(result).toEqual({
...DEFAULT_APP_SETTINGS,
theme: "light",
contentFontSize: DEFAULT_UI_BASE_FONT_SIZE,
});
});
@@ -335,6 +373,7 @@ describe("loadSettingsFromStorage", () => {
expect(result).toEqual({
...DEFAULT_APP_SETTINGS,
theme: "light",
contentFontSize: DEFAULT_UI_BASE_FONT_SIZE,
manageBuiltInDaemon: false,
releaseChannel: "beta",
});
@@ -355,6 +394,7 @@ describe("loadSettingsFromStorage", () => {
expect(result).toEqual({
...DEFAULT_APP_SETTINGS,
theme: "light",
contentFontSize: DEFAULT_UI_BASE_FONT_SIZE,
});
});
});
@@ -399,6 +439,7 @@ describe("saveAppSettings", () => {
expect(JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "null")).toEqual({
...DEFAULT_CLIENT_SETTINGS,
theme: "light",
contentFontSize: DEFAULT_UI_BASE_FONT_SIZE,
toolCallDetailLevel: "overview",
});
});
@@ -451,6 +492,7 @@ describe("appearance settings", () => {
expect(result.uiFontFamily).toBe("");
expect(result.monoFontFamily).toBe("");
expect(result.uiBaseFontSize).toBe(DEFAULT_UI_BASE_FONT_SIZE);
expect(result.contentFontSize).toBe(DEFAULT_UI_BASE_FONT_SIZE);
expect(result.codeFontSize).toBe(DEFAULT_CODE_FONT_SIZE);
expect(result.syntaxTheme).toBe("one");
expect(result.toolCallDetailLevel).toBe("detailed");
@@ -504,6 +546,51 @@ describe("appearance settings", () => {
expect(defaultUiBaseFontSize(false)).toBe(14);
});
it("uses a 15px content default on mobile and web", () => {
expect(defaultContentFontSize(true)).toBe(15);
expect(defaultContentFontSize(false)).toBe(15);
expect(DEFAULT_CONTENT_FONT_SIZE).toBe(defaultContentFontSize(false));
});
it("derives and persists content size from an existing interface-size preference", async () => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({ uiBaseFontSize: 17 }),
}),
});
const result = await loadAppSettingsFromStorage(deps);
const persisted = JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "null");
expect(result.contentFontSize).toBe(17);
expect(persisted).toMatchObject({ uiBaseFontSize: 17, contentFontSize: 17 });
});
it("clamps the content font size into range and rejects non-numeric values", async () => {
const high = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({ contentFontSize: 999 }),
}),
});
expect((await loadAppSettingsFromStorage(high)).contentFontSize).toBe(21);
const low = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({ contentFontSize: 8 }),
}),
});
expect((await loadAppSettingsFromStorage(low)).contentFontSize).toBe(10);
const bogus = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({ contentFontSize: "abc" }),
}),
});
expect((await loadAppSettingsFromStorage(bogus)).contentFontSize).toBe(
DEFAULT_CONTENT_FONT_SIZE,
);
});
it.each([
{ legacySize: 16, baseSize: 14 },
{ legacySize: 17, baseSize: 15 },
+31 -1
View File
@@ -62,6 +62,13 @@ export function defaultUiBaseFontSize(native: boolean): number {
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 function defaultContentFontSize(native: boolean): number {
return native ? 15 : FONT_SIZE.content;
}
export const DEFAULT_CONTENT_FONT_SIZE = defaultContentFontSize(isNative);
export const MIN_CONTENT_FONT_SIZE = 10;
export const MAX_CONTENT_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
@@ -79,6 +86,7 @@ export interface AppSettings {
uiFontFamily: string; // "" = platform default UI stack
monoFontFamily: string; // "" = platform default mono stack
uiBaseFontSize: number; // clamped px, platform default 14 or 15
contentFontSize: number; // clamped px, default 15
codeFontSize: number; // clamped px, default 12
syntaxTheme: SyntaxThemeId; // default "one"
workspaceTitleSource: WorkspaceTitleSource;
@@ -126,6 +134,7 @@ const StoredAppSettingsSchema = z.strictObject({
uiFontFamily: z.string().optional(),
monoFontFamily: z.string().optional(),
uiBaseFontSize: z.union([z.number(), z.string()]).optional(),
contentFontSize: 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(),
@@ -160,6 +169,7 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
uiFontFamily: "",
monoFontFamily: "",
uiBaseFontSize: DEFAULT_UI_BASE_FONT_SIZE,
contentFontSize: DEFAULT_CONTENT_FONT_SIZE,
codeFontSize: DEFAULT_CODE_FONT_SIZE,
syntaxTheme: "one",
workspaceTitleSource: "title",
@@ -238,7 +248,9 @@ async function readAppSettings(
return {
settings: normalizeAppSettings(stored),
// COMPAT(uiFontSizeScale): persist the converted base size, remove after 2027-08-17.
needsWrite: stored.uiBaseFontSize === undefined && stored.uiFontSize !== undefined,
needsWrite:
(stored.uiBaseFontSize === undefined && stored.uiFontSize !== undefined) ||
stored.contentFontSize === undefined,
};
}
@@ -424,6 +436,17 @@ function pickAppSettings(stored: StoredAppSettings): Partial<AppSettings> {
result.uiBaseFontSize = Math.round((FONT_SIZE.base * legacyUiFontSize) / 16);
}
}
const contentFontSize = parseClampedFontSize(stored.contentFontSize, {
min: MIN_CONTENT_FONT_SIZE,
max: MAX_CONTENT_FONT_SIZE,
});
if (contentFontSize !== null) {
result.contentFontSize = contentFontSize;
} else if (stored.contentFontSize === undefined) {
// Existing content followed the interface ramp. Preserve that rendered size
// once, then persist the independent setting during the read migration.
result.contentFontSize = result.uiBaseFontSize ?? DEFAULT_UI_BASE_FONT_SIZE;
}
const codeFontSize = parseClampedFontSize(stored.codeFontSize, {
min: MIN_CODE_FONT_SIZE,
max: MAX_CODE_FONT_SIZE,
@@ -449,6 +472,13 @@ function pickAppSettingsFromLegacy(
if (legacy.theme === "dark" || legacy.theme === "light" || legacy.theme === "auto") {
result.theme = legacy.theme;
}
const legacyInterfaceSize = pickAppSettings(legacy).uiBaseFontSize;
if (legacyInterfaceSize !== undefined) {
result.uiBaseFontSize = legacyInterfaceSize;
}
// The legacy key rendered content on the interface ramp. Freeze that
// rendered value into the new independent preference during migration.
result.contentFontSize = legacyInterfaceSize ?? DEFAULT_UI_BASE_FONT_SIZE;
return result;
}