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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user