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 { 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"; import { openSettingsSection } from "../support/helpers/settings";
test("shows Pure black in the appearance picker", async ({ page }, testInfo) => { 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 }) => { test("applies the interface font size to settings text", async ({ page }) => {
await page.addInitScript(() => { await page.addInitScript(() => {
localStorage.setItem("@paseo:app-settings", JSON.stringify({ uiFontSize: 24 })); 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 { applyAppearance } from "@/screens/settings/appearance/apply-appearance";
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store"; import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
import { flushDraftPersistStorage } from "@/stores/draft-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 { installWebScrollbarStyles } from "@/styles/install-web-scrollbar-styles";
import type { HostProfile } from "@/types/host-connection"; import type { HostProfile } from "@/types/host-connection";
import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle"; import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle";
@@ -424,7 +424,6 @@ interface AppContainerProps {
chromeEnabled?: boolean; chromeEnabled?: boolean;
} }
const THEME_CYCLE_ORDER: ThemeName[] = ["dark", "zinc", "midnight", "claude", "ghostty", "light"];
const WINDOW_SIDEBAR_TOGGLE_HORIZONTAL_PADDING = 12; const WINDOW_SIDEBAR_TOGGLE_HORIZONTAL_PADDING = 12;
function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppContainerProps) { function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppContainerProps) {
@@ -444,9 +443,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
const { width: viewportWidth } = useWindowDimensions(); const { width: viewportWidth } = useWindowDimensions();
const cycleTheme = useCallback(() => { const cycleTheme = useCallback(() => {
const currentIndex = THEME_CYCLE_ORDER.indexOf(settings.theme as ThemeName); void updateSettings({ theme: getNextThemePreference(settings.theme) });
const nextIndex = (currentIndex + 1) % THEME_CYCLE_ORDER.length;
void updateSettings({ theme: THEME_CYCLE_ORDER[nextIndex] });
}, [settings.theme, updateSettings]); }, [settings.theme, updateSettings]);
const isCompactLayout = useIsCompactFormFactor(); const isCompactLayout = useIsCompactFormFactor();
@@ -13,7 +13,7 @@ import {
parseSidebarRowItems, parseSidebarRowItems,
type SidebarRowItems, type SidebarRowItems,
} from "@/components/sidebar/display-preferences/row-items"; } 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_KEY = "@paseo:app-settings";
export const APP_SETTINGS_QUERY_KEY = ["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 SidebarWorkspaceTrailing = "diff" | "timestamp" | "none";
export type ToolCallDetailLevel = "overview" | "detailed"; 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_SERVICE_URL_BEHAVIORS = new Set<ServiceUrlBehavior>(["ask", "in-app", "external"]);
const VALID_WORKSPACE_TITLE_SOURCES = new Set<WorkspaceTitleSource>(["title", "branch"]); const VALID_WORKSPACE_TITLE_SOURCES = new Set<WorkspaceTitleSource>(["title", "branch"]);
const VALID_SIDEBAR_WORKSPACE_TRAILINGS = new Set<SidebarWorkspaceTrailing>([ 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 const MAX_FONT_FAMILY_LENGTH = 200;
export interface AppSettings { export interface AppSettings {
theme: ThemeName | "auto"; theme: ThemePreference;
language: AppLanguage; language: AppLanguage;
sendBehavior: SendBehavior; sendBehavior: SendBehavior;
serviceUrlBehavior: ServiceUrlBehavior; 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 type { TFunction } from "i18next";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Text, TextInput, View, type PressableStateCallbackType } from "react-native"; import { Text, TextInput, View, type PressableStateCallbackType } from "react-native";
@@ -32,6 +32,7 @@ import {
DEFAULT_MONO_FONT_STACK, DEFAULT_MONO_FONT_STACK,
DEFAULT_UI_FONT_STACK, DEFAULT_UI_FONT_STACK,
ICON_SIZE, ICON_SIZE,
THEME_OPTIONS,
THEME_SWATCHES, THEME_SWATCHES,
type Theme, type Theme,
} from "@/styles/theme"; } from "@/styles/theme";
@@ -53,28 +54,9 @@ const ThemedChevronDown = withUnistyles(ChevronDown);
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
function getThemeLabel(t: TFunction, value: AppSettings["theme"]): string { function getThemeLabel(t: TFunction, value: AppSettings["theme"]): string {
const labelKeys: Record<AppSettings["theme"], string> = { return t(`settings.appearance.theme.options.${value}`);
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]);
} }
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"); // 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. // those read as a bug, so show a human label in the placeholder instead.
const BARE_DEFAULT_STACKS: ReadonlySet<string> = new Set(["normal", "monospace"]); 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} /> <ThemedChevronDown size={ICON_SIZE.sm} uniProps={mutedColorMapping} />
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" width={200}> <DropdownMenuContent side="bottom" align="end" width={200}>
{PRIMARY_THEMES.map((themeValue) => ( {THEME_OPTIONS.map((option, index) => {
<ThemeMenuItem const previousOption = THEME_OPTIONS[index - 1];
key={themeValue} return (
themeValue={themeValue} <Fragment key={option.name}>
selected={value === themeValue} {previousOption && previousOption.group !== option.group ? (
onChange={onChange} <DropdownMenuSeparator />
/> ) : null}
))} <ThemeMenuItem
<DropdownMenuSeparator /> themeValue={option.name}
{DARK_VARIANT_THEMES.map((themeValue) => ( selected={value === option.name}
<ThemeMenuItem onChange={onChange}
key={themeValue} />
themeValue={themeValue} </Fragment>
selected={value === themeValue} );
onChange={onChange} })}
/>
))}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</View> </View>
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { darkHighlightColors, resolveSyntaxColors } from "@getpaseo/highlight"; 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"; import { applyAppearance, type AppearanceInput } from "./apply-appearance";
// Override the global react-native-unistyles mock (vitest.setup.ts) so that // 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 })); vi.mock("react-native-unistyles", () => ({ UnistylesRuntime: runtime }));
// The registered Unistyles theme keys, in the order applyAppearance patches them. const ALL_THEME_KEYS = Object.keys(REGISTERED_THEMES);
const ALL_THEME_KEYS = [
"light",
"dark",
"darkZinc",
"darkMidnight",
"darkClaude",
"darkGhostty",
"darkPureBlack",
] as const;
// The signature of the updater passed to UnistylesRuntime.updateTheme. // The signature of the updater passed to UnistylesRuntime.updateTheme.
type ThemeUpdater = (theme: FakeTheme) => FakeTheme; type ThemeUpdater = (theme: FakeTheme) => FakeTheme;
@@ -4,22 +4,12 @@ import {
DEFAULT_UI_FONT_STACK, DEFAULT_UI_FONT_STACK,
DEFAULT_MONO_FONT_STACK, DEFAULT_MONO_FONT_STACK,
FONT_SIZE, FONT_SIZE,
REGISTERED_THEMES,
type Theme, type Theme,
} from "@/styles/theme"; } from "@/styles/theme";
import { applyRootUiFont } from "./apply-root-font"; import { applyRootUiFont } from "./apply-root-font";
// All registered Unistyles keys — pinned literal (greppable, type-checked). const ALL_THEME_KEYS = Object.keys(REGISTERED_THEMES) as (keyof typeof REGISTERED_THEMES)[];
// 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;
// The UI font size at which the FONT_SIZE ramp is authored (1.0 scale factor). // The UI font size at which the FONT_SIZE ramp is authored (1.0 scale factor).
const BASE_UI_REFERENCE = FONT_SIZE.base; // 16 const BASE_UI_REFERENCE = FONT_SIZE.base; // 16
+23 -1
View File
@@ -1,5 +1,22 @@
import { describe, expect, it } from "vitest"; 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", () => { describe("Pure black theme", () => {
it("uses a pure black application and terminal background", () => { it("uses a pure black application and terminal background", () => {
@@ -13,6 +30,11 @@ describe("Pure black theme", () => {
expect(darkPureBlackTheme.colors.accentBright).toBe("#7ccba0"); 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", () => { it("keeps ANSI black output readable on its zero-luminance terminal background", () => {
expect(darkPureBlackTheme.colors.terminal.black).toBe("#595959"); expect(darkPureBlackTheme.colors.terminal.black).toBe("#595959");
expect(darkPureBlackTheme.colors.terminal.brightBlack).toBe("#8a8a8a"); expect(darkPureBlackTheme.colors.terminal.brightBlack).toBe("#8a8a8a");
+88 -35
View File
@@ -107,8 +107,6 @@ export const baseColors = {
}, },
} as const; } 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 // 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 // survive being scanned line by line, so it stays saturated. Light uses muted tones, dark
// uses the brighter palette values. // uses the brighter palette values.
@@ -669,7 +667,8 @@ const pureBlackDarkColors = buildDarkSemanticColors({
surface4: "#2d2d2d", surface4: "#2d2d2d",
surfaceDiffEmpty: "#0c0c0c", surfaceDiffEmpty: "#0c0c0c",
surfaceSidebar: "#000000", surfaceSidebar: "#000000",
surfaceSidebarHover: "#0d0d0d", // Selected sidebar rows share this surface with hover, so it must remain visible at rest.
surfaceSidebarHover: "#161616",
foregroundMuted: "#a1a1aa", foregroundMuted: "#a1a1aa",
foregroundExtraMuted: "#71717a", foregroundExtraMuted: "#71717a",
scrollbarHandle: "#71717a", scrollbarHandle: "#71717a",
@@ -717,40 +716,94 @@ export const lightTheme = {
// Keep compatibility with existing code // Keep compatibility with existing code
export const theme = darkTheme; export const theme = darkTheme;
export type Theme = export const THEME_OPTIONS = [
| typeof darkTheme {
| typeof darkZincTheme name: "light",
| typeof darkMidnightTheme group: "primary",
| typeof darkClaudeTheme unistylesName: "light",
| typeof darkGhosttyTheme theme: lightTheme,
| typeof darkPureBlackTheme swatch: "#ffffff",
| typeof lightTheme; },
{
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 = export type ThemePreference = (typeof THEME_OPTIONS)[number]["name"];
| "light" export type ThemeName = Exclude<ThemePreference, "auto">;
| "dark" type ConcreteThemeOption = Exclude<(typeof THEME_OPTIONS)[number], { name: "auto" }>;
| "darkZinc" export type Theme = ConcreteThemeOption["theme"];
| "darkMidnight"
| "darkClaude"
| "darkGhostty"
| "darkPureBlack";
export const THEME_TO_UNISTYLES: Record<ThemeName, UnistylesThemeKey> = { const CONCRETE_THEME_OPTIONS = THEME_OPTIONS.filter(
light: "light", (option): option is ConcreteThemeOption => option.name !== "auto",
dark: "dark", );
zinc: "darkZinc",
midnight: "darkMidnight", type ThemeToUnistyles = {
claude: "darkClaude", [Name in ThemeName]: Extract<ConcreteThemeOption, { name: Name }>["unistylesName"];
ghostty: "darkGhostty",
pureBlack: "darkPureBlack",
}; };
export const THEME_SWATCHES: Record<ThemeName, string> = { type ThemeSwatches = {
light: "#ffffff", [Name in ThemeName]: Extract<ConcreteThemeOption, { name: Name }>["swatch"];
dark: "#2D8B62",
zinc: "#808080",
midnight: "#4A6BA8",
claude: "#D97757",
ghostty: "#8caaee",
pureBlack: "#000000",
}; };
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 { StyleSheet } from "react-native-unistyles";
import { import { REGISTERED_THEMES } from "./theme";
lightTheme,
darkTheme,
darkZincTheme,
darkMidnightTheme,
darkClaudeTheme,
darkGhosttyTheme,
darkPureBlackTheme,
} from "./theme";
StyleSheet.configure({ StyleSheet.configure({
themes: { themes: REGISTERED_THEMES,
light: lightTheme,
dark: darkTheme,
darkZinc: darkZincTheme,
darkMidnight: darkMidnightTheme,
darkClaude: darkClaudeTheme,
darkGhostty: darkGhosttyTheme,
darkPureBlack: darkPureBlackTheme,
},
breakpoints: { breakpoints: {
xs: 0, xs: 0,
sm: 576, sm: 576,
@@ -32,15 +16,7 @@ StyleSheet.configure({
}); });
// Type augmentation for TypeScript // Type augmentation for TypeScript
interface AppThemes { type AppThemes = typeof REGISTERED_THEMES;
light: typeof lightTheme;
dark: typeof darkTheme;
darkZinc: typeof darkZincTheme;
darkMidnight: typeof darkMidnightTheme;
darkClaude: typeof darkClaudeTheme;
darkGhostty: typeof darkGhosttyTheme;
darkPureBlack: typeof darkPureBlackTheme;
}
interface AppBreakpoints { interface AppBreakpoints {
xs: number; xs: number;