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,7 @@
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { seedWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import { openSettingsSection } from "../support/helpers/settings";
test("shows Pure black in the appearance picker", async ({ page }, testInfo) => {
@@ -15,6 +18,33 @@ test("shows Pure black in the appearance picker", async ({ page }, testInfo) =>
});
});
test("keeps the selected workspace visible in Pure black", async ({ page }, testInfo) => {
const workspace = await seedWorkspace({
repoPrefix: "pure-black-selected-workspace-",
title: "Selected workspace",
});
try {
await page.addInitScript(() => {
localStorage.setItem("@paseo:app-settings", JSON.stringify({ theme: "pureBlack" }));
});
await gotoAppShell(page);
const row = page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspace.workspaceId}`);
await expect(row).toBeVisible({ timeout: 30_000 });
await row.click();
await expect(row).toHaveAttribute("aria-selected", "true");
await expect(row).toHaveCSS("background-color", "rgb(22, 22, 22)");
await page.screenshot({
path: testInfo.outputPath("pure-black-selected-workspace.png"),
fullPage: true,
});
} finally {
await workspace.cleanup();
}
});
test("applies the interface font size to settings text", async ({ page }) => {
await page.addInitScript(() => {
localStorage.setItem("@paseo:app-settings", JSON.stringify({ uiFontSize: 24 }));
+2 -5
View File
@@ -107,7 +107,7 @@ import { getDaemonStartService } from "@/runtime/daemon-start-service";
import { applyAppearance } from "@/screens/settings/appearance/apply-appearance";
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
import { flushDraftPersistStorage } from "@/stores/draft-store";
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
import { getNextThemePreference, THEME_TO_UNISTYLES } from "@/styles/theme";
import { installWebScrollbarStyles } from "@/styles/install-web-scrollbar-styles";
import type { HostProfile } from "@/types/host-connection";
import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle";
@@ -424,7 +424,6 @@ interface AppContainerProps {
chromeEnabled?: boolean;
}
const THEME_CYCLE_ORDER: ThemeName[] = ["dark", "zinc", "midnight", "claude", "ghostty", "light"];
const WINDOW_SIDEBAR_TOGGLE_HORIZONTAL_PADDING = 12;
function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppContainerProps) {
@@ -444,9 +443,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
const { width: viewportWidth } = useWindowDimensions();
const cycleTheme = useCallback(() => {
const currentIndex = THEME_CYCLE_ORDER.indexOf(settings.theme as ThemeName);
const nextIndex = (currentIndex + 1) % THEME_CYCLE_ORDER.length;
void updateSettings({ theme: THEME_CYCLE_ORDER[nextIndex] });
void updateSettings({ theme: getNextThemePreference(settings.theme) });
}, [settings.theme, updateSettings]);
const isCompactLayout = useIsCompactFormFactor();
@@ -13,7 +13,7 @@ import {
parseSidebarRowItems,
type SidebarRowItems,
} from "@/components/sidebar/display-preferences/row-items";
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
import { THEME_OPTIONS, type ThemePreference } from "@/styles/theme";
export const APP_SETTINGS_KEY = "@paseo:app-settings";
export const APP_SETTINGS_QUERY_KEY = ["app-settings"];
@@ -27,7 +27,7 @@ export type WorkspaceTitleSource = "title" | "branch";
export type SidebarWorkspaceTrailing = "diff" | "timestamp" | "none";
export type ToolCallDetailLevel = "overview" | "detailed";
const VALID_THEMES = new Set<string>([...Object.keys(THEME_TO_UNISTYLES), "auto"]);
const VALID_THEMES = new Set<string>(THEME_OPTIONS.map((option) => option.name));
const VALID_SERVICE_URL_BEHAVIORS = new Set<ServiceUrlBehavior>(["ask", "in-app", "external"]);
const VALID_WORKSPACE_TITLE_SOURCES = new Set<WorkspaceTitleSource>(["title", "branch"]);
const VALID_SIDEBAR_WORKSPACE_TRAILINGS = new Set<SidebarWorkspaceTrailing>([
@@ -48,7 +48,7 @@ export const MAX_CODE_FONT_SIZE = 22; // line-height 1.5×22=33 stays safe
export const MAX_FONT_FAMILY_LENGTH = 200;
export interface AppSettings {
theme: ThemeName | "auto";
theme: ThemePreference;
language: AppLanguage;
sendBehavior: SendBehavior;
serviceUrlBehavior: ServiceUrlBehavior;
@@ -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}
/>
))}
{THEME_OPTIONS.map((option, index) => {
const previousOption = THEME_OPTIONS[index - 1];
return (
<Fragment key={option.name}>
{previousOption && previousOption.group !== option.group ? (
<DropdownMenuSeparator />
{DARK_VARIANT_THEMES.map((themeValue) => (
) : null}
<ThemeMenuItem
key={themeValue}
themeValue={themeValue}
selected={value === themeValue}
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
+23 -1
View File
@@ -1,5 +1,22 @@
import { describe, expect, it } from "vitest";
import { darkPureBlackTheme } from "./theme";
import { darkPureBlackTheme, getNextThemePreference, THEME_OPTIONS } from "./theme";
describe("Theme catalog", () => {
it("owns the picker and shortcut order", () => {
expect(THEME_OPTIONS.map((option) => option.name)).toEqual([
"light",
"dark",
"auto",
"zinc",
"midnight",
"claude",
"ghostty",
"pureBlack",
]);
expect(getNextThemePreference("dark")).toBe("auto");
expect(getNextThemePreference("pureBlack")).toBe("light");
});
});
describe("Pure black theme", () => {
it("uses a pure black application and terminal background", () => {
@@ -13,6 +30,11 @@ describe("Pure black theme", () => {
expect(darkPureBlackTheme.colors.accentBright).toBe("#7ccba0");
});
it("keeps selected sidebar rows distinct from the black sidebar", () => {
expect(darkPureBlackTheme.colors.surfaceSidebar).toBe("#000000");
expect(darkPureBlackTheme.colors.surfaceSidebarHover).toBe("#161616");
});
it("keeps ANSI black output readable on its zero-luminance terminal background", () => {
expect(darkPureBlackTheme.colors.terminal.black).toBe("#595959");
expect(darkPureBlackTheme.colors.terminal.brightBlack).toBe("#8a8a8a");
+88 -35
View File
@@ -107,8 +107,6 @@ export const baseColors = {
},
} as const;
export type ThemeName = "light" | "dark" | "zinc" | "midnight" | "claude" | "ghostty" | "pureBlack";
// Diff colors — the +/- inside a diff view, where the color *is* the signal and has to
// survive being scanned line by line, so it stays saturated. Light uses muted tones, dark
// uses the brighter palette values.
@@ -669,7 +667,8 @@ const pureBlackDarkColors = buildDarkSemanticColors({
surface4: "#2d2d2d",
surfaceDiffEmpty: "#0c0c0c",
surfaceSidebar: "#000000",
surfaceSidebarHover: "#0d0d0d",
// Selected sidebar rows share this surface with hover, so it must remain visible at rest.
surfaceSidebarHover: "#161616",
foregroundMuted: "#a1a1aa",
foregroundExtraMuted: "#71717a",
scrollbarHandle: "#71717a",
@@ -717,40 +716,94 @@ export const lightTheme = {
// Keep compatibility with existing code
export const theme = darkTheme;
export type Theme =
| typeof darkTheme
| typeof darkZincTheme
| typeof darkMidnightTheme
| typeof darkClaudeTheme
| typeof darkGhosttyTheme
| typeof darkPureBlackTheme
| typeof lightTheme;
export const THEME_OPTIONS = [
{
name: "light",
group: "primary",
unistylesName: "light",
theme: lightTheme,
swatch: "#ffffff",
},
{
name: "dark",
group: "primary",
unistylesName: "dark",
theme: darkTheme,
swatch: "#2D8B62",
},
{ name: "auto", group: "primary" },
{
name: "zinc",
group: "variant",
unistylesName: "darkZinc",
theme: darkZincTheme,
swatch: "#808080",
},
{
name: "midnight",
group: "variant",
unistylesName: "darkMidnight",
theme: darkMidnightTheme,
swatch: "#4A6BA8",
},
{
name: "claude",
group: "variant",
unistylesName: "darkClaude",
theme: darkClaudeTheme,
swatch: "#D97757",
},
{
name: "ghostty",
group: "variant",
unistylesName: "darkGhostty",
theme: darkGhosttyTheme,
swatch: "#8caaee",
},
{
name: "pureBlack",
group: "variant",
unistylesName: "darkPureBlack",
theme: darkPureBlackTheme,
swatch: "#000000",
},
] as const;
type UnistylesThemeKey =
| "light"
| "dark"
| "darkZinc"
| "darkMidnight"
| "darkClaude"
| "darkGhostty"
| "darkPureBlack";
export type ThemePreference = (typeof THEME_OPTIONS)[number]["name"];
export type ThemeName = Exclude<ThemePreference, "auto">;
type ConcreteThemeOption = Exclude<(typeof THEME_OPTIONS)[number], { name: "auto" }>;
export type Theme = ConcreteThemeOption["theme"];
export const THEME_TO_UNISTYLES: Record<ThemeName, UnistylesThemeKey> = {
light: "light",
dark: "dark",
zinc: "darkZinc",
midnight: "darkMidnight",
claude: "darkClaude",
ghostty: "darkGhostty",
pureBlack: "darkPureBlack",
const CONCRETE_THEME_OPTIONS = THEME_OPTIONS.filter(
(option): option is ConcreteThemeOption => option.name !== "auto",
);
type ThemeToUnistyles = {
[Name in ThemeName]: Extract<ConcreteThemeOption, { name: Name }>["unistylesName"];
};
export const THEME_SWATCHES: Record<ThemeName, string> = {
light: "#ffffff",
dark: "#2D8B62",
zinc: "#808080",
midnight: "#4A6BA8",
claude: "#D97757",
ghostty: "#8caaee",
pureBlack: "#000000",
type ThemeSwatches = {
[Name in ThemeName]: Extract<ConcreteThemeOption, { name: Name }>["swatch"];
};
type RegisteredThemes = {
[Option in ConcreteThemeOption as Option["unistylesName"]]: Option["theme"];
};
export const THEME_TO_UNISTYLES = Object.fromEntries(
CONCRETE_THEME_OPTIONS.map((option) => [option.name, option.unistylesName]),
) as ThemeToUnistyles;
export const THEME_SWATCHES = Object.fromEntries(
CONCRETE_THEME_OPTIONS.map((option) => [option.name, option.swatch]),
) as ThemeSwatches;
export const REGISTERED_THEMES = Object.fromEntries(
CONCRETE_THEME_OPTIONS.map((option) => [option.unistylesName, option.theme]),
) as RegisteredThemes;
export function getNextThemePreference(current: ThemePreference): ThemePreference {
const currentIndex = THEME_OPTIONS.findIndex((option) => option.name === current);
const nextIndex = (currentIndex + 1) % THEME_OPTIONS.length;
return THEME_OPTIONS[nextIndex]?.name ?? THEME_OPTIONS[0].name;
}
+3 -27
View File
@@ -1,24 +1,8 @@
import { StyleSheet } from "react-native-unistyles";
import {
lightTheme,
darkTheme,
darkZincTheme,
darkMidnightTheme,
darkClaudeTheme,
darkGhosttyTheme,
darkPureBlackTheme,
} from "./theme";
import { REGISTERED_THEMES } from "./theme";
StyleSheet.configure({
themes: {
light: lightTheme,
dark: darkTheme,
darkZinc: darkZincTheme,
darkMidnight: darkMidnightTheme,
darkClaude: darkClaudeTheme,
darkGhostty: darkGhosttyTheme,
darkPureBlack: darkPureBlackTheme,
},
themes: REGISTERED_THEMES,
breakpoints: {
xs: 0,
sm: 576,
@@ -32,15 +16,7 @@ StyleSheet.configure({
});
// Type augmentation for TypeScript
interface AppThemes {
light: typeof lightTheme;
dark: typeof darkTheme;
darkZinc: typeof darkZincTheme;
darkMidnight: typeof darkMidnightTheme;
darkClaude: typeof darkClaudeTheme;
darkGhostty: typeof darkGhosttyTheme;
darkPureBlack: typeof darkPureBlackTheme;
}
type AppThemes = typeof REGISTERED_THEMES;
interface AppBreakpoints {
xs: number;