fix(app): keep theme selection consistent (#3214)

Theme registration, the appearance picker, persistence, and shortcut cycling drifted as new choices were added. Make one ordered catalog own those paths so Auto and Pure Black participate consistently, while lifting the Pure Black selected-workspace surface above its sidebar.
This commit is contained in:
Mohamed Boudra
2026-08-11 14:09:44 +02:00
committed by GitHub
parent 36a13601dd
commit 8c003351d0
9 changed files with 171 additions and 132 deletions
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Fragment, useCallback, useEffect, useMemo, useState } from "react";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { Text, TextInput, View, type PressableStateCallbackType } from "react-native";
@@ -32,6 +32,7 @@ import {
DEFAULT_MONO_FONT_STACK,
DEFAULT_UI_FONT_STACK,
ICON_SIZE,
THEME_OPTIONS,
THEME_SWATCHES,
type Theme,
} from "@/styles/theme";
@@ -53,28 +54,9 @@ const ThemedChevronDown = withUnistyles(ChevronDown);
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
function getThemeLabel(t: TFunction, value: AppSettings["theme"]): string {
const labelKeys: Record<AppSettings["theme"], string> = {
light: "settings.appearance.theme.options.light",
dark: "settings.appearance.theme.options.dark",
zinc: "settings.appearance.theme.options.zinc",
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]);
return t(`settings.appearance.theme.options.${value}`);
}
const PRIMARY_THEMES: readonly AppSettings["theme"][] = ["light", "dark", "auto"];
const DARK_VARIANT_THEMES: readonly AppSettings["theme"][] = [
"zinc",
"midnight",
"claude",
"ghostty",
"pureBlack",
];
// Platform default stacks can be the bare native tokens ("normal"/"monospace");
// those read as a bug, so show a human label in the placeholder instead.
const BARE_DEFAULT_STACKS: ReadonlySet<string> = new Set(["normal", "monospace"]);
@@ -169,23 +151,21 @@ function ThemeRow({ value, onChange }: ThemeRowProps) {
<ThemedChevronDown size={ICON_SIZE.sm} uniProps={mutedColorMapping} />
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" width={200}>
{PRIMARY_THEMES.map((themeValue) => (
<ThemeMenuItem
key={themeValue}
themeValue={themeValue}
selected={value === themeValue}
onChange={onChange}
/>
))}
<DropdownMenuSeparator />
{DARK_VARIANT_THEMES.map((themeValue) => (
<ThemeMenuItem
key={themeValue}
themeValue={themeValue}
selected={value === themeValue}
onChange={onChange}
/>
))}
{THEME_OPTIONS.map((option, index) => {
const previousOption = THEME_OPTIONS[index - 1];
return (
<Fragment key={option.name}>
{previousOption && previousOption.group !== option.group ? (
<DropdownMenuSeparator />
) : null}
<ThemeMenuItem
themeValue={option.name}
selected={value === option.name}
onChange={onChange}
/>
</Fragment>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</View>
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { darkHighlightColors, resolveSyntaxColors } from "@getpaseo/highlight";
import { DEFAULT_UI_FONT_STACK } from "@/styles/theme";
import { DEFAULT_UI_FONT_STACK, REGISTERED_THEMES } from "@/styles/theme";
import { applyAppearance, type AppearanceInput } from "./apply-appearance";
// Override the global react-native-unistyles mock (vitest.setup.ts) so that
@@ -14,16 +14,7 @@ const { runtime, updateTheme } = vi.hoisted(() => {
});
vi.mock("react-native-unistyles", () => ({ UnistylesRuntime: runtime }));
// The registered Unistyles theme keys, in the order applyAppearance patches them.
const ALL_THEME_KEYS = [
"light",
"dark",
"darkZinc",
"darkMidnight",
"darkClaude",
"darkGhostty",
"darkPureBlack",
] as const;
const ALL_THEME_KEYS = Object.keys(REGISTERED_THEMES);
// The signature of the updater passed to UnistylesRuntime.updateTheme.
type ThemeUpdater = (theme: FakeTheme) => FakeTheme;
@@ -4,22 +4,12 @@ import {
DEFAULT_UI_FONT_STACK,
DEFAULT_MONO_FONT_STACK,
FONT_SIZE,
REGISTERED_THEMES,
type Theme,
} from "@/styles/theme";
import { applyRootUiFont } from "./apply-root-font";
// 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",
"darkZinc",
"darkMidnight",
"darkClaude",
"darkGhostty",
"darkPureBlack",
] as const;
const ALL_THEME_KEYS = Object.keys(REGISTERED_THEMES) as (keyof typeof REGISTERED_THEMES)[];
// The UI font size at which the FONT_SIZE ramp is authored (1.0 scale factor).
const BASE_UI_REFERENCE = FONT_SIZE.base; // 16