From c28836b4e58091bb6ed8f15916305364a25422c3 Mon Sep 17 00:00:00 2001 From: shrimp <55786013+shrimpwtf@users.noreply.github.com> Date: Sun, 9 Aug 2026 03:12:53 +0400 Subject: [PATCH] feat(app): add Pure black theme (#3012) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(app): add AMOLED and all four Catppuccin themes Add an AMOLED theme (pure black background) and the four official Catppuccin flavors (Latte light, Frappé/Macchiato/Mocha dark) to the appearance picker. Generalize the dark-theme builder to support a light surface variant, register the new themes with Unistyles, and patch all registered theme keys when applying font/syntax appearance settings. * fix(app): localize Catppuccin + AMOLED theme labels across language packs The initial theme commit pasted the English brand/flavor labels into every locale. Localize them following each pack's convention: transliterate Catppuccin and the coffee-flavor names into Arabic/Russian scripts, use katakana/hanzi equivalents in ja/zh-CN, and standard loanwords in fr/es/pt-BR. AMOLED is a universal acronym and stays as-is except where the pack transliterates (Arabic). * fix(app): make Latte terminal ANSI colors readable * test(app): cover appearance theme picker options * refactor(app): scope theme PR to AMOLED * refactor(app): rename AMOLED theme to Pure Black * fix(app): refine Pure black appearance * test(app): wait for sidebar toggle icon * test(app): remove hover timing race --------- Co-authored-by: Mohamed Boudra --- docs/unistyles.md | 2 +- .../browser/appearance-theme-picker.spec.ts | 36 ++++++++++++ .../e2e/browser/sidebar-resize-handle.spec.ts | 1 - .../app/e2e/browser/sidebar-workspace.spec.ts | 8 ++- packages/app/src/app/_layout.tsx | 5 +- .../app/src/components/markdown/renderer.tsx | 7 +-- .../app/src/components/tool-call-details.tsx | 11 +--- .../components/ui/scrollable-code-surface.tsx | 4 +- packages/app/src/i18n/resources/ar.ts | 1 + packages/app/src/i18n/resources/en.ts | 1 + packages/app/src/i18n/resources/es.ts | 1 + packages/app/src/i18n/resources/fr.ts | 1 + packages/app/src/i18n/resources/ja.ts | 1 + packages/app/src/i18n/resources/ko.ts | 1 + packages/app/src/i18n/resources/pt-BR.ts | 1 + packages/app/src/i18n/resources/ru.ts | 1 + packages/app/src/i18n/resources/zh-CN.ts | 1 + .../appearance/appearance-section.tsx | 2 + .../appearance/apply-appearance.test.ts | 27 +++++++-- .../settings/appearance/apply-appearance.ts | 36 ++++++------ packages/app/src/styles/theme.test.ts | 20 +++++++ packages/app/src/styles/theme.ts | 57 +++++++++++++++++-- packages/app/src/styles/unistyles.ts | 3 + .../e2e/appearance-font-size.electron.mjs | 48 ++++++++++++++++ packages/desktop/e2e/browser-tabs.e2e.mjs | 3 + 25 files changed, 228 insertions(+), 51 deletions(-) create mode 100644 packages/app/e2e/browser/appearance-theme-picker.spec.ts create mode 100644 packages/app/src/styles/theme.test.ts create mode 100644 packages/desktop/e2e/appearance-font-size.electron.mjs diff --git a/docs/unistyles.md b/docs/unistyles.md index 7d54a3b4e..c3a66c4f6 100644 --- a/docs/unistyles.md +++ b/docs/unistyles.md @@ -345,7 +345,7 @@ Gotchas: - **Narrow the discriminated union before spreading.** `updateTheme`'s updater returns the theme union; spreading the union widens `colorScheme` to `"light" | "dark"`, which is assignable to neither concrete member. Branch on `t.colorScheme` so each branch spreads a single narrowed theme type (no `as`). - **`lineHeight.diff` is the code/diff line-height axis** — it is coupled to the code-font-size control (≈ `codeFontSize * 1.5`). Do NOT use it for prose. Markdown body line-height scales with the UI ramp (`Math.round(theme.fontSize.base * 1.4)`); routing prose through `lineHeight.diff` clips text at small code sizes. - **High-churn draft values** (live-while-typing in the appearance preview) bypass the theme: apply them as inline styles marked with `inlineUnistylesStyle` so per-keystroke values don't grow the `#unistyles-web` CSS registry. -- **Mounted parsed content uses `AppearanceStyleBoundary`.** Markdown, syntax-highlighted code, and tool-call detail bodies can contain memoized/custom renderer trees that do not naturally re-run when runtime-patched appearance tokens change. Wrap the parsed surface once with `packages/app/src/components/appearance-style-boundary.tsx`; do not add local "appearance key" props at each callsite. +- **The app shell uses one `AppearanceStyleBoundary`.** Runtime-patched numeric theme values are baked into Unistyles web classes rather than CSS variables, while parsed/memoized content also does not naturally re-run when appearance tokens change. The boundary sits below stable runtime providers in `app/_layout.tsx` and remounts the visual shell once. `applyAppearance` patches the active theme before inactive registry entries so its subscribers receive the committed values in the same update. Do not add local appearance keys or nested boundaries. - **Dynamic font tokens stay widened.** `fontFamily`, `fontSize`, and `lineHeight` on `commonTheme` are annotated `string`/`number` (not narrowed by `as const`) so the updater's return assigns; the platform default stacks live in `DEFAULT_UI_FONT_STACK` / `DEFAULT_MONO_FONT_STACK`. ## Debugging diff --git a/packages/app/e2e/browser/appearance-theme-picker.spec.ts b/packages/app/e2e/browser/appearance-theme-picker.spec.ts new file mode 100644 index 000000000..818cc52ba --- /dev/null +++ b/packages/app/e2e/browser/appearance-theme-picker.spec.ts @@ -0,0 +1,36 @@ +import { expect, test } from "../support/fixtures"; +import { openSettingsSection } from "../support/helpers/settings"; + +test("shows Pure black in the appearance picker", async ({ page }, testInfo) => { + await page.goto("/settings"); + await expect(page.getByTestId("settings-sidebar")).toBeVisible(); + await openSettingsSection(page, "appearance"); + + const themeTrigger = page.getByLabel("Theme: System", { exact: true }); + await themeTrigger.click(); + await expect(page.getByText("Pure black", { exact: true })).toBeVisible(); + await page.screenshot({ + path: testInfo.outputPath("appearance-theme-picker.png"), + fullPage: true, + }); +}); + +test("applies the interface font size to settings text", async ({ page }) => { + await page.addInitScript(() => { + localStorage.setItem("@paseo:app-settings", JSON.stringify({ uiFontSize: 24 })); + }); + await page.goto("/settings"); + await expect(page.getByTestId("settings-sidebar")).toBeVisible(); + await openSettingsSection(page, "appearance"); + + const sectionTitle = page.getByText("Theme", { exact: true }).first(); + await expect(sectionTitle).toHaveCSS("font-size", "18px"); + + const fontSizeInput = page.getByLabel("Interface font size"); + await expect(fontSizeInput).toHaveValue("24"); + await fontSizeInput.fill("12"); + await fontSizeInput.press("Tab"); + + await expect(fontSizeInput).toHaveValue("12"); + await expect(sectionTitle).toHaveCSS("font-size", "9px"); +}); diff --git a/packages/app/e2e/browser/sidebar-resize-handle.spec.ts b/packages/app/e2e/browser/sidebar-resize-handle.spec.ts index 7df4c64f6..5859ca0e1 100644 --- a/packages/app/e2e/browser/sidebar-resize-handle.spec.ts +++ b/packages/app/e2e/browser/sidebar-resize-handle.spec.ts @@ -8,7 +8,6 @@ async function expectBorderHighlight(page: Page, testID: string) { await expect(page.getByTestId(`${testID}-highlight`)).toHaveCount(0); await handle.hover(); - await expect(page.getByTestId(`${testID}-highlight`)).toHaveCount(0); const highlight = page.getByTestId(`${testID}-highlight`); await expect(highlight).toBeVisible(); diff --git a/packages/app/e2e/browser/sidebar-workspace.spec.ts b/packages/app/e2e/browser/sidebar-workspace.spec.ts index 64d808d9d..28d8236ad 100644 --- a/packages/app/e2e/browser/sidebar-workspace.spec.ts +++ b/packages/app/e2e/browser/sidebar-workspace.spec.ts @@ -304,7 +304,9 @@ test.describe("Half-screen desktop layout", () => { await gotoAppShell(page); const openToggle = page.getByTestId("menu-button"); - const openBounds = await openToggle.locator("svg").first().boundingBox(); + const openIcon = openToggle.locator("svg").first(); + await expect(openIcon).toBeVisible(); + const openBounds = await openIcon.boundingBox(); expect(openBounds).not.toBeNull(); expect(openBounds?.x).toBeGreaterThan(12); @@ -312,7 +314,9 @@ test.describe("Half-screen desktop layout", () => { await expect(page.getByTestId("sidebar-global-new-workspace")).not.toBeVisible(); const closedToggle = page.getByTestId("menu-button"); - const closedBounds = await closedToggle.locator("svg").first().boundingBox(); + const closedIcon = closedToggle.locator("svg").first(); + await expect(closedIcon).toBeVisible(); + const closedBounds = await closedIcon.boundingBox(); expect(closedBounds).not.toBeNull(); expect(closedBounds?.x).toBeCloseTo(12, 0); expect(closedBounds?.y).toBe(openBounds?.y); diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 7cdaf4b95..a9a49e326 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -26,6 +26,7 @@ import { CommandCenterRootActions } from "@/command-center/root-registration"; import { CommandCenterProvider } from "@/command-center/provider"; import { CommandCenterWorkspaceActions } from "@/command-center/workspace-registration"; import { AddProjectFlowHost } from "@/components/add-project-flow-host"; +import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary"; import { WorktreeSetupCalloutSource } from "@/components/worktree-setup-callout-source"; import { DownloadToast } from "@/components/download-toast"; import { QuittingOverlay } from "@/components/quitting-overlay"; @@ -643,7 +644,7 @@ function ProvidersWrapper({ children }: { children: ReactNode }) { // Apply font / size / syntax appearance settings on mount and when they change. // Sibling to the theme effect above; order is irrelevant because both patch all - // six registered theme keys, so the active key is always current. + // registered theme keys, so the active key is always current. useEffect(() => { if (settingsLoading) return; applyAppearance({ @@ -668,7 +669,7 @@ function ProvidersWrapper({ children }: { children: ReactNode }) { - {children} + {children} ); } diff --git a/packages/app/src/components/markdown/renderer.tsx b/packages/app/src/components/markdown/renderer.tsx index 6c1eb4e01..292ea6f76 100644 --- a/packages/app/src/components/markdown/renderer.tsx +++ b/packages/app/src/components/markdown/renderer.tsx @@ -22,7 +22,6 @@ import Markdown, { type RenderRules, } from "react-native-markdown-display"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary"; import { HighlightedCodeBlock } from "@/components/highlighted-code-block"; import { MarkdownParagraphView, MarkdownTextSpan } from "@/components/markdown-text"; import { MarkdownTableCellText } from "@/components/markdown-text-selection"; @@ -110,11 +109,7 @@ export function MarkdownRenderer({ ], ); - return ( - - - - ); + return ; } type MarkdownPartRendererProps = Omit & { diff --git a/packages/app/src/components/tool-call-details.tsx b/packages/app/src/components/tool-call-details.tsx index 72ea48cef..41006c4f2 100644 --- a/packages/app/src/components/tool-call-details.tsx +++ b/packages/app/src/components/tool-call-details.tsx @@ -10,7 +10,6 @@ import { ScrollView as GHScrollView } from "react-native-gesture-handler"; import { StyleSheet } from "react-native-unistyles"; import type { TFunction } from "i18next"; import { useTranslation } from "react-i18next"; -import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary"; import type { ToolCallDetail } from "@getpaseo/protocol/agent-types"; import { buildLineDiff, parseUnifiedDiff, type DiffLine } from "@/utils/tool-call-parsers"; import { highlightDiffLines } from "@/utils/diff-highlight"; @@ -741,15 +740,7 @@ function LoadingSkeleton({ containerStyle }: { containerStyle: StyleProp - - - ); -} - -function ToolCallDetailsContentInner({ +export function ToolCallDetailsContent({ detail, errorText, maxHeight, diff --git a/packages/app/src/components/ui/scrollable-code-surface.tsx b/packages/app/src/components/ui/scrollable-code-surface.tsx index ab900f9ac..e55093b9d 100644 --- a/packages/app/src/components/ui/scrollable-code-surface.tsx +++ b/packages/app/src/components/ui/scrollable-code-surface.tsx @@ -9,7 +9,6 @@ import { } from "react-native"; import { StyleSheet } from "react-native-unistyles"; -import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary"; import { isWeb } from "@/constants/platform"; import { CODE_SURFACE_DATASET } from "@/styles/code-surface"; import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; @@ -56,7 +55,7 @@ export function SurfaceCard({ [bordered, style, surfaceStyle], ); - const surface = ( + return ( ); - return {surface}; } export function ScrollableCodeSurface({ diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 80c367799..c29f45b3f 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1874,6 +1874,7 @@ export const ar: TranslationResources = { midnight: "منتصف الليل", claude: "كلود", ghostty: "شبحي", + pureBlack: "أسود خالص", auto: "نظام", }, }, diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 71c3e143a..897107ebc 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1884,6 +1884,7 @@ export const en = { midnight: "Midnight", claude: "Claude", ghostty: "Ghostty", + pureBlack: "Pure black", auto: "System", }, }, diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index bcdbefc0d..afbab3b4d 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1921,6 +1921,7 @@ export const es: TranslationResources = { midnight: "Medianoche", claude: "claudio", ghostty: "fantasmal", + pureBlack: "Negro puro", auto: "Sistema", }, }, diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index d81537e57..cc6e07d26 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1924,6 +1924,7 @@ export const fr: TranslationResources = { midnight: "Minuit", claude: "Claude", ghostty: "Fantôme", + pureBlack: "Noir pur", auto: "Système", }, }, diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 2905c0eb9..0f90f923b 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1889,6 +1889,7 @@ export const ja: TranslationResources = { midnight: "Midnight", claude: "Claude", ghostty: "Ghostty", + pureBlack: "ピュアブラック", auto: "システム", }, }, diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 55e8a47b5..f1817f187 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -1885,6 +1885,7 @@ export const ko: TranslationResources = { midnight: "Midnight", claude: "Claude", ghostty: "Ghostty", + pureBlack: "순수 검정", auto: "시스템", }, }, diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 55dfb088d..f0b738156 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1905,6 +1905,7 @@ export const ptBR: TranslationResources = { midnight: "Midnight", claude: "Claude", ghostty: "Ghostty", + pureBlack: "Preto puro", auto: "Sistema", }, }, diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 82377f1ea..6e720f610 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1910,6 +1910,7 @@ export const ru: TranslationResources = { midnight: "Полночь", claude: "Клод", ghostty: "Призрачный", + pureBlack: "Чистый чёрный", auto: "Система", }, }, diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 3f81d754a..4e8566e28 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1853,6 +1853,7 @@ export const zhCN: TranslationResources = { midnight: "Midnight", claude: "Claude", ghostty: "Ghostty", + pureBlack: "纯黑", auto: "系统", }, }, diff --git a/packages/app/src/screens/settings/appearance/appearance-section.tsx b/packages/app/src/screens/settings/appearance/appearance-section.tsx index fe1013d63..b6516bf90 100644 --- a/packages/app/src/screens/settings/appearance/appearance-section.tsx +++ b/packages/app/src/screens/settings/appearance/appearance-section.tsx @@ -60,6 +60,7 @@ function getThemeLabel(t: TFunction, value: AppSettings["theme"]): string { 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]); @@ -71,6 +72,7 @@ const DARK_VARIANT_THEMES: readonly AppSettings["theme"][] = [ "midnight", "claude", "ghostty", + "pureBlack", ]; // Platform default stacks can be the bare native tokens ("normal"/"monospace"); 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 39202d95a..3b6afddbd 100644 --- a/packages/app/src/screens/settings/appearance/apply-appearance.test.ts +++ b/packages/app/src/screens/settings/appearance/apply-appearance.test.ts @@ -5,10 +5,16 @@ import { applyAppearance, type AppearanceInput } from "./apply-appearance"; // Override the global react-native-unistyles mock (vitest.setup.ts) so that // UnistylesRuntime.updateTheme is a spy that records (themeName, updater) calls. -const { updateTheme } = vi.hoisted(() => ({ updateTheme: vi.fn() })); -vi.mock("react-native-unistyles", () => ({ UnistylesRuntime: { updateTheme } })); +const { runtime, updateTheme } = vi.hoisted(() => { + const updateThemeSpy = vi.fn(); + return { + runtime: { themeName: undefined as string | undefined, updateTheme: updateThemeSpy }, + updateTheme: updateThemeSpy, + }; +}); +vi.mock("react-native-unistyles", () => ({ UnistylesRuntime: runtime })); -// The six registered Unistyles theme keys, in the order applyAppearance patches them. +// The registered Unistyles theme keys, in the order applyAppearance patches them. const ALL_THEME_KEYS = [ "light", "dark", @@ -16,6 +22,7 @@ const ALL_THEME_KEYS = [ "darkMidnight", "darkClaude", "darkGhostty", + "darkPureBlack", ] as const; // The signature of the updater passed to UnistylesRuntime.updateTheme. @@ -82,15 +89,27 @@ function runCapturedUpdater(call = 0): FakeTheme { describe("applyAppearance", () => { beforeEach(() => { updateTheme.mockClear(); + runtime.themeName = undefined; }); it("patches every registered Unistyles theme exactly once", () => { applyAppearance(makeInput()); - expect(updateTheme).toHaveBeenCalledTimes(6); + expect(updateTheme).toHaveBeenCalledTimes(ALL_THEME_KEYS.length); expect(updateTheme.mock.calls.map((call) => call[0])).toEqual([...ALL_THEME_KEYS]); }); + it("patches the active theme before inactive registry entries", () => { + runtime.themeName = "darkPureBlack"; + + applyAppearance(makeInput({ uiFontSize: 17 })); + + expect(updateTheme.mock.calls.map((call) => call[0])).toEqual([ + "darkPureBlack", + ...ALL_THEME_KEYS.filter((key) => key !== "darkPureBlack"), + ]); + }); + it("resolves an empty UI font family to the default stack", () => { applyAppearance(makeInput({ uiFontFamily: "" })); diff --git a/packages/app/src/screens/settings/appearance/apply-appearance.ts b/packages/app/src/screens/settings/appearance/apply-appearance.ts index 6bc308bb2..90fe782fd 100644 --- a/packages/app/src/screens/settings/appearance/apply-appearance.ts +++ b/packages/app/src/screens/settings/appearance/apply-appearance.ts @@ -8,9 +8,9 @@ import { } from "@/styles/theme"; import { applyRootUiFont } from "./apply-root-font"; -// All six registered Unistyles keys — pinned literal (greppable, type-checked). -// The `as const` element types are exactly `keyof UnistylesThemes`, so each key -// is assignable to `UnistylesRuntime.updateTheme`'s first argument with no cast. +// 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", @@ -18,6 +18,7 @@ const ALL_THEME_KEYS = [ "darkMidnight", "darkClaude", "darkGhostty", + "darkPureBlack", ] as const; // The UI font size at which the FONT_SIZE ramp is authored (1.0 scale factor). @@ -57,25 +58,28 @@ function scaleFontSize(uiSize: number, codeSize: number): Theme["fontSize"] { /** * Patch every registered Unistyles theme with the user's appearance choices. - * All six keys are patched because the active theme can change and adaptive mode - * can flip light/dark — patching all keys keeps the active key always current and - * makes ordering vs `setTheme`/`setAdaptiveThemes` irrelevant. + * All keys in `ALL_THEME_KEYS` are patched because the active theme can change + * and adaptive mode can flip light/dark — patching all keys keeps the active key + * always current and makes ordering vs `setTheme`/`setAdaptiveThemes` irrelevant. + * + * The updater preserves the active theme wholesale (surfaces, accents, + * terminal) and only patches the font ramp and syntax palette. + * `updateTheme` replaces the stored theme rather than merging, so we spread + * `...t` first. */ export function applyAppearance(input: AppearanceInput): void { const ui = input.uiFontFamily.trim() || DEFAULT_UI_FONT_STACK; const mono = input.monoFontFamily.trim() || DEFAULT_MONO_FONT_STACK; const diffLineHeight = Math.round(input.codeFontSize * 1.5); // couple to code size + const activeTheme = UnistylesRuntime.themeName; + // Unistyles web emits after each registry patch. Updating the mounted theme + // first ensures subscribers receive its new numeric tokens in this render; + // updating it last makes Pure black appear one committed value behind. + const themeKeys = activeTheme + ? [activeTheme, ...ALL_THEME_KEYS.filter((key) => key !== activeTheme)] + : ALL_THEME_KEYS; - for (const key of ALL_THEME_KEYS) { - // Spread `...t` first — `updateTheme` replaces the stored theme, it does not - // merge; an omitted key would be dropped. `syntax` follows the theme's own - // scheme for `auto`; named palettes ignore it. `colors.base`/plain text stays - // `theme.colors.foreground` (owned by `syntaxTokenStyles.base`, not patched). - // - // Narrow on the `colorScheme` discriminant before spreading: the updater must - // return the theme union, and a spread of the union widens `colorScheme` to - // `"light" | "dark"`, assignable to neither concrete member. Each branch spreads - // a single narrowed theme type. + for (const key of themeKeys) { UnistylesRuntime.updateTheme(key, (t) => { const fontFamily = { ui, mono }; const fontSize = scaleFontSize(input.uiFontSize, input.codeFontSize); diff --git a/packages/app/src/styles/theme.test.ts b/packages/app/src/styles/theme.test.ts new file mode 100644 index 000000000..daa600494 --- /dev/null +++ b/packages/app/src/styles/theme.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { darkPureBlackTheme } from "./theme"; + +describe("Pure black theme", () => { + it("uses a pure black application and terminal background", () => { + expect(darkPureBlackTheme.colors.surface0).toBe("#000000"); + expect(darkPureBlackTheme.colors.background).toBe("#000000"); + expect(darkPureBlackTheme.colors.terminal.background).toBe("#000000"); + }); + + it("uses Paseo's muted green accent", () => { + expect(darkPureBlackTheme.colors.accent).toBe("#20744A"); + expect(darkPureBlackTheme.colors.accentBright).toBe("#7ccba0"); + }); + + 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 32cfea081..94aa2e1d1 100644 --- a/packages/app/src/styles/theme.ts +++ b/packages/app/src/styles/theme.ts @@ -107,7 +107,7 @@ export const baseColors = { }, } as const; -export type ThemeName = "light" | "dark" | "zinc" | "midnight" | "claude" | "ghostty"; +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 @@ -300,6 +300,8 @@ interface DarkThemeConfig { accentBright: string; accentForeground?: string; destructive: string; + terminalBlack: string; + terminalBrightBlack: string; } const darkTerminalAnsi = { @@ -374,9 +376,9 @@ function buildDarkSemanticColors(tint: DarkThemeConfig) { cursorAccent: tint.surface0, selectionBackground: "rgba(255, 255, 255, 0.2)", selectionForeground: "#fafafa", - black: tint.surfaceSidebar, + black: tint.terminalBlack, ...darkTerminalAnsi, - brightBlack: tint.surface3, + brightBlack: tint.terminalBrightBlack, }, }; } @@ -403,6 +405,8 @@ const paseoDarkColors = buildDarkSemanticColors({ accent: "#20744A", accentBright: "#7ccba0", destructive: "#c64f43", // warm red, hue ~7 — reads as red (not pink) against the green tint + terminalBlack: "#141716", + terminalBrightBlack: "#434645", }); // Zinc — neutral gray, no tint @@ -424,6 +428,8 @@ const zincDarkColors = buildDarkSemanticColors({ accentBright: "#fafafa", accentForeground: "#18181b", // monochrome zinc accent is near-white — needs dark text destructive: "#c44a4a", // neutral red, hue 0 — clearly red without screaming + terminalBlack: "#131316", + terminalBrightBlack: "#3f3f46", }); // Midnight — subtle blue tint @@ -444,6 +450,8 @@ const midnightDarkColors = buildDarkSemanticColors({ accent: "#3b6fcf", accentBright: "#7eaaeb", destructive: "#c44a52", // red with a hint of cool lean against the blue tint + terminalBlack: "#121420", + terminalBrightBlack: "#3c3e4c", }); // Claude — warm neutral with subtle orange undertone @@ -464,6 +472,8 @@ const claudeDarkColors = buildDarkSemanticColors({ accent: "#d97757", accentBright: "#e89a7f", destructive: "#cf513e", // warm orange-red, hue ~10 — sits with the Claude orange accent + terminalBlack: "#1a1918", + terminalBrightBlack: "#4a4745", }); // Ghostty — blue-tinted dark based on Ghostty default background @@ -484,6 +494,8 @@ const ghosttyDarkColors = buildDarkSemanticColors({ accent: "#89b4fa", accentBright: "#b4d0fc", destructive: "#c44a55", // red with slight cool lean against the slate-blue surfaces + terminalBlack: "#21252d", + terminalBrightBlack: "#4a4f5e", }); export const SPACING = { @@ -639,6 +651,30 @@ export const darkMidnightTheme = buildDarkTheme(midnightDarkColors); export const darkClaudeTheme = buildDarkTheme(claudeDarkColors); export const darkGhosttyTheme = buildDarkTheme(ghosttyDarkColors); +// Pure black — zero-luminance background with high-contrast surfaces. +const pureBlackDarkColors = buildDarkSemanticColors({ + surface0: "#000000", + surface1: "#0a0a0a", + surface2: "#111111", + surface3: "#202020", + surface4: "#2d2d2d", + surfaceDiffEmpty: "#0c0c0c", + surfaceSidebar: "#000000", + surfaceSidebarHover: "#0d0d0d", + foregroundMuted: "#a1a1aa", + foregroundExtraMuted: "#71717a", + scrollbarHandle: "#71717a", + border: "#1c1c1c", + borderAccent: "#242424", + accent: "#20744A", + accentBright: "#7ccba0", + destructive: "#c44a4a", + terminalBlack: "#595959", + terminalBrightBlack: "#8a8a8a", +}); + +export const darkPureBlackTheme = buildDarkTheme(pureBlackDarkColors); + export const lightTheme = { colorScheme: "light" as const, colors: { @@ -672,8 +708,14 @@ export const lightTheme = { // Keep compatibility with existing code export const theme = darkTheme; -// Export a union type that works for both themes -export type Theme = typeof darkTheme | typeof lightTheme; +export type Theme = + | typeof darkTheme + | typeof darkZincTheme + | typeof darkMidnightTheme + | typeof darkClaudeTheme + | typeof darkGhosttyTheme + | typeof darkPureBlackTheme + | typeof lightTheme; type UnistylesThemeKey = | "light" @@ -681,7 +723,8 @@ type UnistylesThemeKey = | "darkZinc" | "darkMidnight" | "darkClaude" - | "darkGhostty"; + | "darkGhostty" + | "darkPureBlack"; export const THEME_TO_UNISTYLES: Record = { light: "light", @@ -690,6 +733,7 @@ export const THEME_TO_UNISTYLES: Record = { midnight: "darkMidnight", claude: "darkClaude", ghostty: "darkGhostty", + pureBlack: "darkPureBlack", }; export const THEME_SWATCHES: Record = { @@ -699,4 +743,5 @@ export const THEME_SWATCHES: Record = { midnight: "#4A6BA8", claude: "#D97757", ghostty: "#8caaee", + pureBlack: "#000000", }; diff --git a/packages/app/src/styles/unistyles.ts b/packages/app/src/styles/unistyles.ts index 284b05c74..46124d053 100644 --- a/packages/app/src/styles/unistyles.ts +++ b/packages/app/src/styles/unistyles.ts @@ -6,6 +6,7 @@ import { darkMidnightTheme, darkClaudeTheme, darkGhosttyTheme, + darkPureBlackTheme, } from "./theme"; StyleSheet.configure({ @@ -16,6 +17,7 @@ StyleSheet.configure({ darkMidnight: darkMidnightTheme, darkClaude: darkClaudeTheme, darkGhostty: darkGhosttyTheme, + darkPureBlack: darkPureBlackTheme, }, breakpoints: { xs: 0, @@ -37,6 +39,7 @@ interface AppThemes { darkMidnight: typeof darkMidnightTheme; darkClaude: typeof darkClaudeTheme; darkGhostty: typeof darkGhosttyTheme; + darkPureBlack: typeof darkPureBlackTheme; } interface AppBreakpoints { diff --git a/packages/desktop/e2e/appearance-font-size.electron.mjs b/packages/desktop/e2e/appearance-font-size.electron.mjs new file mode 100644 index 000000000..8f9fd16db --- /dev/null +++ b/packages/desktop/e2e/appearance-font-size.electron.mjs @@ -0,0 +1,48 @@ +const SETTINGS_TIMEOUT_MS = 5_000; + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +async function readFontSize(locator) { + return locator.evaluate((element) => getComputedStyle(element).fontSize); +} + +export async function runAppearanceFontSizeRegression(page) { + await page.getByRole("button", { name: "Settings", exact: true }).click(); + await page.getByRole("button", { name: "Appearance", exact: true }).click(); + + await page.getByLabel("Theme: System", { exact: true }).click(); + await page.getByText("Pure black", { exact: true }).click(); + + const input = page.getByRole("textbox", { name: "Interface font size" }); + const sectionTitle = page.getByText("Theme", { exact: true }).first(); + await input.waitFor({ state: "visible", timeout: SETTINGS_TIMEOUT_MS }); + + assert((await input.inputValue()) === "16", "Interface font size did not start at 16px"); + assert( + (await readFontSize(sectionTitle)) === "12px", + "Theme label did not start at the default 12px ramp size", + ); + + await input.fill("17"); + await input.press("Tab"); + + await page.waitForFunction( + () => { + const inputElement = document.querySelector('input[aria-label="Interface font size"]'); + const themeLabel = [...document.querySelectorAll("div")].find( + (element) => element.children.length === 0 && element.textContent?.trim() === "Theme", + ); + return ( + inputElement?.value === "17" && + themeLabel instanceof HTMLElement && + getComputedStyle(themeLabel).fontSize === "13px" + ); + }, + undefined, + { timeout: SETTINGS_TIMEOUT_MS }, + ); + + await page.getByRole("button", { name: "Back", exact: true }).click(); +} diff --git a/packages/desktop/e2e/browser-tabs.e2e.mjs b/packages/desktop/e2e/browser-tabs.e2e.mjs index ba175ad25..6c8ec44d7 100644 --- a/packages/desktop/e2e/browser-tabs.e2e.mjs +++ b/packages/desktop/e2e/browser-tabs.e2e.mjs @@ -12,6 +12,7 @@ import { fileURLToPath } from "node:url"; import { experimental_createMCPClient } from "ai"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { chromium } from "playwright"; +import { runAppearanceFontSizeRegression } from "./appearance-font-size.electron.mjs"; const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const desktopDir = path.resolve(scriptDir, ".."); @@ -780,6 +781,8 @@ async function main() { const page = await waitForAppPage(browser, expoPort); const status = await waitForDesktopStatus(page); + await runAppearanceFontSizeRegression(page); + const callerAgentId = await createCallerAgent(daemonPort); const transport = new StreamableHTTPClientTransport( new URL(