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:
@@ -45,7 +45,7 @@ test("keeps the selected workspace visible in Pure black", async ({ page }, test
|
||||
}
|
||||
});
|
||||
|
||||
test("applies the base font size to settings text", async ({ page }) => {
|
||||
test("applies the interface font size to settings text", async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("@paseo:app-settings", JSON.stringify({ uiBaseFontSize: 21 }));
|
||||
});
|
||||
@@ -56,11 +56,14 @@ test("applies the base font size to settings text", async ({ page }) => {
|
||||
const sectionTitle = page.getByText("Theme", { exact: true }).first();
|
||||
await expect(sectionTitle).toHaveCSS("font-size", "18px");
|
||||
|
||||
const fontSizeInput = page.getByLabel("Base font size");
|
||||
await expect(fontSizeInput).toHaveValue("21");
|
||||
await fontSizeInput.fill("12");
|
||||
await fontSizeInput.press("Tab");
|
||||
const interfaceSizeInput = page.getByLabel("Interface font size");
|
||||
const contentSizeInput = page.getByLabel("Content font size");
|
||||
await expect(interfaceSizeInput).toHaveValue("21");
|
||||
await expect(contentSizeInput).toHaveValue("21");
|
||||
await interfaceSizeInput.fill("12");
|
||||
await interfaceSizeInput.press("Tab");
|
||||
|
||||
await expect(fontSizeInput).toHaveValue("12");
|
||||
await expect(interfaceSizeInput).toHaveValue("12");
|
||||
await expect(contentSizeInput).toHaveValue("21");
|
||||
await expect(sectionTitle).toHaveCSS("font-size", "10px");
|
||||
});
|
||||
|
||||
@@ -545,15 +545,22 @@ async function returnTimelineToSettledHistoryStart(
|
||||
page: Page,
|
||||
moveUp: () => Promise<unknown>,
|
||||
): Promise<void> {
|
||||
const loadingSpinner = page.getByTestId("load-older-history-spinner");
|
||||
// A caller may only start a new history traversal after its prior page has settled.
|
||||
await expect(loadingSpinner).toBeHidden();
|
||||
let previous = await readTimelineViewport(page);
|
||||
while (true) {
|
||||
await moveUp();
|
||||
await waitForTimelineGeometryToSettle(page);
|
||||
let viewport = await readTimelineViewport(page);
|
||||
// Tests that hold the response need to observe the requested page before releasing it.
|
||||
if (await loadingSpinner.isVisible()) {
|
||||
return;
|
||||
}
|
||||
if (viewport.scrollTop <= HISTORY_START_THRESHOLD_PX) {
|
||||
return;
|
||||
}
|
||||
await expect(page.getByTestId("load-older-history-spinner")).toBeHidden();
|
||||
await expect(loadingSpinner).toBeHidden();
|
||||
await waitForTimelineGeometryToSettle(page);
|
||||
viewport = await readTimelineViewport(page);
|
||||
expect(
|
||||
|
||||
@@ -27,6 +27,7 @@ interface FakeTheme {
|
||||
fontFamily: { ui: string; mono: string };
|
||||
fontSize: {
|
||||
code: number;
|
||||
content: number;
|
||||
sm: number;
|
||||
base: number;
|
||||
lg: number;
|
||||
@@ -45,6 +46,7 @@ function makeFakeTheme(): FakeTheme {
|
||||
fontFamily: { ui: "seed-ui-stack", mono: "seed-mono-stack" },
|
||||
fontSize: {
|
||||
code: 12,
|
||||
content: 15,
|
||||
sm: 12,
|
||||
base: 14,
|
||||
lg: 16,
|
||||
@@ -63,6 +65,7 @@ function makeInput(overrides: Partial<AppearanceInput> = {}): AppearanceInput {
|
||||
uiFontFamily: "",
|
||||
monoFontFamily: "",
|
||||
uiBaseFontSize: 14,
|
||||
contentFontSize: 15,
|
||||
codeFontSize: 12,
|
||||
syntaxTheme: "one",
|
||||
...overrides,
|
||||
@@ -131,6 +134,7 @@ describe("applyAppearance", () => {
|
||||
const alreadyScaled = makeFakeTheme();
|
||||
alreadyScaled.fontSize = {
|
||||
code: 4,
|
||||
content: 4,
|
||||
sm: 4,
|
||||
base: 4,
|
||||
lg: 4,
|
||||
@@ -155,6 +159,15 @@ describe("applyAppearance", () => {
|
||||
expect(fontSize.code).toBe(10);
|
||||
});
|
||||
|
||||
it("sets content size independently of the interface and code sizes", () => {
|
||||
applyAppearance(makeInput({ uiBaseFontSize: 14, contentFontSize: 19, codeFontSize: 10 }));
|
||||
|
||||
const { fontSize } = runCapturedUpdater();
|
||||
expect(fontSize.base).toBe(14);
|
||||
expect(fontSize.content).toBe(19);
|
||||
expect(fontSize.code).toBe(10);
|
||||
});
|
||||
|
||||
it("sets fontSize.code to codeFontSize regardless of the UI font size", () => {
|
||||
applyAppearance(makeInput({ uiBaseFontSize: 14, codeFontSize: 18 }));
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface AppearanceInput {
|
||||
uiFontFamily: string; // "" -> default stack
|
||||
monoFontFamily: string; // "" -> default stack
|
||||
uiBaseFontSize: number; // already clamped
|
||||
contentFontSize: number; // already clamped
|
||||
codeFontSize: number; // already clamped
|
||||
syntaxTheme: SyntaxThemeId;
|
||||
}
|
||||
@@ -28,7 +29,11 @@ export interface AppearanceInput {
|
||||
* `code` is set absolutely to `codeSize`, never scaled by the UI factor — a separate
|
||||
* control on a separate semantic axis (mono/diff text).
|
||||
*/
|
||||
function scaleFontSize(uiBaseSize: number, codeSize: number): Theme["fontSize"] {
|
||||
function scaleFontSize(
|
||||
uiBaseSize: number,
|
||||
contentSize: number,
|
||||
codeSize: number,
|
||||
): Theme["fontSize"] {
|
||||
const r = uiBaseSize / FONT_SIZE.base;
|
||||
return {
|
||||
sm: Math.round(FONT_SIZE.sm * r),
|
||||
@@ -38,6 +43,7 @@ function scaleFontSize(uiBaseSize: number, codeSize: number): Theme["fontSize"]
|
||||
"2xl": Math.round(FONT_SIZE["2xl"] * r),
|
||||
"3xl": Math.round(FONT_SIZE["3xl"] * r),
|
||||
"4xl": Math.round(FONT_SIZE["4xl"] * r),
|
||||
content: contentSize, // absolute, NOT scaled
|
||||
code: codeSize, // absolute, NOT scaled
|
||||
};
|
||||
}
|
||||
@@ -68,7 +74,11 @@ export function applyAppearance(input: AppearanceInput): void {
|
||||
for (const key of themeKeys) {
|
||||
UnistylesRuntime.updateTheme(key, (t) => {
|
||||
const fontFamily = { ui, mono };
|
||||
const fontSize = scaleFontSize(input.uiBaseFontSize, input.codeFontSize);
|
||||
const fontSize = scaleFontSize(
|
||||
input.uiBaseFontSize,
|
||||
input.contentFontSize,
|
||||
input.codeFontSize,
|
||||
);
|
||||
const lineHeight = { ...t.lineHeight, diff: diffLineHeight };
|
||||
if (t.colorScheme === "light") {
|
||||
return {
|
||||
|
||||
@@ -57,6 +57,7 @@ export function AppearanceProvider({ children }: { children: ReactNode }) {
|
||||
uiFontFamily: settings.uiFontFamily,
|
||||
monoFontFamily: settings.monoFontFamily,
|
||||
uiBaseFontSize: settings.uiBaseFontSize,
|
||||
contentFontSize: settings.contentFontSize,
|
||||
codeFontSize: settings.codeFontSize,
|
||||
syntaxTheme: settings.syntaxTheme,
|
||||
});
|
||||
@@ -67,6 +68,7 @@ export function AppearanceProvider({ children }: { children: ReactNode }) {
|
||||
settings.uiFontFamily,
|
||||
settings.monoFontFamily,
|
||||
settings.uiBaseFontSize,
|
||||
settings.contentFontSize,
|
||||
settings.codeFontSize,
|
||||
settings.syntaxTheme,
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { darkTheme } from "@/styles/theme";
|
||||
import { appearanceStyleBoundaryKey } from "./appearance-style-boundary";
|
||||
|
||||
describe("appearanceStyleBoundaryKey", () => {
|
||||
it("changes when content size changes without any other appearance token changing", () => {
|
||||
const contentOnlyChange = {
|
||||
...darkTheme,
|
||||
fontSize: { ...darkTheme.fontSize, content: darkTheme.fontSize.content + 1 },
|
||||
};
|
||||
|
||||
expect(appearanceStyleBoundaryKey(contentOnlyChange)).not.toBe(
|
||||
appearanceStyleBoundaryKey(darkTheme),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -11,8 +11,8 @@ function AppearanceStyleBoundaryBase({ appearanceKey, children }: AppearanceStyl
|
||||
return <Fragment key={appearanceKey}>{children}</Fragment>;
|
||||
}
|
||||
|
||||
const appearanceStyleBoundaryMapping = (theme: Theme): Partial<AppearanceStyleBoundaryProps> => ({
|
||||
appearanceKey: [
|
||||
export function appearanceStyleBoundaryKey(theme: Theme): string {
|
||||
return [
|
||||
theme.fontFamily.ui,
|
||||
theme.fontFamily.mono,
|
||||
theme.fontSize.sm,
|
||||
@@ -22,6 +22,7 @@ const appearanceStyleBoundaryMapping = (theme: Theme): Partial<AppearanceStyleBo
|
||||
theme.fontSize["2xl"],
|
||||
theme.fontSize["3xl"],
|
||||
theme.fontSize["4xl"],
|
||||
theme.fontSize.content,
|
||||
theme.fontSize.code,
|
||||
theme.lineHeight.diff,
|
||||
theme.colors.foreground,
|
||||
@@ -51,8 +52,12 @@ const appearanceStyleBoundaryMapping = (theme: Theme): Partial<AppearanceStyleBo
|
||||
theme.colors.syntax.meta,
|
||||
theme.colors.syntax.heading,
|
||||
theme.colors.syntax.link,
|
||||
].join("\u0000"),
|
||||
});
|
||||
].join("\u0000");
|
||||
}
|
||||
|
||||
function appearanceStyleBoundaryMapping(theme: Theme): Partial<AppearanceStyleBoundaryProps> {
|
||||
return { appearanceKey: appearanceStyleBoundaryKey(theme) };
|
||||
}
|
||||
|
||||
const ThemedAppearanceStyleBoundary = withUnistyles(AppearanceStyleBoundaryBase);
|
||||
|
||||
|
||||
@@ -354,9 +354,12 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
},
|
||||
text: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontSize: theme.fontSize.content,
|
||||
...(isWeb
|
||||
? { lineHeight: Math.round(theme.fontSize.base * 1.4), overflowWrap: "anywhere" as const }
|
||||
? {
|
||||
lineHeight: Math.round(theme.fontSize.content * 1.4),
|
||||
overflowWrap: "anywhere" as const,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
imagePreviewContainer: {
|
||||
@@ -1958,8 +1961,8 @@ const speakMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
},
|
||||
text: {
|
||||
fontFamily: theme.fontFamily.ui,
|
||||
fontSize: theme.fontSize.base,
|
||||
lineHeight: Math.round(theme.fontSize.base * 1.4),
|
||||
fontSize: theme.fontSize.content,
|
||||
lineHeight: Math.round(theme.fontSize.content * 1.4),
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -1980,9 +1980,9 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
textInput: {
|
||||
width: "100%",
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontSize: theme.fontSize.content,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
lineHeight: theme.fontSize.base * 1.4,
|
||||
lineHeight: theme.fontSize.content * 1.4,
|
||||
...(isWeb
|
||||
? ({
|
||||
outlineStyle: "none",
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -2047,13 +2047,18 @@ export const ar: TranslationResources = {
|
||||
interfaceFont: "خط الواجهة",
|
||||
interfaceFontHint: "تستخدم عبر التطبيق. اتركه فارغًا للإعداد الافتراضي للنظام",
|
||||
interfaceFontAccessibility: "عائلة خطوط الواجهة",
|
||||
baseSize: "الحجم الأساسي",
|
||||
baseSizeAccessibility: "حجم الخط الأساسي",
|
||||
interfaceSize: "حجم الواجهة",
|
||||
interfaceSizeHint: "يُستخدم للتنقل وعناصر التحكم والتسميات",
|
||||
interfaceSizeAccessibility: "حجم خط الواجهة",
|
||||
contentSize: "حجم المحتوى",
|
||||
contentSizeHint: "يُستخدم لنص الدردشة وملفات Markdown المعروضة",
|
||||
contentSizeAccessibility: "حجم خط المحتوى",
|
||||
codeFont: "خط الكود",
|
||||
codeFontHint:
|
||||
"تستخدم في الكود والاختلافات والمخرجات الطرفية. اتركه فارغًا للإعداد الافتراضي للنظام",
|
||||
codeFontAccessibility: "عائلة خطوط الكود",
|
||||
codeSize: "حجم الكود",
|
||||
codeSizeHint: "يُستخدم للكود والفروقات ومخرجات الطرفية",
|
||||
codeSizeAccessibility: "حجم خط الكود",
|
||||
},
|
||||
syntax: {
|
||||
@@ -2061,7 +2066,9 @@ export const ar: TranslationResources = {
|
||||
highlightTheme: "تسليط الضوء على الموضوع",
|
||||
highlightThemeHint: "ألوان التعليمات البرمجية، مستقلة عن سمة التطبيق",
|
||||
highlightThemeAccessibility: "تسليط الضوء على الموضوع:{{value}}",
|
||||
previewAccessibility: "معاينة مباشرة لموضوع بناء الجملة وخط التعليمات البرمجية",
|
||||
previewAccessibility:
|
||||
"معاينة مباشرة لطباعة المحتوى وموضوع بناء الجملة وخط التعليمات البرمجية",
|
||||
previewContent: "معاينة المحتوى والرمز",
|
||||
},
|
||||
},
|
||||
shortcuts: {
|
||||
|
||||
@@ -2111,13 +2111,18 @@ export const en = {
|
||||
interfaceFont: "Interface font",
|
||||
interfaceFontHint: "Used across the app. Leave empty for the system default",
|
||||
interfaceFontAccessibility: "Interface font family",
|
||||
baseSize: "Base size",
|
||||
baseSizeAccessibility: "Base font size",
|
||||
interfaceSize: "Interface size",
|
||||
interfaceSizeHint: "Used for navigation, controls, and labels",
|
||||
interfaceSizeAccessibility: "Interface font size",
|
||||
contentSize: "Content size",
|
||||
contentSizeHint: "Used for chat text and rendered Markdown",
|
||||
contentSizeAccessibility: "Content font size",
|
||||
codeFont: "Code font",
|
||||
codeFontHint:
|
||||
"Used in code, diffs, and the terminal output. Leave empty for the system default",
|
||||
codeFontAccessibility: "Code font family",
|
||||
codeSize: "Code size",
|
||||
codeSizeHint: "Used for code, diffs, and terminal output",
|
||||
codeSizeAccessibility: "Code font size",
|
||||
},
|
||||
syntax: {
|
||||
@@ -2125,7 +2130,8 @@ export const en = {
|
||||
highlightTheme: "Highlight theme",
|
||||
highlightThemeHint: "Colors for code, independent of the app theme",
|
||||
highlightThemeAccessibility: "Highlight theme: {{value}}",
|
||||
previewAccessibility: "Live preview of the syntax theme and code font",
|
||||
previewAccessibility: "Live preview of content typography, syntax theme, and code font",
|
||||
previewContent: "Readable content and code preview",
|
||||
},
|
||||
},
|
||||
shortcuts: {
|
||||
|
||||
@@ -2098,13 +2098,18 @@ export const es: TranslationResources = {
|
||||
interfaceFontHint:
|
||||
"Utilizado en toda la aplicación. Déjelo vacío para el valor predeterminado del sistema.",
|
||||
interfaceFontAccessibility: "Familia de fuentes de interfaz",
|
||||
baseSize: "Tamaño base",
|
||||
baseSizeAccessibility: "Tamaño base de la fuente",
|
||||
interfaceSize: "Tamaño de interfaz",
|
||||
interfaceSizeHint: "Se usa en la navegación, los controles y las etiquetas",
|
||||
interfaceSizeAccessibility: "Tamaño de fuente de interfaz",
|
||||
contentSize: "Tamaño de contenido",
|
||||
contentSizeHint: "Se usa en el texto del chat y el Markdown renderizado",
|
||||
contentSizeAccessibility: "Tamaño de fuente de contenido",
|
||||
codeFont: "Fuente de código",
|
||||
codeFontHint:
|
||||
"Se utiliza en código, diferencias y salida del terminal. Déjelo vacío para el valor predeterminado del sistema.",
|
||||
codeFontAccessibility: "Familia de fuentes de código",
|
||||
codeSize: "Tamaño del código",
|
||||
codeSizeHint: "Se usa en código, diferencias y la salida del terminal",
|
||||
codeSizeAccessibility: "Tamaño de fuente del código",
|
||||
},
|
||||
syntax: {
|
||||
@@ -2112,7 +2117,9 @@ export const es: TranslationResources = {
|
||||
highlightTheme: "Tema destacado",
|
||||
highlightThemeHint: "Colores para el código, independientemente del tema de la aplicación.",
|
||||
highlightThemeAccessibility: "Tema destacado:{{value}}",
|
||||
previewAccessibility: "Vista previa en vivo del tema de sintaxis y fuente del código",
|
||||
previewAccessibility:
|
||||
"Vista previa en vivo de la tipografía de contenido, el tema de sintaxis y la fuente del código",
|
||||
previewContent: "Vista previa de contenido y código",
|
||||
},
|
||||
},
|
||||
shortcuts: {
|
||||
|
||||
@@ -2102,13 +2102,18 @@ export const fr: TranslationResources = {
|
||||
interfaceFontHint:
|
||||
"Utilisé dans toute l'application. Laisser vide pour la valeur par défaut du système",
|
||||
interfaceFontAccessibility: "Famille de polices d'interface",
|
||||
baseSize: "Taille de base",
|
||||
baseSizeAccessibility: "Taille de base de la police",
|
||||
interfaceSize: "Taille de l'interface",
|
||||
interfaceSizeHint: "Utilisée pour la navigation, les contrôles et les libellés",
|
||||
interfaceSizeAccessibility: "Taille de police de l'interface",
|
||||
contentSize: "Taille du contenu",
|
||||
contentSizeHint: "Utilisée pour le texte du chat et le Markdown rendu",
|
||||
contentSizeAccessibility: "Taille de police du contenu",
|
||||
codeFont: "Police de code",
|
||||
codeFontHint:
|
||||
"Utilisé dans le code, les différences et la sortie du terminal. Laisser vide pour la valeur par défaut du système",
|
||||
codeFontAccessibility: "Famille de polices de code",
|
||||
codeSize: "Taille du code",
|
||||
codeSizeHint: "Utilisée pour le code, les diffs et la sortie du terminal",
|
||||
codeSizeAccessibility: "Taille de la police du code",
|
||||
},
|
||||
syntax: {
|
||||
@@ -2116,7 +2121,9 @@ export const fr: TranslationResources = {
|
||||
highlightTheme: "Thème de surbrillance",
|
||||
highlightThemeHint: "Couleurs du code, indépendamment du thème de l'application",
|
||||
highlightThemeAccessibility: "Thème phare:{{value}}",
|
||||
previewAccessibility: "Aperçu en direct du thème de syntaxe et de la police de code",
|
||||
previewAccessibility:
|
||||
"Aperçu en direct de la typographie du contenu, du thème de syntaxe et de la police de code",
|
||||
previewContent: "Aperçu du contenu et du code",
|
||||
},
|
||||
},
|
||||
shortcuts: {
|
||||
|
||||
@@ -2065,13 +2065,18 @@ export const ja: TranslationResources = {
|
||||
interfaceFontHint:
|
||||
"アプリ全体で使用されます。システムデフォルトにするには空のままにしてください",
|
||||
interfaceFontAccessibility: "インターフェースフォントファミリー",
|
||||
baseSize: "基本サイズ",
|
||||
baseSizeAccessibility: "基本フォントサイズ",
|
||||
interfaceSize: "インターフェースサイズ",
|
||||
interfaceSizeHint: "ナビゲーション、コントロール、ラベルに使用されます",
|
||||
interfaceSizeAccessibility: "インターフェースのフォントサイズ",
|
||||
contentSize: "コンテンツサイズ",
|
||||
contentSizeHint: "チャットテキストとレンダリングされた Markdown に使用されます",
|
||||
contentSizeAccessibility: "コンテンツのフォントサイズ",
|
||||
codeFont: "コードフォント",
|
||||
codeFontHint:
|
||||
"コード、差分、ターミナル出力で使用されます。システムデフォルトにするには空のままにしてください",
|
||||
codeFontAccessibility: "コードフォントファミリー",
|
||||
codeSize: "コードサイズ",
|
||||
codeSizeHint: "コード、差分、ターミナル出力に使用されます",
|
||||
codeSizeAccessibility: "コードフォントサイズ",
|
||||
},
|
||||
syntax: {
|
||||
@@ -2079,7 +2084,9 @@ export const ja: TranslationResources = {
|
||||
highlightTheme: "ハイライトテーマ",
|
||||
highlightThemeHint: "コード用の色、アプリのテーマとは独立しています",
|
||||
highlightThemeAccessibility: "ハイライトテーマ: {{value}}",
|
||||
previewAccessibility: "構文ハイライトテーマとコードフォントのライブプレビュー",
|
||||
previewAccessibility:
|
||||
"コンテンツの文字組み、構文ハイライトテーマ、コードフォントのライブプレビュー",
|
||||
previewContent: "コンテンツとコードのプレビュー",
|
||||
},
|
||||
},
|
||||
shortcuts: {
|
||||
|
||||
@@ -2058,13 +2058,18 @@ export const ko: TranslationResources = {
|
||||
interfaceFont: "인터페이스 글꼴",
|
||||
interfaceFontHint: "앱 전체에 사용됩니다. 시스템 기본값을 사용하려면 비워 두세요",
|
||||
interfaceFontAccessibility: "인터페이스 글꼴 패밀리",
|
||||
baseSize: "기본 크기",
|
||||
baseSizeAccessibility: "기본 글꼴 크기",
|
||||
interfaceSize: "인터페이스 크기",
|
||||
interfaceSizeHint: "탐색, 컨트롤 및 레이블에 사용됩니다",
|
||||
interfaceSizeAccessibility: "인터페이스 글꼴 크기",
|
||||
contentSize: "콘텐츠 크기",
|
||||
contentSizeHint: "채팅 텍스트와 렌더링된 Markdown에 사용됩니다",
|
||||
contentSizeAccessibility: "콘텐츠 글꼴 크기",
|
||||
codeFont: "코드 글꼴",
|
||||
codeFontHint:
|
||||
"코드, diff, 터미널 출력에 사용됩니다. 시스템 기본값을 사용하려면 비워 두세요",
|
||||
codeFontAccessibility: "코드 글꼴 패밀리",
|
||||
codeSize: "코드 크기",
|
||||
codeSizeHint: "코드, diff 및 터미널 출력에 사용됩니다",
|
||||
codeSizeAccessibility: "코드 글꼴 크기",
|
||||
},
|
||||
syntax: {
|
||||
@@ -2072,7 +2077,8 @@ export const ko: TranslationResources = {
|
||||
highlightTheme: "강조 테마",
|
||||
highlightThemeHint: "앱 테마와 독립적인 코드 색상",
|
||||
highlightThemeAccessibility: "강조 테마: {{value}}",
|
||||
previewAccessibility: "구문 테마와 코드 글꼴의 실시간 미리보기",
|
||||
previewAccessibility: "콘텐츠 타이포그래피, 구문 테마, 코드 글꼴의 실시간 미리보기",
|
||||
previewContent: "콘텐츠 및 코드 미리보기",
|
||||
},
|
||||
},
|
||||
shortcuts: {
|
||||
|
||||
@@ -2081,13 +2081,18 @@ export const ptBR: TranslationResources = {
|
||||
interfaceFont: "Fonte da interface",
|
||||
interfaceFontHint: "Usada em todo o app. Deixe vazio para usar o padrão do sistema",
|
||||
interfaceFontAccessibility: "Família da fonte da interface",
|
||||
baseSize: "Tamanho base",
|
||||
baseSizeAccessibility: "Tamanho base da fonte",
|
||||
interfaceSize: "Tamanho da interface",
|
||||
interfaceSizeHint: "Usado na navegação, nos controles e nos rótulos",
|
||||
interfaceSizeAccessibility: "Tamanho da fonte da interface",
|
||||
contentSize: "Tamanho do conteúdo",
|
||||
contentSizeHint: "Usado no texto do chat e no Markdown renderizado",
|
||||
contentSizeAccessibility: "Tamanho da fonte do conteúdo",
|
||||
codeFont: "Fonte de código",
|
||||
codeFontHint:
|
||||
"Usada em código, diffs e saída do terminal. Deixe vazio para usar o padrão do sistema",
|
||||
codeFontAccessibility: "Família da fonte de código",
|
||||
codeSize: "Tamanho do código",
|
||||
codeSizeHint: "Usado em código, diffs e saída do terminal",
|
||||
codeSizeAccessibility: "Tamanho da fonte de código",
|
||||
},
|
||||
syntax: {
|
||||
@@ -2095,7 +2100,9 @@ export const ptBR: TranslationResources = {
|
||||
highlightTheme: "Tema de destaque",
|
||||
highlightThemeHint: "Cores para código, independentes do tema do app",
|
||||
highlightThemeAccessibility: "Tema de destaque: {{value}}",
|
||||
previewAccessibility: "Prévia ao vivo do tema de sintaxe e da fonte de código",
|
||||
previewAccessibility:
|
||||
"Prévia ao vivo da tipografia de conteúdo, do tema de sintaxe e da fonte de código",
|
||||
previewContent: "Prévia de conteúdo e código",
|
||||
},
|
||||
},
|
||||
shortcuts: {
|
||||
|
||||
@@ -2084,13 +2084,18 @@ export const ru: TranslationResources = {
|
||||
interfaceFontHint:
|
||||
"Используется во всём приложении. Оставьте поле пустым, чтобы использовать системный шрифт.",
|
||||
interfaceFontAccessibility: "Семейство интерфейсных шрифтов",
|
||||
baseSize: "Базовый размер",
|
||||
baseSizeAccessibility: "Базовый размер шрифта",
|
||||
interfaceSize: "Размер интерфейса",
|
||||
interfaceSizeHint: "Используется для навигации, элементов управления и подписей",
|
||||
interfaceSizeAccessibility: "Размер шрифта интерфейса",
|
||||
contentSize: "Размер содержимого",
|
||||
contentSizeHint: "Используется для текста чата и отображаемого Markdown",
|
||||
contentSizeAccessibility: "Размер шрифта содержимого",
|
||||
codeFont: "Шрифт кода",
|
||||
codeFontHint:
|
||||
"Используется для кода, диффов и вывода терминала. Оставьте поле пустым, чтобы использовать системный шрифт.",
|
||||
codeFontAccessibility: "Семейство шрифтов кода",
|
||||
codeSize: "Размер кода",
|
||||
codeSizeHint: "Используется для кода, диффов и вывода терминала",
|
||||
codeSizeAccessibility: "Размер шрифта кода",
|
||||
},
|
||||
syntax: {
|
||||
@@ -2099,7 +2104,8 @@ export const ru: TranslationResources = {
|
||||
highlightThemeHint: "Цвета кода, не зависящие от темы приложения",
|
||||
highlightThemeAccessibility: "Тема подсветки: {{value}}",
|
||||
previewAccessibility:
|
||||
"Предварительный просмотр темы синтаксиса и шрифта кода в реальном времени.",
|
||||
"Предварительный просмотр типографики содержимого, темы синтаксиса и шрифта кода в реальном времени.",
|
||||
previewContent: "Предварительный просмотр содержимого и кода",
|
||||
},
|
||||
},
|
||||
shortcuts: {
|
||||
|
||||
@@ -2023,12 +2023,17 @@ export const zhCN: TranslationResources = {
|
||||
interfaceFont: "界面字体",
|
||||
interfaceFontHint: "用于整个应用。留空则使用系统默认",
|
||||
interfaceFontAccessibility: "界面字体族",
|
||||
baseSize: "基础字号",
|
||||
baseSizeAccessibility: "基础字号",
|
||||
interfaceSize: "界面字号",
|
||||
interfaceSizeHint: "用于导航、控件和标签",
|
||||
interfaceSizeAccessibility: "界面字体大小",
|
||||
contentSize: "内容字号",
|
||||
contentSizeHint: "用于聊天文本和渲染后的 Markdown",
|
||||
contentSizeAccessibility: "内容字体大小",
|
||||
codeFont: "代码字体",
|
||||
codeFontHint: "用于代码、diff 和终端输出。留空则使用系统默认",
|
||||
codeFontAccessibility: "代码字体族",
|
||||
codeSize: "代码字号",
|
||||
codeSizeHint: "用于代码、差异和终端输出",
|
||||
codeSizeAccessibility: "代码字号",
|
||||
},
|
||||
syntax: {
|
||||
@@ -2036,7 +2041,8 @@ export const zhCN: TranslationResources = {
|
||||
highlightTheme: "高亮主题",
|
||||
highlightThemeHint: "代码配色,独立于应用主题",
|
||||
highlightThemeAccessibility: "高亮主题:{{value}}",
|
||||
previewAccessibility: "语法主题和代码字体的实时预览",
|
||||
previewAccessibility: "内容排版、语法主题和代码字体的实时预览",
|
||||
previewContent: "内容和代码预览",
|
||||
},
|
||||
},
|
||||
shortcuts: {
|
||||
|
||||
@@ -601,8 +601,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
lineHeight: theme.fontSize.base * 1.4,
|
||||
fontSize: theme.fontSize.content,
|
||||
lineHeight: theme.fontSize.content * 1.4,
|
||||
},
|
||||
commentActions: {
|
||||
flexDirection: "row",
|
||||
@@ -650,8 +650,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderRadius: theme.borderRadius.md,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
fontSize: theme.fontSize.base,
|
||||
lineHeight: theme.fontSize.base * 1.4,
|
||||
fontSize: theme.fontSize.content,
|
||||
lineHeight: theme.fontSize.content * 1.4,
|
||||
textAlignVertical: "top",
|
||||
...(isWeb
|
||||
? {
|
||||
|
||||
@@ -26,6 +26,7 @@ const ZERO_WIDTH = "";
|
||||
type RowType = "context" | "add" | "remove";
|
||||
|
||||
interface PreviewOverrides {
|
||||
contentFontSize?: number;
|
||||
monoFontFamily?: string;
|
||||
codeFontSize?: number;
|
||||
}
|
||||
@@ -63,6 +64,12 @@ function buildCodeOverride(overrides: PreviewOverrides | undefined): TextStyle {
|
||||
return inlineUnistylesStyle(style);
|
||||
}
|
||||
|
||||
function buildContentOverride(overrides: PreviewOverrides | undefined): TextStyle {
|
||||
const fontSize = resolveSizeOverride(overrides?.contentFontSize);
|
||||
if (fontSize === undefined) return {};
|
||||
return inlineUnistylesStyle({ fontSize, lineHeight: Math.round(fontSize * 1.4) });
|
||||
}
|
||||
|
||||
interface KeyedToken {
|
||||
key: string;
|
||||
style: string | null;
|
||||
@@ -137,7 +144,9 @@ function markerStyle(type: RowType) {
|
||||
export function AppearancePreview({ overrides }: AppearancePreviewProps) {
|
||||
const { t } = useTranslation();
|
||||
const rows = useMemo(() => buildUnifiedRows(), []);
|
||||
const contentOverride = useMemo(() => buildContentOverride(overrides), [overrides]);
|
||||
const codeOverride = useMemo(() => buildCodeOverride(overrides), [overrides]);
|
||||
const contentStyle = useMemo(() => [styles.contentSample, contentOverride], [contentOverride]);
|
||||
const codeStyle = useMemo(() => [styles.codeLine, codeOverride], [codeOverride]);
|
||||
const addRowStyle = useMemo(() => [styles.row, styles.addRow], []);
|
||||
const removeRowStyle = useMemo(() => [styles.row, styles.removeRow], []);
|
||||
@@ -155,6 +164,7 @@ export function AppearancePreview({ overrides }: AppearancePreviewProps) {
|
||||
dataSet={CODE_SURFACE_DATASET}
|
||||
style={styles.card}
|
||||
>
|
||||
<Text style={contentStyle}>{t("settings.appearance.syntax.previewContent")}</Text>
|
||||
{rows.map((row) => (
|
||||
<View key={row.key} style={rowStyle(row.type)}>
|
||||
<Text style={codeStyle}>
|
||||
@@ -201,6 +211,13 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foreground,
|
||||
...(isWeb ? { whiteSpace: "pre", overflowWrap: "normal" } : null),
|
||||
},
|
||||
contentSample: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.content,
|
||||
lineHeight: Math.round(theme.fontSize.content * 1.4),
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingBottom: theme.spacing[2],
|
||||
},
|
||||
markerContext: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
|
||||
@@ -22,8 +22,10 @@ import { useContributedThemes } from "@/appearance/provider";
|
||||
import { EditingTextInput as TextInput } from "@/components/ui/text-input";
|
||||
import {
|
||||
MAX_CODE_FONT_SIZE,
|
||||
MAX_CONTENT_FONT_SIZE,
|
||||
MAX_UI_BASE_FONT_SIZE,
|
||||
MIN_CODE_FONT_SIZE,
|
||||
MIN_CONTENT_FONT_SIZE,
|
||||
MIN_UI_BASE_FONT_SIZE,
|
||||
parseClampedFontSize,
|
||||
sanitizeFontFamily,
|
||||
@@ -401,6 +403,7 @@ function FontFamilyRow({
|
||||
|
||||
interface FontSizeRowProps {
|
||||
title: string;
|
||||
hint: string;
|
||||
accessibilityLabel: string;
|
||||
draft: string;
|
||||
withBorder?: boolean;
|
||||
@@ -410,6 +413,7 @@ interface FontSizeRowProps {
|
||||
|
||||
function FontSizeRow({
|
||||
title,
|
||||
hint,
|
||||
accessibilityLabel,
|
||||
draft,
|
||||
withBorder = true,
|
||||
@@ -420,6 +424,7 @@ function FontSizeRow({
|
||||
<View style={withBorder ? styles.rowWithBorder : settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>{title}</Text>
|
||||
<Text style={settingsStyles.rowHint}>{hint}</Text>
|
||||
</View>
|
||||
<View style={styles.sizeField}>
|
||||
<TextInput
|
||||
@@ -527,12 +532,16 @@ export function AppearanceSection() {
|
||||
const [uiFontDraft, setUiFontDraft] = useState(settings.uiFontFamily);
|
||||
const [monoFontDraft, setMonoFontDraft] = useState(settings.monoFontFamily);
|
||||
const [uiBaseSizeDraft, setUiBaseSizeDraft] = useState(String(settings.uiBaseFontSize));
|
||||
const [contentSizeDraft, setContentSizeDraft] = useState(String(settings.contentFontSize));
|
||||
const [codeSizeDraft, setCodeSizeDraft] = useState(String(settings.codeFontSize));
|
||||
|
||||
// Resync numeric drafts when the committed value changes elsewhere.
|
||||
useEffect(() => {
|
||||
setUiBaseSizeDraft(String(settings.uiBaseFontSize));
|
||||
}, [settings.uiBaseFontSize]);
|
||||
useEffect(() => {
|
||||
setContentSizeDraft(String(settings.contentFontSize));
|
||||
}, [settings.contentFontSize]);
|
||||
useEffect(() => {
|
||||
setCodeSizeDraft(String(settings.codeFontSize));
|
||||
}, [settings.codeFontSize]);
|
||||
@@ -617,6 +626,10 @@ export function AppearanceSection() {
|
||||
setCodeSizeDraft(value.replace(/[^\d]/g, ""));
|
||||
}, []);
|
||||
|
||||
const handleContentSizeChange = useCallback((value: string) => {
|
||||
setContentSizeDraft(value.replace(/[^\d]/g, ""));
|
||||
}, []);
|
||||
|
||||
const commitUiBaseSize = useCallback(() => {
|
||||
const parsed = parseClampedFontSize(uiBaseSizeDraft, {
|
||||
min: MIN_UI_BASE_FONT_SIZE,
|
||||
@@ -641,15 +654,28 @@ export function AppearanceSection() {
|
||||
}
|
||||
}, [codeSizeDraft, settings.codeFontSize, updateSettings]);
|
||||
|
||||
const commitContentSize = useCallback(() => {
|
||||
const parsed = parseClampedFontSize(contentSizeDraft, {
|
||||
min: MIN_CONTENT_FONT_SIZE,
|
||||
max: MAX_CONTENT_FONT_SIZE,
|
||||
});
|
||||
const next = parsed ?? settings.contentFontSize;
|
||||
setContentSizeDraft(String(next));
|
||||
if (next !== settings.contentFontSize) {
|
||||
void updateSettings({ contentFontSize: next });
|
||||
}
|
||||
}, [contentSizeDraft, settings.contentFontSize, updateSettings]);
|
||||
|
||||
// Live-while-typing: the in-progress drafts drive the preview without
|
||||
// committing to the global theme. Empty/invalid fields fall back to the
|
||||
// theme value inside the preview.
|
||||
const previewOverrides = useMemo(
|
||||
() => ({
|
||||
contentFontSize: sizeDraftToOverride(contentSizeDraft),
|
||||
monoFontFamily: monoFontDraft,
|
||||
codeFontSize: sizeDraftToOverride(codeSizeDraft),
|
||||
}),
|
||||
[codeSizeDraft, monoFontDraft],
|
||||
[codeSizeDraft, contentSizeDraft, monoFontDraft],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -699,13 +725,22 @@ export function AppearanceSection() {
|
||||
/>
|
||||
) : null}
|
||||
<FontSizeRow
|
||||
title={t("settings.appearance.fonts.baseSize")}
|
||||
accessibilityLabel={t("settings.appearance.fonts.baseSizeAccessibility")}
|
||||
title={t("settings.appearance.fonts.interfaceSize")}
|
||||
hint={t("settings.appearance.fonts.interfaceSizeHint")}
|
||||
accessibilityLabel={t("settings.appearance.fonts.interfaceSizeAccessibility")}
|
||||
draft={uiBaseSizeDraft}
|
||||
withBorder={showInterfaceFontFamilyRow}
|
||||
onChangeDraft={handleUiBaseSizeChange}
|
||||
onCommit={commitUiBaseSize}
|
||||
/>
|
||||
<FontSizeRow
|
||||
title={t("settings.appearance.fonts.contentSize")}
|
||||
hint={t("settings.appearance.fonts.contentSizeHint")}
|
||||
accessibilityLabel={t("settings.appearance.fonts.contentSizeAccessibility")}
|
||||
draft={contentSizeDraft}
|
||||
onChangeDraft={handleContentSizeChange}
|
||||
onCommit={commitContentSize}
|
||||
/>
|
||||
<FontFamilyRow
|
||||
title={t("settings.appearance.fonts.codeFont")}
|
||||
hint={t("settings.appearance.fonts.codeFontHint")}
|
||||
@@ -719,6 +754,7 @@ export function AppearanceSection() {
|
||||
/>
|
||||
<FontSizeRow
|
||||
title={t("settings.appearance.fonts.codeSize")}
|
||||
hint={t("settings.appearance.fonts.codeSizeHint")}
|
||||
accessibilityLabel={t("settings.appearance.fonts.codeSizeAccessibility")}
|
||||
draft={codeSizeDraft}
|
||||
onChangeDraft={handleCodeSizeChange}
|
||||
|
||||
@@ -3,20 +3,20 @@ import { createCompactMarkdownStyles, createMarkdownStyles } from "./markdown-st
|
||||
import { darkTheme } from "./theme";
|
||||
|
||||
describe("createMarkdownStyles", () => {
|
||||
it("uses the standard interface size for conversation prose and list markers", () => {
|
||||
it("uses the content size for conversation prose and list markers", () => {
|
||||
const styles = createMarkdownStyles(darkTheme);
|
||||
const proseLineHeight = Math.round(darkTheme.fontSize.base * 1.4);
|
||||
const proseLineHeight = Math.round(darkTheme.fontSize.content * 1.4);
|
||||
|
||||
expect(styles.body).toMatchObject({
|
||||
fontSize: darkTheme.fontSize.base,
|
||||
fontSize: darkTheme.fontSize.content,
|
||||
lineHeight: proseLineHeight,
|
||||
});
|
||||
expect(styles.bullet_list_icon).toMatchObject({
|
||||
fontSize: darkTheme.fontSize.base,
|
||||
fontSize: darkTheme.fontSize.content,
|
||||
lineHeight: proseLineHeight,
|
||||
});
|
||||
expect(styles.ordered_list_icon).toMatchObject({
|
||||
fontSize: darkTheme.fontSize.base,
|
||||
fontSize: darkTheme.fontSize.content,
|
||||
lineHeight: proseLineHeight,
|
||||
});
|
||||
});
|
||||
@@ -112,6 +112,18 @@ describe("createMarkdownStyles", () => {
|
||||
expect(compactStyles.code_inline).not.toHaveProperty("lineHeight");
|
||||
});
|
||||
|
||||
it("scales Markdown headings from content size with safe line heights", () => {
|
||||
const largeContentTheme = {
|
||||
...darkTheme,
|
||||
fontSize: { ...darkTheme.fontSize, content: 21 },
|
||||
};
|
||||
const styles = createMarkdownStyles(largeContentTheme);
|
||||
|
||||
expect(styles.heading1.lineHeight).toBeGreaterThan(styles.heading1.fontSize);
|
||||
expect(styles.heading2.lineHeight).toBeGreaterThan(styles.heading2.fontSize);
|
||||
expect(styles.heading3.lineHeight).toBeGreaterThan(styles.heading3.fontSize);
|
||||
});
|
||||
|
||||
it("keeps blockquotes quiet with a square left edge", () => {
|
||||
const styles = createMarkdownStyles(darkTheme);
|
||||
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import type { Theme } from "./theme";
|
||||
import { FONT_SIZE, type Theme } from "./theme";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
const webSelectableTextStyle = isWeb ? { userSelect: "text" as const } : {};
|
||||
|
||||
function contentHeadingSize(contentSize: number, tier: keyof typeof FONT_SIZE): number {
|
||||
return Math.round(contentSize * (FONT_SIZE[tier] / FONT_SIZE.base));
|
||||
}
|
||||
|
||||
function contentHeadingLineHeight(contentSize: number, tier: keyof typeof FONT_SIZE): number {
|
||||
return Math.round(contentHeadingSize(contentSize, tier) * 1.3);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates comprehensive markdown styles for react-native-markdown-display.
|
||||
*
|
||||
@@ -19,10 +27,10 @@ export function createMarkdownStyles(theme: Theme) {
|
||||
body: {
|
||||
...webSelectableTextStyle,
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
// Prose line-height scales with the UI ramp, not the
|
||||
fontSize: theme.fontSize.content,
|
||||
// Prose line-height scales with the content size, not the
|
||||
// code-size-coupled lineHeight.diff token used by code/diff surfaces.
|
||||
lineHeight: Math.round(theme.fontSize.base * 1.4),
|
||||
lineHeight: Math.round(theme.fontSize.content * 1.4),
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
width: "100%" as const,
|
||||
@@ -53,12 +61,12 @@ export function createMarkdownStyles(theme: Theme) {
|
||||
|
||||
heading1: {
|
||||
...webSelectableTextStyle,
|
||||
fontSize: theme.fontSize["4xl"],
|
||||
fontSize: contentHeadingSize(theme.fontSize.content, "4xl"),
|
||||
fontWeight: theme.fontWeight.bold,
|
||||
color: theme.colors.foreground,
|
||||
marginTop: theme.spacing[6],
|
||||
marginBottom: theme.spacing[3],
|
||||
lineHeight: 32,
|
||||
lineHeight: contentHeadingLineHeight(theme.fontSize.content, "4xl"),
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
paddingBottom: theme.spacing[2],
|
||||
@@ -66,12 +74,12 @@ export function createMarkdownStyles(theme: Theme) {
|
||||
|
||||
heading2: {
|
||||
...webSelectableTextStyle,
|
||||
fontSize: theme.fontSize["3xl"],
|
||||
fontSize: contentHeadingSize(theme.fontSize.content, "3xl"),
|
||||
fontWeight: theme.fontWeight.bold,
|
||||
color: theme.colors.foreground,
|
||||
marginTop: theme.spacing[6],
|
||||
marginBottom: theme.spacing[3],
|
||||
lineHeight: 28,
|
||||
lineHeight: contentHeadingLineHeight(theme.fontSize.content, "3xl"),
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
paddingBottom: theme.spacing[2],
|
||||
@@ -79,42 +87,42 @@ export function createMarkdownStyles(theme: Theme) {
|
||||
|
||||
heading3: {
|
||||
...webSelectableTextStyle,
|
||||
fontSize: theme.fontSize["2xl"],
|
||||
fontSize: contentHeadingSize(theme.fontSize.content, "2xl"),
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
color: theme.colors.foreground,
|
||||
marginTop: theme.spacing[4],
|
||||
marginBottom: theme.spacing[2],
|
||||
lineHeight: 26,
|
||||
lineHeight: contentHeadingLineHeight(theme.fontSize.content, "2xl"),
|
||||
},
|
||||
|
||||
heading4: {
|
||||
...webSelectableTextStyle,
|
||||
fontSize: theme.fontSize.xl,
|
||||
fontSize: contentHeadingSize(theme.fontSize.content, "xl"),
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
color: theme.colors.foreground,
|
||||
marginTop: theme.spacing[4],
|
||||
marginBottom: theme.spacing[2],
|
||||
lineHeight: 24,
|
||||
lineHeight: contentHeadingLineHeight(theme.fontSize.content, "xl"),
|
||||
},
|
||||
|
||||
heading5: {
|
||||
...webSelectableTextStyle,
|
||||
fontSize: theme.fontSize.lg,
|
||||
fontSize: contentHeadingSize(theme.fontSize.content, "lg"),
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
color: theme.colors.foreground,
|
||||
marginTop: theme.spacing[3],
|
||||
marginBottom: theme.spacing[1],
|
||||
lineHeight: 22,
|
||||
lineHeight: contentHeadingLineHeight(theme.fontSize.content, "lg"),
|
||||
},
|
||||
|
||||
heading6: {
|
||||
...webSelectableTextStyle,
|
||||
fontSize: theme.fontSize.lg,
|
||||
fontSize: contentHeadingSize(theme.fontSize.content, "lg"),
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
color: theme.colors.foregroundMuted,
|
||||
marginTop: theme.spacing[3],
|
||||
marginBottom: theme.spacing[1],
|
||||
lineHeight: 20,
|
||||
lineHeight: contentHeadingLineHeight(theme.fontSize.content, "lg"),
|
||||
textTransform: "uppercase" as const,
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
@@ -227,7 +235,7 @@ export function createMarkdownStyles(theme: Theme) {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontSize: theme.fontSize.content,
|
||||
textAlign: "left" as const,
|
||||
},
|
||||
|
||||
@@ -243,7 +251,7 @@ export function createMarkdownStyles(theme: Theme) {
|
||||
borderRightWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontSize: theme.fontSize.content,
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
@@ -282,17 +290,17 @@ export function createMarkdownStyles(theme: Theme) {
|
||||
...webSelectableTextStyle,
|
||||
color: theme.colors.foregroundMuted,
|
||||
marginRight: 4,
|
||||
fontSize: theme.fontSize.base,
|
||||
lineHeight: Math.round(theme.fontSize.base * 1.4),
|
||||
fontSize: theme.fontSize.content,
|
||||
lineHeight: Math.round(theme.fontSize.content * 1.4),
|
||||
},
|
||||
|
||||
ordered_list_icon: {
|
||||
...webSelectableTextStyle,
|
||||
color: theme.colors.foregroundMuted,
|
||||
marginRight: 4,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontSize: theme.fontSize.content,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
lineHeight: Math.round(theme.fontSize.base * 1.4),
|
||||
lineHeight: Math.round(theme.fontSize.content * 1.4),
|
||||
minWidth: 12,
|
||||
},
|
||||
|
||||
@@ -357,32 +365,32 @@ export function createCompactMarkdownStyles(theme: Theme) {
|
||||
|
||||
body: {
|
||||
...baseStyles.body,
|
||||
fontSize: theme.fontSize.base,
|
||||
lineHeight: 20,
|
||||
fontSize: theme.fontSize.content,
|
||||
lineHeight: Math.round(theme.fontSize.content * 1.4),
|
||||
},
|
||||
|
||||
heading1: {
|
||||
...baseStyles.heading1,
|
||||
fontSize: theme.fontSize["2xl"],
|
||||
fontSize: contentHeadingSize(theme.fontSize.content, "2xl"),
|
||||
marginTop: theme.spacing[4],
|
||||
marginBottom: theme.spacing[2],
|
||||
lineHeight: 26,
|
||||
lineHeight: contentHeadingLineHeight(theme.fontSize.content, "2xl"),
|
||||
},
|
||||
|
||||
heading2: {
|
||||
...baseStyles.heading2,
|
||||
fontSize: theme.fontSize.xl,
|
||||
fontSize: contentHeadingSize(theme.fontSize.content, "xl"),
|
||||
marginTop: theme.spacing[3],
|
||||
marginBottom: theme.spacing[2],
|
||||
lineHeight: 24,
|
||||
lineHeight: contentHeadingLineHeight(theme.fontSize.content, "xl"),
|
||||
},
|
||||
|
||||
heading3: {
|
||||
...baseStyles.heading3,
|
||||
fontSize: theme.fontSize.lg,
|
||||
fontSize: contentHeadingSize(theme.fontSize.content, "lg"),
|
||||
marginTop: theme.spacing[3],
|
||||
marginBottom: theme.spacing[1],
|
||||
lineHeight: 22,
|
||||
lineHeight: contentHeadingLineHeight(theme.fontSize.content, "lg"),
|
||||
},
|
||||
|
||||
paragraph: {
|
||||
|
||||
@@ -12,6 +12,7 @@ describe("Typography scale", () => {
|
||||
it("names 14px as the default interface tier", () => {
|
||||
expect(FONT_SIZE).toEqual({
|
||||
code: 12,
|
||||
content: 15,
|
||||
sm: 12,
|
||||
base: 14,
|
||||
lg: 16,
|
||||
|
||||
@@ -558,6 +558,7 @@ export const SPACING = {
|
||||
|
||||
export const FONT_SIZE = {
|
||||
code: 12,
|
||||
content: 15,
|
||||
sm: 12,
|
||||
base: 14,
|
||||
lg: 16,
|
||||
|
||||
@@ -15,29 +15,33 @@ export async function runAppearanceFontSizeRegression(page) {
|
||||
await page.getByLabel("Theme: System", { exact: true }).click();
|
||||
await page.getByText("Pure black", { exact: true }).click();
|
||||
|
||||
const input = page.getByRole("textbox", { name: "Base font size" });
|
||||
const interfaceSizeInput = page.getByRole("textbox", { name: "Interface font size" });
|
||||
const contentSizeInput = page.getByRole("textbox", { name: "Content font size" });
|
||||
const sectionTitle = page.getByText("Theme", { exact: true }).first();
|
||||
await input.waitFor({ state: "visible", timeout: SETTINGS_TIMEOUT_MS });
|
||||
await interfaceSizeInput.waitFor({ state: "visible", timeout: SETTINGS_TIMEOUT_MS });
|
||||
|
||||
assert((await input.inputValue()) === "14", "Base font size did not start at 14px");
|
||||
assert((await interfaceSizeInput.inputValue()) === "14", "Interface size did not start at 14px");
|
||||
assert((await contentSizeInput.inputValue()) === "15", "Content size did not start at 15px");
|
||||
assert(
|
||||
(await readFontSize(sectionTitle)) === "12px",
|
||||
"Theme label did not start at the default 12px ramp size",
|
||||
);
|
||||
|
||||
await input.fill("15");
|
||||
await input.press("Tab");
|
||||
await interfaceSizeInput.fill("12");
|
||||
await interfaceSizeInput.press("Tab");
|
||||
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const inputElement = document.querySelector('input[aria-label="Base font size"]');
|
||||
const interfaceInput = document.querySelector('input[aria-label="Interface font size"]');
|
||||
const contentInput = document.querySelector('input[aria-label="Content font size"]');
|
||||
const themeLabel = [...document.querySelectorAll("div")].find(
|
||||
(element) => element.children.length === 0 && element.textContent?.trim() === "Theme",
|
||||
);
|
||||
return (
|
||||
inputElement?.value === "15" &&
|
||||
interfaceInput?.value === "12" &&
|
||||
contentInput?.value === "15" &&
|
||||
themeLabel instanceof HTMLElement &&
|
||||
getComputedStyle(themeLabel).fontSize === "13px"
|
||||
getComputedStyle(themeLabel).fontSize === "10px"
|
||||
);
|
||||
},
|
||||
undefined,
|
||||
|
||||
Reference in New Issue
Block a user