feat(app): add Pure black theme (#3012)
* 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 <boudra.moha@gmail.com>
This commit is contained in:
@@ -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");
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }) {
|
||||
<OfferLinkListener upsertDaemonFromOfferUrl={upsertConnectionFromOfferUrl} />
|
||||
<HostSessionManager />
|
||||
<FaviconStatusSync />
|
||||
{children}
|
||||
<AppearanceStyleBoundary>{children}</AppearanceStyleBoundary>
|
||||
</VoiceProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<AppearanceStyleBoundary>
|
||||
<MarkdownPartList parts={parts} rendererProps={rendererProps} />
|
||||
</AppearanceStyleBoundary>
|
||||
);
|
||||
return <MarkdownPartList parts={parts} rendererProps={rendererProps} />;
|
||||
}
|
||||
|
||||
type MarkdownPartRendererProps = Omit<MarkdownRendererProps, "text" | "enableHtmlish"> & {
|
||||
|
||||
@@ -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<ViewSty
|
||||
);
|
||||
}
|
||||
|
||||
export function ToolCallDetailsContent({ ...props }: ToolCallDetailsContentProps) {
|
||||
return (
|
||||
<AppearanceStyleBoundary>
|
||||
<ToolCallDetailsContentInner {...props} />
|
||||
</AppearanceStyleBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolCallDetailsContentInner({
|
||||
export function ToolCallDetailsContent({
|
||||
detail,
|
||||
errorText,
|
||||
maxHeight,
|
||||
|
||||
@@ -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 (
|
||||
<View
|
||||
style={containerStyle}
|
||||
dataSet={codeSurface ? CODE_SURFACE_DATASET : undefined}
|
||||
@@ -67,7 +66,6 @@ export function SurfaceCard({
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
return <AppearanceStyleBoundary>{surface}</AppearanceStyleBoundary>;
|
||||
}
|
||||
|
||||
export function ScrollableCodeSurface({
|
||||
|
||||
@@ -1874,6 +1874,7 @@ export const ar: TranslationResources = {
|
||||
midnight: "منتصف الليل",
|
||||
claude: "كلود",
|
||||
ghostty: "شبحي",
|
||||
pureBlack: "أسود خالص",
|
||||
auto: "نظام",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1884,6 +1884,7 @@ export const en = {
|
||||
midnight: "Midnight",
|
||||
claude: "Claude",
|
||||
ghostty: "Ghostty",
|
||||
pureBlack: "Pure black",
|
||||
auto: "System",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1921,6 +1921,7 @@ export const es: TranslationResources = {
|
||||
midnight: "Medianoche",
|
||||
claude: "claudio",
|
||||
ghostty: "fantasmal",
|
||||
pureBlack: "Negro puro",
|
||||
auto: "Sistema",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1924,6 +1924,7 @@ export const fr: TranslationResources = {
|
||||
midnight: "Minuit",
|
||||
claude: "Claude",
|
||||
ghostty: "Fantôme",
|
||||
pureBlack: "Noir pur",
|
||||
auto: "Système",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1889,6 +1889,7 @@ export const ja: TranslationResources = {
|
||||
midnight: "Midnight",
|
||||
claude: "Claude",
|
||||
ghostty: "Ghostty",
|
||||
pureBlack: "ピュアブラック",
|
||||
auto: "システム",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1885,6 +1885,7 @@ export const ko: TranslationResources = {
|
||||
midnight: "Midnight",
|
||||
claude: "Claude",
|
||||
ghostty: "Ghostty",
|
||||
pureBlack: "순수 검정",
|
||||
auto: "시스템",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1905,6 +1905,7 @@ export const ptBR: TranslationResources = {
|
||||
midnight: "Midnight",
|
||||
claude: "Claude",
|
||||
ghostty: "Ghostty",
|
||||
pureBlack: "Preto puro",
|
||||
auto: "Sistema",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1910,6 +1910,7 @@ export const ru: TranslationResources = {
|
||||
midnight: "Полночь",
|
||||
claude: "Клод",
|
||||
ghostty: "Призрачный",
|
||||
pureBlack: "Чистый чёрный",
|
||||
auto: "Система",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1853,6 +1853,7 @@ export const zhCN: TranslationResources = {
|
||||
midnight: "Midnight",
|
||||
claude: "Claude",
|
||||
ghostty: "Ghostty",
|
||||
pureBlack: "纯黑",
|
||||
auto: "系统",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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: "" }));
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<ThemeName, UnistylesThemeKey> = {
|
||||
light: "light",
|
||||
@@ -690,6 +733,7 @@ export const THEME_TO_UNISTYLES: Record<ThemeName, UnistylesThemeKey> = {
|
||||
midnight: "darkMidnight",
|
||||
claude: "darkClaude",
|
||||
ghostty: "darkGhostty",
|
||||
pureBlack: "darkPureBlack",
|
||||
};
|
||||
|
||||
export const THEME_SWATCHES: Record<ThemeName, string> = {
|
||||
@@ -699,4 +743,5 @@ export const THEME_SWATCHES: Record<ThemeName, string> = {
|
||||
midnight: "#4A6BA8",
|
||||
claude: "#D97757",
|
||||
ghostty: "#8caaee",
|
||||
pureBlack: "#000000",
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user