feat(app): add Pure black theme (#3012)
* feat(app): add AMOLED and all four Catppuccin themes Add an AMOLED theme (pure black background) and the four official Catppuccin flavors (Latte light, Frappé/Macchiato/Mocha dark) to the appearance picker. Generalize the dark-theme builder to support a light surface variant, register the new themes with Unistyles, and patch all registered theme keys when applying font/syntax appearance settings. * fix(app): localize Catppuccin + AMOLED theme labels across language packs The initial theme commit pasted the English brand/flavor labels into every locale. Localize them following each pack's convention: transliterate Catppuccin and the coffee-flavor names into Arabic/Russian scripts, use katakana/hanzi equivalents in ja/zh-CN, and standard loanwords in fr/es/pt-BR. AMOLED is a universal acronym and stays as-is except where the pack transliterates (Arabic). * fix(app): make Latte terminal ANSI colors readable * test(app): cover appearance theme picker options * refactor(app): scope theme PR to AMOLED * refactor(app): rename AMOLED theme to Pure Black * fix(app): refine Pure black appearance * test(app): wait for sidebar toggle icon * test(app): remove hover timing race --------- Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
@@ -60,6 +60,7 @@ function getThemeLabel(t: TFunction, value: AppSettings["theme"]): string {
|
||||
midnight: "settings.appearance.theme.options.midnight",
|
||||
claude: "settings.appearance.theme.options.claude",
|
||||
ghostty: "settings.appearance.theme.options.ghostty",
|
||||
pureBlack: "settings.appearance.theme.options.pureBlack",
|
||||
auto: "settings.appearance.theme.options.auto",
|
||||
};
|
||||
return t(labelKeys[value]);
|
||||
@@ -71,6 +72,7 @@ const DARK_VARIANT_THEMES: readonly AppSettings["theme"][] = [
|
||||
"midnight",
|
||||
"claude",
|
||||
"ghostty",
|
||||
"pureBlack",
|
||||
];
|
||||
|
||||
// Platform default stacks can be the bare native tokens ("normal"/"monospace");
|
||||
|
||||
@@ -5,10 +5,16 @@ 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 } }));
|
||||
const { runtime, updateTheme } = vi.hoisted(() => {
|
||||
const updateThemeSpy = vi.fn();
|
||||
return {
|
||||
runtime: { themeName: undefined as string | undefined, updateTheme: updateThemeSpy },
|
||||
updateTheme: updateThemeSpy,
|
||||
};
|
||||
});
|
||||
vi.mock("react-native-unistyles", () => ({ UnistylesRuntime: runtime }));
|
||||
|
||||
// The six registered Unistyles theme keys, in the order applyAppearance patches them.
|
||||
// The registered Unistyles theme keys, in the order applyAppearance patches them.
|
||||
const ALL_THEME_KEYS = [
|
||||
"light",
|
||||
"dark",
|
||||
@@ -16,6 +22,7 @@ const ALL_THEME_KEYS = [
|
||||
"darkMidnight",
|
||||
"darkClaude",
|
||||
"darkGhostty",
|
||||
"darkPureBlack",
|
||||
] as const;
|
||||
|
||||
// The signature of the updater passed to UnistylesRuntime.updateTheme.
|
||||
@@ -82,15 +89,27 @@ function runCapturedUpdater(call = 0): FakeTheme {
|
||||
describe("applyAppearance", () => {
|
||||
beforeEach(() => {
|
||||
updateTheme.mockClear();
|
||||
runtime.themeName = undefined;
|
||||
});
|
||||
|
||||
it("patches every registered Unistyles theme exactly once", () => {
|
||||
applyAppearance(makeInput());
|
||||
|
||||
expect(updateTheme).toHaveBeenCalledTimes(6);
|
||||
expect(updateTheme).toHaveBeenCalledTimes(ALL_THEME_KEYS.length);
|
||||
expect(updateTheme.mock.calls.map((call) => call[0])).toEqual([...ALL_THEME_KEYS]);
|
||||
});
|
||||
|
||||
it("patches the active theme before inactive registry entries", () => {
|
||||
runtime.themeName = "darkPureBlack";
|
||||
|
||||
applyAppearance(makeInput({ uiFontSize: 17 }));
|
||||
|
||||
expect(updateTheme.mock.calls.map((call) => call[0])).toEqual([
|
||||
"darkPureBlack",
|
||||
...ALL_THEME_KEYS.filter((key) => key !== "darkPureBlack"),
|
||||
]);
|
||||
});
|
||||
|
||||
it("resolves an empty UI font family to the default stack", () => {
|
||||
applyAppearance(makeInput({ uiFontFamily: "" }));
|
||||
|
||||
|
||||
@@ -8,9 +8,9 @@ import {
|
||||
} from "@/styles/theme";
|
||||
import { applyRootUiFont } from "./apply-root-font";
|
||||
|
||||
// All six registered Unistyles keys — pinned literal (greppable, type-checked).
|
||||
// The `as const` element types are exactly `keyof UnistylesThemes`, so each key
|
||||
// is assignable to `UnistylesRuntime.updateTheme`'s first argument with no cast.
|
||||
// All registered Unistyles keys — pinned literal (greppable, type-checked).
|
||||
// The `as const` element types stay assignable to
|
||||
// `UnistylesRuntime.updateTheme`'s first argument as themes are added.
|
||||
const ALL_THEME_KEYS = [
|
||||
"light",
|
||||
"dark",
|
||||
@@ -18,6 +18,7 @@ const ALL_THEME_KEYS = [
|
||||
"darkMidnight",
|
||||
"darkClaude",
|
||||
"darkGhostty",
|
||||
"darkPureBlack",
|
||||
] as const;
|
||||
|
||||
// The UI font size at which the FONT_SIZE ramp is authored (1.0 scale factor).
|
||||
@@ -57,25 +58,28 @@ function scaleFontSize(uiSize: number, codeSize: number): Theme["fontSize"] {
|
||||
|
||||
/**
|
||||
* Patch every registered Unistyles theme with the user's appearance choices.
|
||||
* All six keys are patched because the active theme can change and adaptive mode
|
||||
* can flip light/dark — patching all keys keeps the active key always current and
|
||||
* makes ordering vs `setTheme`/`setAdaptiveThemes` irrelevant.
|
||||
* All keys in `ALL_THEME_KEYS` are patched because the active theme can change
|
||||
* and adaptive mode can flip light/dark — patching all keys keeps the active key
|
||||
* always current and makes ordering vs `setTheme`/`setAdaptiveThemes` irrelevant.
|
||||
*
|
||||
* The updater preserves the active theme wholesale (surfaces, accents,
|
||||
* terminal) and only patches the font ramp and syntax palette.
|
||||
* `updateTheme` replaces the stored theme rather than merging, so we spread
|
||||
* `...t` first.
|
||||
*/
|
||||
export function applyAppearance(input: AppearanceInput): void {
|
||||
const ui = input.uiFontFamily.trim() || DEFAULT_UI_FONT_STACK;
|
||||
const mono = input.monoFontFamily.trim() || DEFAULT_MONO_FONT_STACK;
|
||||
const diffLineHeight = Math.round(input.codeFontSize * 1.5); // couple to code size
|
||||
const activeTheme = UnistylesRuntime.themeName;
|
||||
// Unistyles web emits after each registry patch. Updating the mounted theme
|
||||
// first ensures subscribers receive its new numeric tokens in this render;
|
||||
// updating it last makes Pure black appear one committed value behind.
|
||||
const themeKeys = activeTheme
|
||||
? [activeTheme, ...ALL_THEME_KEYS.filter((key) => key !== activeTheme)]
|
||||
: ALL_THEME_KEYS;
|
||||
|
||||
for (const key of ALL_THEME_KEYS) {
|
||||
// Spread `...t` first — `updateTheme` replaces the stored theme, it does not
|
||||
// merge; an omitted key would be dropped. `syntax` follows the theme's own
|
||||
// scheme for `auto`; named palettes ignore it. `colors.base`/plain text stays
|
||||
// `theme.colors.foreground` (owned by `syntaxTokenStyles.base`, not patched).
|
||||
//
|
||||
// Narrow on the `colorScheme` discriminant before spreading: the updater must
|
||||
// return the theme union, and a spread of the union widens `colorScheme` to
|
||||
// `"light" | "dark"`, assignable to neither concrete member. Each branch spreads
|
||||
// a single narrowed theme type.
|
||||
for (const key of themeKeys) {
|
||||
UnistylesRuntime.updateTheme(key, (t) => {
|
||||
const fontFamily = { ui, mono };
|
||||
const fontSize = scaleFontSize(input.uiFontSize, input.codeFontSize);
|
||||
|
||||
Reference in New Issue
Block a user