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:
shrimp
2026-08-09 01:12:53 +02:00
committed by GitHub
co-authored by Mohamed Boudra
parent 8f38d04919
commit c28836b4e5
25 changed files with 228 additions and 51 deletions
@@ -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);