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
@@ -0,0 +1,81 @@
import { describe, it, expect } from "vitest";
import { darkHighlightColors, lightHighlightColors } from "../colors.js";
import { SYNTAX_THEME_IDS, isSyntaxThemeId, resolveSyntaxColors } from "../themes.js";
import type { HighlightStyle } from "../types.js";
const allStyles: HighlightStyle[] = [
"keyword",
"comment",
"string",
"number",
"literal",
"function",
"definition",
"class",
"type",
"tag",
"attribute",
"property",
"variable",
"operator",
"punctuation",
"regexp",
"escape",
"meta",
"heading",
"link",
];
const colorSchemes: ("light" | "dark")[] = ["light", "dark"];
describe("resolveSyntaxColors", () => {
for (const id of SYNTAX_THEME_IDS) {
for (const colorScheme of colorSchemes) {
describe(`${id} (${colorScheme})`, () => {
const colors = resolveSyntaxColors(id, colorScheme);
it("covers all HighlightStyle values", () => {
for (const style of allStyles) {
expect(colors[style]).toBeDefined();
expect(typeof colors[style]).toBe("string");
}
});
it("has valid hex color values", () => {
for (const style of allStyles) {
expect(colors[style]).toMatch(/^#[0-9a-fA-F]{6}$/);
}
});
});
}
}
it("github + light deep-equals lightHighlightColors", () => {
expect(resolveSyntaxColors("github", "light")).toEqual(lightHighlightColors);
});
it("github + dark deep-equals darkHighlightColors", () => {
expect(resolveSyntaxColors("github", "dark")).toEqual(darkHighlightColors);
});
it("dark-only themes ignore the color scheme", () => {
for (const id of ["dracula", "nord"] as const) {
expect(resolveSyntaxColors(id, "light")).toEqual(resolveSyntaxColors(id, "dark"));
}
});
});
describe("isSyntaxThemeId", () => {
it("accepts every id in SYNTAX_THEME_IDS", () => {
for (const id of SYNTAX_THEME_IDS) {
expect(isSyntaxThemeId(id)).toBe(true);
}
});
it("rejects unknown ids", () => {
expect(isSyntaxThemeId("auto")).toBe(false);
expect(isSyntaxThemeId("github-light")).toBe(false);
expect(isSyntaxThemeId("one-dark")).toBe(false);
expect(isSyntaxThemeId("nope")).toBe(false);
});
});
+7
View File
@@ -2,3 +2,10 @@ export type { HighlightStyle, HighlightToken } from "./types.js";
export { getParserForFile, isLanguageSupported, getSupportedExtensions } from "./parsers.js";
export { highlightCode, highlightLine } from "./highlighter.js";
export { darkHighlightColors, lightHighlightColors } from "./colors.js";
export type { SyntaxThemeId, SyntaxThemeOption, SyntaxColors } from "./themes.js";
export {
SYNTAX_THEME_IDS,
SYNTAX_THEME_OPTIONS,
isSyntaxThemeId,
resolveSyntaxColors,
} from "./themes.js";
+278
View File
@@ -0,0 +1,278 @@
import { darkHighlightColors, lightHighlightColors } from "./colors.js";
import type { HighlightStyle } from "./types.js";
// Syntax-highlighting themes are chosen independently of the app's light/dark
// theme. The ONLY coupling is the light/dark axis: a theme that ships both
// variants uses its light palette on a light app and its dark palette on a dark
// app (resolveSyntaxColors receives the active theme's colorScheme). Dark-only
// themes (Dracula, Nord) apply their single palette regardless. The code frame
// — gutter, line numbers, background — follows the app theme, not the palette.
export type SyntaxThemeId =
| "github"
| "catppuccin"
| "dracula"
| "tokyo-night"
| "one"
| "nord"
| "gruvbox"
| "solarized";
export const SYNTAX_THEME_IDS: readonly SyntaxThemeId[] = [
"github",
"catppuccin",
"dracula",
"tokyo-night",
"one",
"nord",
"gruvbox",
"solarized",
];
export interface SyntaxThemeOption {
id: SyntaxThemeId;
label: string;
}
export const SYNTAX_THEME_OPTIONS: readonly SyntaxThemeOption[] = [
{ id: "github", label: "GitHub" },
{ id: "catppuccin", label: "Catppuccin" },
{ id: "dracula", label: "Dracula" },
{ id: "tokyo-night", label: "Tokyo Night" },
{ id: "one", label: "One" },
{ id: "nord", label: "Nord" },
{ id: "gruvbox", label: "Gruvbox" },
{ id: "solarized", label: "Solarized" },
];
export type SyntaxColors = Record<HighlightStyle, string>;
// A compact per-theme role palette. `expandRolePalette` maps these roles onto
// all 20 HighlightStyle tokens, so every theme stays complete and internally
// consistent. GitHub keeps its own hand-tuned maps (colors.ts) for exactness
// and byte-for-byte back-compat with the previous default.
interface RolePalette {
base: string; // plain text: variables, punctuation
keyword: string;
comment: string; // comments, meta
string: string; // strings, regexp, links
number: string; // numbers, literals, escapes
function: string; // functions, definitions, headings
type: string; // types, classes
tag: string;
attribute: string; // attributes, properties
operator: string;
}
function expandRolePalette(r: RolePalette): SyntaxColors {
return {
keyword: r.keyword,
comment: r.comment,
string: r.string,
number: r.number,
literal: r.number,
function: r.function,
definition: r.function,
class: r.type,
type: r.type,
tag: r.tag,
attribute: r.attribute,
property: r.attribute,
variable: r.base,
operator: r.operator,
punctuation: r.base,
regexp: r.string,
escape: r.number,
meta: r.comment,
heading: r.function,
link: r.string,
};
}
// --- Catppuccin (Latte light / Mocha dark) -------------------------------
const catppuccinLatte: RolePalette = {
base: "#4c4f69",
keyword: "#8839ef",
comment: "#8c8fa1",
string: "#40a02b",
number: "#fe640b",
function: "#1e66f5",
type: "#df8e1d",
tag: "#8839ef",
attribute: "#df8e1d",
operator: "#04a5e5",
};
const catppuccinMocha: RolePalette = {
base: "#cdd6f4",
keyword: "#cba6f7",
comment: "#9399b2",
string: "#a6e3a1",
number: "#fab387",
function: "#89b4fa",
type: "#f9e2af",
tag: "#cba6f7",
attribute: "#f9e2af",
operator: "#89dceb",
};
// --- Dracula (dark only) -------------------------------------------------
const dracula: RolePalette = {
base: "#f8f8f2",
keyword: "#ff79c6",
comment: "#6272a4",
string: "#f1fa8c",
number: "#bd93f9",
function: "#50fa7b",
type: "#8be9fd",
tag: "#ff79c6",
attribute: "#50fa7b",
operator: "#ff79c6",
};
// --- Tokyo Night (Day light / Night dark) --------------------------------
const tokyoDay: RolePalette = {
base: "#3760bf",
keyword: "#9854f1",
comment: "#848cb5",
string: "#587539",
number: "#b15c00",
function: "#2e7de9",
type: "#007197",
tag: "#f52a65",
attribute: "#8c6c3e",
operator: "#006a83",
};
const tokyoNight: RolePalette = {
base: "#c0caf5",
keyword: "#bb9af7",
comment: "#565f89",
string: "#9ece6a",
number: "#ff9e64",
function: "#7aa2f7",
type: "#2ac3de",
tag: "#f7768e",
attribute: "#e0af68",
operator: "#89ddff",
};
// --- One (One Light / One Dark) ------------------------------------------
const oneLight: RolePalette = {
base: "#383a42",
keyword: "#a626a4",
comment: "#a0a1a7",
string: "#50a14f",
number: "#986801",
function: "#4078f2",
type: "#c18401",
tag: "#e45649",
attribute: "#986801",
operator: "#0184bc",
};
const oneDark: RolePalette = {
base: "#abb2bf",
keyword: "#c678dd",
comment: "#5c6370",
string: "#98c379",
number: "#d19a66",
function: "#61afef",
type: "#e5c07b",
tag: "#e06c75",
attribute: "#d19a66",
operator: "#56b6c2",
};
// --- Nord (dark only) ----------------------------------------------------
const nord: RolePalette = {
base: "#d8dee9",
keyword: "#81a1c1",
comment: "#616e88",
string: "#a3be8c",
number: "#b48ead",
function: "#88c0d0",
type: "#8fbcbb",
tag: "#81a1c1",
attribute: "#8fbcbb",
operator: "#81a1c1",
};
// --- Gruvbox (Light / Dark) ----------------------------------------------
const gruvboxLight: RolePalette = {
base: "#3c3836",
keyword: "#9d0006",
comment: "#928374",
string: "#79740e",
number: "#8f3f71",
function: "#427b58",
type: "#b57614",
tag: "#076678",
attribute: "#b57614",
operator: "#af3a03",
};
const gruvboxDark: RolePalette = {
base: "#ebdbb2",
keyword: "#fb4934",
comment: "#928374",
string: "#b8bb26",
number: "#d3869b",
function: "#8ec07c",
type: "#fabd2f",
tag: "#83a598",
attribute: "#fabd2f",
operator: "#fe8019",
};
// --- Solarized (Light / Dark — shared accents, different base/comment) ----
const solarizedLight: RolePalette = {
base: "#657b83",
keyword: "#859900",
comment: "#93a1a1",
string: "#2aa198",
number: "#d33682",
function: "#268bd2",
type: "#b58900",
tag: "#268bd2",
attribute: "#b58900",
operator: "#859900",
};
const solarizedDark: RolePalette = {
base: "#839496",
keyword: "#859900",
comment: "#586e75",
string: "#2aa198",
number: "#d33682",
function: "#268bd2",
type: "#b58900",
tag: "#268bd2",
attribute: "#b58900",
operator: "#859900",
};
export function isSyntaxThemeId(value: string): value is SyntaxThemeId {
return (SYNTAX_THEME_IDS as readonly string[]).includes(value);
}
// Resolve a theme id + the app's color scheme to a full token palette. Only the
// light/dark axis is coupled to the app; the theme brand is the user's choice.
export function resolveSyntaxColors(
id: SyntaxThemeId,
colorScheme: "light" | "dark",
): SyntaxColors {
const dark = colorScheme === "dark";
switch (id) {
case "github":
return dark ? darkHighlightColors : lightHighlightColors;
case "catppuccin":
return expandRolePalette(dark ? catppuccinMocha : catppuccinLatte);
case "dracula":
return expandRolePalette(dracula);
case "tokyo-night":
return expandRolePalette(dark ? tokyoNight : tokyoDay);
case "one":
return expandRolePalette(dark ? oneDark : oneLight);
case "nord":
return expandRolePalette(nord);
case "gruvbox":
return expandRolePalette(dark ? gruvboxDark : gruvboxLight);
case "solarized":
return expandRolePalette(dark ? solarizedDark : solarizedLight);
}
}