From 8c003351d0b8ac154511bd5bb408450ad22e931b Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 11 Aug 2026 14:09:44 +0200 Subject: [PATCH] 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. --- .../browser/appearance-theme-picker.spec.ts | 30 +++++ packages/app/src/app/_layout.tsx | 7 +- .../app/src/hooks/use-settings/storage.ts | 6 +- .../appearance/appearance-section.tsx | 56 +++----- .../appearance/apply-appearance.test.ts | 13 +- .../settings/appearance/apply-appearance.ts | 14 +- packages/app/src/styles/theme.test.ts | 24 +++- packages/app/src/styles/theme.ts | 123 +++++++++++++----- packages/app/src/styles/unistyles.ts | 30 +---- 9 files changed, 171 insertions(+), 132 deletions(-) diff --git a/packages/app/e2e/browser/appearance-theme-picker.spec.ts b/packages/app/e2e/browser/appearance-theme-picker.spec.ts index 818cc52ba..0d654f319 100644 --- a/packages/app/e2e/browser/appearance-theme-picker.spec.ts +++ b/packages/app/e2e/browser/appearance-theme-picker.spec.ts @@ -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 })); diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index a9a49e326..13fd94904 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -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(); diff --git a/packages/app/src/hooks/use-settings/storage.ts b/packages/app/src/hooks/use-settings/storage.ts index dbe180cc7..3b78310fa 100644 --- a/packages/app/src/hooks/use-settings/storage.ts +++ b/packages/app/src/hooks/use-settings/storage.ts @@ -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([...Object.keys(THEME_TO_UNISTYLES), "auto"]); +const VALID_THEMES = new Set(THEME_OPTIONS.map((option) => option.name)); const VALID_SERVICE_URL_BEHAVIORS = new Set(["ask", "in-app", "external"]); const VALID_WORKSPACE_TITLE_SOURCES = new Set(["title", "branch"]); const VALID_SIDEBAR_WORKSPACE_TRAILINGS = new Set([ @@ -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; diff --git a/packages/app/src/screens/settings/appearance/appearance-section.tsx b/packages/app/src/screens/settings/appearance/appearance-section.tsx index b6516bf90..8dfda2353 100644 --- a/packages/app/src/screens/settings/appearance/appearance-section.tsx +++ b/packages/app/src/screens/settings/appearance/appearance-section.tsx @@ -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 = { - 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 = new Set(["normal", "monospace"]); @@ -169,23 +151,21 @@ function ThemeRow({ value, onChange }: ThemeRowProps) { - {PRIMARY_THEMES.map((themeValue) => ( - - ))} - - {DARK_VARIANT_THEMES.map((themeValue) => ( - - ))} + {THEME_OPTIONS.map((option, index) => { + const previousOption = THEME_OPTIONS[index - 1]; + return ( + + {previousOption && previousOption.group !== option.group ? ( + + ) : null} + + + ); + })} diff --git a/packages/app/src/screens/settings/appearance/apply-appearance.test.ts b/packages/app/src/screens/settings/appearance/apply-appearance.test.ts index 3b6afddbd..3ce041fbd 100644 --- a/packages/app/src/screens/settings/appearance/apply-appearance.test.ts +++ b/packages/app/src/screens/settings/appearance/apply-appearance.test.ts @@ -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; diff --git a/packages/app/src/screens/settings/appearance/apply-appearance.ts b/packages/app/src/screens/settings/appearance/apply-appearance.ts index 90fe782fd..9f469d5c8 100644 --- a/packages/app/src/screens/settings/appearance/apply-appearance.ts +++ b/packages/app/src/screens/settings/appearance/apply-appearance.ts @@ -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 diff --git a/packages/app/src/styles/theme.test.ts b/packages/app/src/styles/theme.test.ts index daa600494..ea2164192 100644 --- a/packages/app/src/styles/theme.test.ts +++ b/packages/app/src/styles/theme.test.ts @@ -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"); diff --git a/packages/app/src/styles/theme.ts b/packages/app/src/styles/theme.ts index 709061a98..408ed87ae 100644 --- a/packages/app/src/styles/theme.ts +++ b/packages/app/src/styles/theme.ts @@ -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; +type ConcreteThemeOption = Exclude<(typeof THEME_OPTIONS)[number], { name: "auto" }>; +export type Theme = ConcreteThemeOption["theme"]; -export const THEME_TO_UNISTYLES: Record = { - 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["unistylesName"]; }; -export const THEME_SWATCHES: Record = { - light: "#ffffff", - dark: "#2D8B62", - zinc: "#808080", - midnight: "#4A6BA8", - claude: "#D97757", - ghostty: "#8caaee", - pureBlack: "#000000", +type ThemeSwatches = { + [Name in ThemeName]: Extract["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; +} diff --git a/packages/app/src/styles/unistyles.ts b/packages/app/src/styles/unistyles.ts index 46124d053..b679d5e69 100644 --- a/packages/app/src/styles/unistyles.ts +++ b/packages/app/src/styles/unistyles.ts @@ -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;