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