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;