Files
paseo/packages/app/src/screens/settings/appearance/apply-appearance.test.ts
T
Mohamed BoudraandGitHub a1c8e1a1f9 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
2026-05-31 16:09:01 +08:00

177 lines
5.7 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
import { darkHighlightColors, resolveSyntaxColors } from "@getpaseo/highlight";
import { DEFAULT_UI_FONT_STACK } from "@/styles/theme";
import { applyAppearance, type AppearanceInput } from "./apply-appearance";
// Override the global react-native-unistyles mock (vitest.setup.ts) so that
// UnistylesRuntime.updateTheme is a spy that records (themeName, updater) calls.
const { updateTheme } = vi.hoisted(() => ({ updateTheme: vi.fn() }));
vi.mock("react-native-unistyles", () => ({ UnistylesRuntime: { updateTheme } }));
// The six registered Unistyles theme keys, in the order applyAppearance patches them.
const ALL_THEME_KEYS = [
"light",
"dark",
"darkZinc",
"darkMidnight",
"darkClaude",
"darkGhostty",
] as const;
// The signature of the updater passed to UnistylesRuntime.updateTheme.
type ThemeUpdater = (theme: FakeTheme) => FakeTheme;
// The subset of the theme shape the updater reads / spreads. The real Theme type
// is a frozen `as const` literal; the updater only touches these fields. Casting a
// fake of this shape through `unknown` to ThemeUpdater's param is test-only.
interface FakeTheme {
colorScheme: "light" | "dark";
fontFamily: { ui: string; mono: string };
fontSize: {
xs: number;
code: number;
sm: number;
base: number;
lg: number;
xl: number;
"2xl": number;
"3xl": number;
"4xl": number;
};
lineHeight: { diff: number };
colors: { foreground: string; syntax: Record<string, string> };
}
function makeFakeTheme(): FakeTheme {
return {
colorScheme: "dark",
fontFamily: { ui: "seed-ui-stack", mono: "seed-mono-stack" },
fontSize: {
xs: 12,
code: 12,
sm: 14,
base: 16,
lg: 18,
xl: 20,
"2xl": 22,
"3xl": 26,
"4xl": 34,
},
lineHeight: { diff: 22 },
colors: { foreground: "#fff", syntax: {} },
};
}
function makeInput(overrides: Partial<AppearanceInput> = {}): AppearanceInput {
return {
uiFontFamily: "",
monoFontFamily: "",
uiFontSize: 16,
codeFontSize: 12,
syntaxTheme: "github",
...overrides,
};
}
// Run a single captured updater (default the first) against a fresh fake theme.
function runCapturedUpdater(call = 0): FakeTheme {
const updater = updateTheme.mock.calls[call]?.[1] as unknown as ThemeUpdater;
return updater(makeFakeTheme());
}
describe("applyAppearance", () => {
beforeEach(() => {
updateTheme.mockClear();
});
it("patches every registered Unistyles theme exactly once", () => {
applyAppearance(makeInput());
expect(updateTheme).toHaveBeenCalledTimes(6);
expect(updateTheme.mock.calls.map((call) => call[0])).toEqual([...ALL_THEME_KEYS]);
});
it("resolves an empty UI font family to the default stack", () => {
applyAppearance(makeInput({ uiFontFamily: "" }));
expect(runCapturedUpdater().fontFamily.ui).toBe(DEFAULT_UI_FONT_STACK);
});
it("passes a non-empty UI font family through trimmed", () => {
applyAppearance(makeInput({ uiFontFamily: " Menlo " }));
expect(runCapturedUpdater().fontFamily.ui).toBe("Menlo");
});
it("scales the whole UI ramp proportionally while preserving ratios", () => {
applyAppearance(makeInput({ uiFontSize: 14 }));
const { fontSize } = runCapturedUpdater();
// r = 14 / 16 = 0.875
expect(fontSize.base).toBe(14); // round(16 * 0.875)
expect(fontSize.lg).toBe(16); // round(18 * 0.875) = round(15.75)
expect(fontSize.xs).toBe(11); // round(12 * 0.875) = round(10.5)
expect(fontSize["4xl"]).toBe(30); // round(34 * 0.875) = round(29.75)
});
it("derives the UI ramp from the canonical sizes, not the live theme (no compounding)", () => {
applyAppearance(makeInput({ uiFontSize: 14 }));
// Simulate a theme whose fontSize was already scaled by a prior apply; the
// updater must ignore it and rebuild from the authored FONT_SIZE ramp.
const updater = updateTheme.mock.calls[0]?.[1] as unknown as ThemeUpdater;
const alreadyScaled = makeFakeTheme();
alreadyScaled.fontSize = {
xs: 4,
code: 4,
sm: 4,
base: 4,
lg: 4,
xl: 4,
"2xl": 4,
"3xl": 4,
"4xl": 4,
};
const { fontSize } = updater(alreadyScaled);
expect(fontSize.base).toBe(14); // not 4 * 0.875 — rebuilt from FONT_SIZE
expect(fontSize.lg).toBe(16);
});
it("leaves the UI ramp at authored sizes when only the code size changes", () => {
applyAppearance(makeInput({ uiFontSize: 16, codeFontSize: 10 }));
const { fontSize } = runCapturedUpdater();
expect(fontSize.base).toBe(16);
expect(fontSize.sm).toBe(14);
expect(fontSize.code).toBe(10);
});
it("sets fontSize.code to codeFontSize regardless of the UI font size", () => {
applyAppearance(makeInput({ uiFontSize: 14, codeFontSize: 18 }));
expect(runCapturedUpdater().fontSize.code).toBe(18);
});
it("couples lineHeight.diff to the code font size", () => {
applyAppearance(makeInput({ codeFontSize: 18 }));
expect(runCapturedUpdater().lineHeight.diff).toBe(Math.round(18 * 1.5)); // 27
});
it("swaps colors.syntax to the resolved palette for the named theme", () => {
applyAppearance(makeInput({ syntaxTheme: "dracula" }));
const { colors } = runCapturedUpdater();
expect(colors.syntax).toEqual(resolveSyntaxColors("dracula", "dark"));
});
it("resolves a syntax theme using the theme's own color scheme", () => {
applyAppearance(makeInput({ syntaxTheme: "github" }));
// makeFakeTheme().colorScheme === "dark" -> github resolves to the dark palette.
expect(runCapturedUpdater().colors.syntax).toEqual(darkHighlightColors);
expect(runCapturedUpdater().colors.syntax).toEqual(resolveSyntaxColors("github", "dark"));
});
});