Add appearance settings for theme, fonts, and syntax highlighting (#1236)

* Add appearance settings for theme, fonts, and syntax highlighting

New Appearance section in Settings to configure the app theme, UI and
mono font family + size, and a syntax-highlighting theme chosen
independently of light/dark. Font family fields default to empty and
show the active system stack as the placeholder; a live split-diff
preview reflects the choices as they change. The theme picker moves
here from General.

Preferences are client-only (AsyncStorage) and applied at runtime by
patching every registered Unistyles theme via
UnistylesRuntime.updateTheme, so existing StyleSheet consumers repaint
with no per-component preference reads and no useUnistyles. No
daemon/protocol change.

* Expand syntax themes and move the preview under the chooser

Replace the small fixed syntax-theme set with eight popular themes — GitHub,
Catppuccin, Dracula, Tokyo Night, One, Nord, Gruvbox, Solarized — built from a
compact per-theme role palette. The app theme and syntax theme stay separate;
the only coupling is the light/dark axis: a theme with both variants uses its
light palette on a light app and its dark palette on a dark app, while the code
frame (gutter, line numbers, background) keeps following the app theme. Default
is now GitHub (GitHub keeps its existing hand-tuned maps for an exact match).

Move the live preview directly under the syntax-theme chooser, drop the
"preview.ts" filename header, and use a clearer code snippet for it.

* Update settings e2e for the moved theme picker and Appearance section

The theme picker moved from General to the new Appearance section, so the
General-content assertion no longer finds "Theme" — point it at "Default send"
(which stays in General) and add Appearance to the e2e section map plus an
expectAppearanceContent check, and exercise the Appearance section in the
sidebar-navigation test.

* Render the appearance preview as a unified diff

Switch the live preview from side-by-side columns to a single unified diff:
unchanged context lines plus "-"/"+" rows for the change, matching how diffs
read elsewhere in the app. Same snippet, mono font, syntax colors, and live
code-font draft behavior.

* Fix code font size change shrinking all UI text

applyAppearance scaled the live (already-patched) theme ramp instead of the
authored FONT_SIZE ramp, so it was not idempotent: every appearance change
re-scaled the current sizes, compounding the whole-UI font scale. With any
non-default UI size, changing the code size — or any setting — shrank (or grew)
all app text, and it persisted into the theme. Build the ramp from the canonical
FONT_SIZE ramp each apply (code size still set absolutely), so applies are
idempotent and a code-size change never touches the UI ramp.

* Apply the interface font across the app on web

react-native-web stamps a default font onto every text element, so the interface
font only reached the few components that set a font explicitly. Inject one rule
that points all text at a CSS variable (updated live as the setting changes),
with high specificity (1,2,0) so it deterministically beats RN-web's base font
and Unistyles' generated classes — no stylesheet-order fragility. Code, diff, and
monospace surfaces carry a `data-pmono` marker (and their subtrees are excluded),
so they keep their code font.

Web-only: React Native has no global font cascade, so native still applies the UI
font only where components opt in. Inline `code` within chat prose is a known
minor gap (its render paths aren't tagged yet).

* Keep preview code in the code font

* Apply code font settings to terminals

* Fix settings host picker import after rebase

* Format terminal webview bundle
This commit is contained in:
Mohamed Boudra
2026-05-31 16:09:01 +08:00
committed by GitHub
parent f26a81798b
commit a1c8e1a1f9
43 changed files with 2083 additions and 247 deletions
@@ -14,12 +14,20 @@ import {
APP_SETTINGS_QUERY_KEY,
DEFAULT_APP_SETTINGS,
DEFAULT_CLIENT_SETTINGS,
DEFAULT_CODE_FONT_SIZE,
DEFAULT_TERMINAL_SCROLLBACK_LINES,
DEFAULT_UI_FONT_SIZE,
MAX_CODE_FONT_SIZE,
MAX_TERMINAL_SCROLLBACK_LINES,
MAX_UI_FONT_SIZE,
MIN_CODE_FONT_SIZE,
MIN_TERMINAL_SCROLLBACK_LINES,
MIN_UI_FONT_SIZE,
loadAppSettingsFromStorage as loadAppSettingsFromStoragePure,
loadSettingsFromStorage as loadSettingsFromStoragePure,
parseClampedFontSize,
parseTerminalScrollbackLines,
sanitizeFontFamily,
saveAppSettings as saveAppSettingsPure,
type AppSettings,
type DesktopSettingsBridge,
@@ -35,10 +43,18 @@ export {
APP_SETTINGS_KEY,
DEFAULT_APP_SETTINGS,
DEFAULT_CLIENT_SETTINGS,
DEFAULT_CODE_FONT_SIZE,
DEFAULT_TERMINAL_SCROLLBACK_LINES,
DEFAULT_UI_FONT_SIZE,
MAX_CODE_FONT_SIZE,
MAX_TERMINAL_SCROLLBACK_LINES,
MAX_UI_FONT_SIZE,
MIN_CODE_FONT_SIZE,
MIN_TERMINAL_SCROLLBACK_LINES,
MIN_UI_FONT_SIZE,
parseClampedFontSize,
parseTerminalScrollbackLines,
sanitizeFontFamily,
};
export type {
AppSettings,
@@ -136,6 +152,21 @@ export function useSettings(): UseSettingsReturn {
if (updates.terminalScrollbackLines !== undefined) {
appUpdates.terminalScrollbackLines = updates.terminalScrollbackLines;
}
if (updates.uiFontFamily !== undefined) {
appUpdates.uiFontFamily = updates.uiFontFamily;
}
if (updates.monoFontFamily !== undefined) {
appUpdates.monoFontFamily = updates.monoFontFamily;
}
if (updates.uiFontSize !== undefined) {
appUpdates.uiFontSize = updates.uiFontSize;
}
if (updates.codeFontSize !== undefined) {
appUpdates.codeFontSize = updates.codeFontSize;
}
if (updates.syntaxTheme !== undefined) {
appUpdates.syntaxTheme = updates.syntaxTheme;
}
const promises: Promise<void>[] = [];
if (Object.keys(appUpdates).length > 0) {
promises.push(appSettings.updateSettings(appUpdates));
@@ -4,8 +4,11 @@ import {
APP_SETTINGS_KEY,
DEFAULT_APP_SETTINGS,
DEFAULT_CLIENT_SETTINGS,
DEFAULT_CODE_FONT_SIZE,
DEFAULT_UI_FONT_SIZE,
loadAppSettingsFromStorage,
loadSettingsFromStorage,
parseClampedFontSize,
parseTerminalScrollbackLines,
saveAppSettings,
type SettingsDeps,
@@ -87,10 +90,8 @@ describe("loadAppSettingsFromStorage", () => {
const result = await loadAppSettingsFromStorage(deps);
expect(result).toEqual({
...DEFAULT_CLIENT_SETTINGS,
theme: "dark",
sendBehavior: "interrupt",
serviceUrlBehavior: "ask",
terminalScrollbackLines: 10_000,
});
expect(deps.storage.entries.get(APP_SETTINGS_KEY)).toBe(JSON.stringify(result));
});
@@ -126,12 +127,8 @@ describe("loadSettingsFromStorage", () => {
const result = await loadSettingsFromStorage(deps);
expect(result).toEqual({
...DEFAULT_APP_SETTINGS,
theme: "light",
manageBuiltInDaemon: true,
sendBehavior: "interrupt",
serviceUrlBehavior: "ask",
terminalScrollbackLines: 10_000,
releaseChannel: "stable",
});
});
@@ -172,10 +169,8 @@ describe("loadSettingsFromStorage", () => {
{ manageBuiltInDaemon: false, releaseChannel: "beta" },
]);
expect(result).toEqual({
...DEFAULT_APP_SETTINGS,
theme: "light",
sendBehavior: "interrupt",
serviceUrlBehavior: "ask",
terminalScrollbackLines: 10_000,
manageBuiltInDaemon: false,
releaseChannel: "beta",
});
@@ -194,12 +189,8 @@ describe("loadSettingsFromStorage", () => {
expect(desktop.migrationsApplied).toEqual([]);
expect(result).toEqual({
...DEFAULT_APP_SETTINGS,
theme: "light",
sendBehavior: "interrupt",
serviceUrlBehavior: "ask",
terminalScrollbackLines: 10_000,
manageBuiltInDaemon: true,
releaseChannel: "stable",
});
});
});
@@ -234,3 +225,146 @@ describe("parseTerminalScrollbackLines", () => {
expect(parseTerminalScrollbackLines("abc")).toBeNull();
});
});
describe("appearance settings", () => {
it("defaults the appearance fields when an old blob omits them", async () => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({ theme: "dark" }),
}),
});
const result = await loadAppSettingsFromStorage(deps);
expect(result.uiFontFamily).toBe("");
expect(result.monoFontFamily).toBe("");
expect(result.uiFontSize).toBe(DEFAULT_UI_FONT_SIZE);
expect(result.codeFontSize).toBe(DEFAULT_CODE_FONT_SIZE);
expect(result.syntaxTheme).toBe("github");
});
it("clamps the UI font size into range and rejects non-numeric values", async () => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({ uiFontSize: 999 }),
}),
});
expect((await loadAppSettingsFromStorage(deps)).uiFontSize).toBe(24);
const low = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({ uiFontSize: 8 }),
}),
});
expect((await loadAppSettingsFromStorage(low)).uiFontSize).toBe(11);
const bogus = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({ uiFontSize: "abc" }),
}),
});
expect((await loadAppSettingsFromStorage(bogus)).uiFontSize).toBe(DEFAULT_UI_FONT_SIZE);
});
it("clamps the code font size into range and rejects non-numeric values", async () => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({ codeFontSize: 999 }),
}),
});
expect((await loadAppSettingsFromStorage(deps)).codeFontSize).toBe(22);
const low = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({ codeFontSize: 8 }),
}),
});
expect((await loadAppSettingsFromStorage(low)).codeFontSize).toBe(9);
const bogus = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({ codeFontSize: "abc" }),
}),
});
expect((await loadAppSettingsFromStorage(bogus)).codeFontSize).toBe(DEFAULT_CODE_FONT_SIZE);
});
it("trims an accepted font family", async () => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({ uiFontFamily: " Menlo " }),
}),
});
expect((await loadAppSettingsFromStorage(deps)).uiFontFamily).toBe("Menlo");
});
it("keeps an explicit empty font family as the default sentinel", async () => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({ uiFontFamily: "" }),
}),
});
expect((await loadAppSettingsFromStorage(deps)).uiFontFamily).toBe("");
});
it("rejects a font family containing CSS-breaking characters", async () => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({ uiFontFamily: "a;b{c}" }),
}),
});
expect((await loadAppSettingsFromStorage(deps)).uiFontFamily).toBe("");
});
it("rejects an over-length font family", async () => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({ uiFontFamily: "a".repeat(201) }),
}),
});
expect((await loadAppSettingsFromStorage(deps)).uiFontFamily).toBe("");
});
it("accepts a known syntax theme id", async () => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({ syntaxTheme: "dracula" }),
}),
});
expect((await loadAppSettingsFromStorage(deps)).syntaxTheme).toBe("dracula");
});
it("drops a removed syntax theme id back to the default", async () => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({ syntaxTheme: "auto" }),
}),
});
expect((await loadAppSettingsFromStorage(deps)).syntaxTheme).toBe("github");
});
it("drops an unknown syntax theme id back to the default", async () => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({ syntaxTheme: "bogus" }),
}),
});
expect((await loadAppSettingsFromStorage(deps)).syntaxTheme).toBe("github");
});
});
describe("parseClampedFontSize", () => {
it("clamps to the bounds and rejects non-numeric strings", () => {
expect(parseClampedFontSize(999, { min: 11, max: 24 })).toBe(24);
expect(parseClampedFontSize(8, { min: 11, max: 24 })).toBe(11);
expect(parseClampedFontSize("15", { min: 11, max: 24 })).toBe(15);
expect(parseClampedFontSize("abc", { min: 11, max: 24 })).toBeNull();
});
});
@@ -1,3 +1,4 @@
import { isSyntaxThemeId, type SyntaxThemeId } from "@getpaseo/highlight";
import type { QueryClient } from "@tanstack/react-query";
import type { DesktopSettings } from "@/desktop/settings/desktop-settings";
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
@@ -15,12 +16,24 @@ const VALID_SERVICE_URL_BEHAVIORS = new Set<ServiceUrlBehavior>(["ask", "in-app"
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 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
export const MAX_FONT_FAMILY_LENGTH = 200;
export interface AppSettings {
theme: ThemeName | "auto";
sendBehavior: SendBehavior;
serviceUrlBehavior: ServiceUrlBehavior;
terminalScrollbackLines: number;
uiFontFamily: string; // "" = platform default UI stack
monoFontFamily: string; // "" = platform default mono stack
uiFontSize: number; // clamped px, default 16
codeFontSize: number; // clamped px, default 12
syntaxTheme: SyntaxThemeId; // default "auto"
}
export interface Settings extends AppSettings {
@@ -33,6 +46,11 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
sendBehavior: "interrupt",
serviceUrlBehavior: "ask",
terminalScrollbackLines: DEFAULT_TERMINAL_SCROLLBACK_LINES,
uiFontFamily: "",
monoFontFamily: "",
uiFontSize: DEFAULT_UI_FONT_SIZE,
codeFontSize: DEFAULT_CODE_FONT_SIZE,
syntaxTheme: "github",
};
export const DEFAULT_APP_SETTINGS: Settings = {
@@ -144,6 +162,31 @@ function pickAppSettings(stored: Partial<AppSettings>): Partial<AppSettings> {
if (terminalScrollbackLines !== null) {
result.terminalScrollbackLines = terminalScrollbackLines;
}
const uiFontFamily = sanitizeFontFamily(stored.uiFontFamily);
if (uiFontFamily !== null) {
result.uiFontFamily = uiFontFamily;
}
const monoFontFamily = sanitizeFontFamily(stored.monoFontFamily);
if (monoFontFamily !== null) {
result.monoFontFamily = monoFontFamily;
}
const uiFontSize = parseClampedFontSize(stored.uiFontSize, {
min: MIN_UI_FONT_SIZE,
max: MAX_UI_FONT_SIZE,
});
if (uiFontSize !== null) {
result.uiFontSize = uiFontSize;
}
const codeFontSize = parseClampedFontSize(stored.codeFontSize, {
min: MIN_CODE_FONT_SIZE,
max: MAX_CODE_FONT_SIZE,
});
if (codeFontSize !== null) {
result.codeFontSize = codeFontSize;
}
if (typeof stored.syntaxTheme === "string" && isSyntaxThemeId(stored.syntaxTheme)) {
result.syntaxTheme = stored.syntaxTheme;
}
return result;
}
@@ -171,6 +214,42 @@ export function parseTerminalScrollbackLines(value: unknown): number | null {
);
}
export function parseClampedFontSize(
value: unknown,
bounds: { min: number; max: number },
): number | null {
let numericValue = NaN;
if (typeof value === "number") {
numericValue = value;
} else if (typeof value === "string" && value.trim().length > 0) {
numericValue = Number(value);
}
if (!Number.isFinite(numericValue)) {
return null;
}
return Math.min(bounds.max, Math.max(bounds.min, Math.floor(numericValue)));
}
export function sanitizeFontFamily(value: unknown): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
if (trimmed.length === 0) {
return ""; // explicit empty = default
}
if (trimmed.length > MAX_FONT_FAMILY_LENGTH) {
return null;
}
if (/[;{}<>]/.test(trimmed)) {
return null; // would break the web CSS font-family declaration
}
if ([...trimmed].some((char) => char.charCodeAt(0) <= 0x1f)) {
return null; // control chars would corrupt the font-family string
}
return trimmed; // quotes/commas are legit in stacks
}
async function loadLegacyDesktopSettingsFromStorage(storage: KeyValueStorage): Promise<{
manageBuiltInDaemon?: boolean;
releaseChannel?: ReleaseChannel;