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(