* 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
177 lines
5.7 KiB
TypeScript
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"));
|
|
});
|
|
});
|