feat(plugins): add theme contributions

Adds plugin-contributed light and dark app themes, including persistence, host-aware coalescing, fallback behavior, documentation, examples, and browser/iOS QA.
This commit is contained in:
Okan YILDIRIM
2026-08-21 11:48:12 +02:00
committed by GitHub
parent a7f3bf7abb
commit e1805ef542
41 changed files with 1238 additions and 203 deletions
@@ -0,0 +1,118 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test } from "../support/fixtures";
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
import { openSettingsSection } from "../support/helpers/settings";
const PLUGIN_ID = "plugin-theme-e2e";
// Catppuccin Mocha: base, text, surface0, surface1, mauve, subtext0, overlay0.
const PLUGIN_SOURCE = `export default function contribute(plugin) {
plugin.addTheme({
id: "mocha",
name: "Catppuccin Mocha",
appearance: "dark",
colors: {
background: "#1e1e2e",
foreground: "#cdd6f4",
raised: "#313244",
control: "#45475a",
border: "#45475a",
accent: "#cba6f7",
mutedForeground: "#a6adc8",
ring: "#6c7086",
},
});
plugin.addTheme({
id: "latte",
name: "Catppuccin Latte",
appearance: "light",
colors: {
background: "#eff1f5",
foreground: "#4c4f69",
raised: "#e6e9ef",
control: "#dce0e8",
border: "#ccd0da",
accent: "#8839ef",
mutedForeground: "#6c6f85",
ring: "#9ca0b0",
},
});
return () => {};
}`;
// settingsStyles.sectionHeaderTitle paints from foregroundMuted, so the section heading proves the
// contributed palette reached the semantic tokens rather than just the swatch.
const MOCHA_MUTED_FOREGROUND = "rgb(166, 173, 200)";
const LATTE_MUTED_FOREGROUND = "rgb(108, 111, 133)";
test("applies a contributed theme and falls back when its plugin is gone", async ({
page,
}, testInfo) => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-theme-e2e-"));
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previousConfig = await client.getDaemonConfig();
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: PLUGIN_ID }));
await writeFile(path.join(directory, "index.ts"), PLUGIN_SOURCE);
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(directory);
await page.goto("/settings");
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
await openSettingsSection(page, "appearance");
const sectionTitle = page.getByText("Theme", { exact: true }).first();
await page.getByLabel("Theme: System", { exact: true }).click();
const mochaItem = page.getByText("Catppuccin Mocha", { exact: true });
await expect(mochaItem).toBeVisible({ timeout: 30_000 });
await page.screenshot({
path: testInfo.outputPath("plugin-theme-picker.png"),
animations: "disabled",
fullPage: true,
});
await test.step("a contributed light theme uses the light palette", async () => {
await page.getByText("Catppuccin Latte", { exact: true }).click();
await expect(page.getByLabel("Theme: Catppuccin Latte", { exact: true })).toBeVisible();
await expect(sectionTitle).toHaveCSS("color", LATTE_MUTED_FOREGROUND);
await page.getByLabel("Theme: Catppuccin Latte", { exact: true }).click();
});
await mochaItem.click();
await expect(page.getByLabel("Theme: Catppuccin Mocha", { exact: true })).toBeVisible();
await expect(sectionTitle).toHaveCSS("color", MOCHA_MUTED_FOREGROUND);
await page.screenshot({
path: testInfo.outputPath("plugin-theme-applied.png"),
fullPage: true,
});
await test.step("the selection survives a reload", async () => {
await page.reload();
await expect(page.getByLabel("Theme: Catppuccin Mocha", { exact: true })).toBeVisible({
timeout: 30_000,
});
await expect(sectionTitle).toHaveCSS("color", MOCHA_MUTED_FOREGROUND);
});
await test.step("removing the plugin falls back to the default theme", async () => {
await client.removePlugin(PLUGIN_ID);
await expect(page.getByLabel("Theme: System", { exact: true })).toBeVisible({
timeout: 30_000,
});
await expect(sectionTitle).not.toHaveCSS("color", MOCHA_MUTED_FOREGROUND);
await page.screenshot({
path: testInfo.outputPath("plugin-theme-fallback.png"),
fullPage: true,
});
});
} finally {
await client.removePlugin(PLUGIN_ID).catch(() => undefined);
await client
.patchDaemonConfig({ pluginsEnabled: previousConfig.config.pluginsEnabled ?? false })
.catch(() => undefined);
await client.close().catch(() => undefined);
await rm(directory, { recursive: true, force: true });
}
});
+16 -46
View File
@@ -20,7 +20,8 @@ import { AppState, useWindowDimensions, View } from "react-native";
import { GestureDetector, GestureHandlerRootView } from "react-native-gesture-handler";
import { KeyboardProvider } from "react-native-keyboard-controller";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { AppearanceProvider } from "@/appearance/provider";
import { CommandCenter } from "@/command-center/command-center";
import { CommandCenterRootActions } from "@/command-center/root-registration";
import { CommandCenterProvider } from "@/command-center/provider";
@@ -110,10 +111,9 @@ import {
useHosts,
} from "@/runtime/host-runtime";
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 { getNextThemePreference, THEME_TO_UNISTYLES } from "@/styles/theme";
import { getNextThemePreference } from "@/styles/theme";
import { useSessionStore } from "@/stores/session-store";
import { installWebScrollbarStyles } from "@/styles/install-web-scrollbar-styles";
import type { HostProfile } from "@/types/host-connection";
@@ -646,59 +646,29 @@ function MobileGestureWrapper({
}
function ProvidersWrapper({ children }: { children: ReactNode }) {
const { settings, isLoading: settingsLoading } = useAppSettings();
const { upsertConnectionFromOfferUrl } = useHostMutations();
// Apply theme setting on mount and when it changes
useEffect(() => {
if (settingsLoading) return;
if (settings.theme === "auto") {
UnistylesRuntime.setAdaptiveThemes(true);
} else {
UnistylesRuntime.setAdaptiveThemes(false);
UnistylesRuntime.setTheme(THEME_TO_UNISTYLES[settings.theme]);
}
}, [settingsLoading, settings.theme]);
// 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
// registered theme keys, so the active key is always current.
useEffect(() => {
if (settingsLoading) return;
applyAppearance({
uiFontFamily: settings.uiFontFamily,
monoFontFamily: settings.monoFontFamily,
uiBaseFontSize: settings.uiBaseFontSize,
codeFontSize: settings.codeFontSize,
syntaxTheme: settings.syntaxTheme,
});
}, [
settingsLoading,
settings.uiFontFamily,
settings.monoFontFamily,
settings.uiBaseFontSize,
settings.codeFontSize,
settings.syntaxTheme,
]);
return (
<VoiceProvider>
<DesktopWindowControlsSync enabled={!settingsLoading} />
<OfferLinkListener upsertDaemonFromOfferUrl={upsertConnectionFromOfferUrl} />
<HostSessionManager />
<FaviconStatusSync />
<AppearanceStyleBoundary>{children}</AppearanceStyleBoundary>
</VoiceProvider>
<AppearanceProvider>
<VoiceProvider>
<DesktopWindowControlsSync />
<OfferLinkListener upsertDaemonFromOfferUrl={upsertConnectionFromOfferUrl} />
<HostSessionManager />
<FaviconStatusSync />
<AppearanceStyleBoundary>{children}</AppearanceStyleBoundary>
</VoiceProvider>
</AppearanceProvider>
);
}
function DesktopWindowControlsSync({ enabled }: { enabled: boolean }) {
function DesktopWindowControlsSync() {
const { isLoading } = useAppSettings();
const { theme } = useUnistyles();
const surface0 = theme.colors.surface0;
const foreground = theme.colors.foreground;
useEffect(() => {
if (!enabled || isNative) return;
if (isLoading || isNative) return;
void updateDesktopWindowControls({
backgroundColor: surface0,
foregroundColor: foreground,
@@ -706,7 +676,7 @@ function DesktopWindowControlsSync({ enabled }: { enabled: boolean }) {
}).catch((error) => {
console.warn("[DesktopWindow] Failed to update window controls overlay", error);
});
}, [enabled, surface0, foreground]);
}, [isLoading, surface0, foreground]);
return null;
}
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { darkHighlightColors, resolveSyntaxColors } from "@getpaseo/highlight";
import { DEFAULT_UI_FONT_STACK, REGISTERED_THEMES } from "@/styles/theme";
import { applyAppearance, type AppearanceInput } from "./apply-appearance";
import { applyAppearance, type AppearanceInput } from "./apply";
// Override the global react-native-unistyles mock (vitest.setup.ts) so that
// UnistylesRuntime.updateTheme is a spy that records (themeName, updater) calls.
+95
View File
@@ -0,0 +1,95 @@
import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo } from "react";
import { UnistylesRuntime } from "react-native-unistyles";
import { DEFAULT_THEME_PREFERENCE, useAppSettings, type AppSettings } from "@/hooks/use-settings";
import {
rememberPluginThemeHost,
usePluginThemeCatalog,
type PluginThemeOption,
} from "@/plugins/themes";
import { PLUGIN_THEME_NAMES, PLUGIN_THEME_PREFERENCE, THEME_TO_UNISTYLES } from "@/styles/theme";
import { applyAppearance } from "./apply";
interface ContributedThemes {
options: PluginThemeOption[];
selected: PluginThemeOption | null;
select: (option: PluginThemeOption) => void;
}
interface ApplyThemeInput {
preference: AppSettings["theme"];
contributedTheme: PluginThemeOption | null;
}
const ContributedThemesContext = createContext<ContributedThemes | null>(null);
function applyTheme({ preference, contributedTheme }: ApplyThemeInput): void {
if (contributedTheme) {
const themeName = PLUGIN_THEME_NAMES[contributedTheme.theme.colorScheme];
UnistylesRuntime.updateTheme(themeName, () => contributedTheme.theme);
UnistylesRuntime.setAdaptiveThemes(false);
UnistylesRuntime.setTheme(themeName);
return;
}
const builtInPreference =
preference === PLUGIN_THEME_PREFERENCE ? DEFAULT_THEME_PREFERENCE : preference;
if (builtInPreference === "auto") {
UnistylesRuntime.setAdaptiveThemes(true);
return;
}
UnistylesRuntime.setAdaptiveThemes(false);
UnistylesRuntime.setTheme(THEME_TO_UNISTYLES[builtInPreference]);
}
export function AppearanceProvider({ children }: { children: ReactNode }) {
const { settings, updateSettings, isLoading } = useAppSettings();
const options = usePluginThemeCatalog();
const selected = useMemo(() => {
if (settings.theme !== PLUGIN_THEME_PREFERENCE) return null;
return options.find((option) => option.id === settings.pluginThemeId) ?? null;
}, [options, settings.pluginThemeId, settings.theme]);
useEffect(() => {
if (isLoading) return;
applyTheme({ preference: settings.theme, contributedTheme: selected });
applyAppearance({
uiFontFamily: settings.uiFontFamily,
monoFontFamily: settings.monoFontFamily,
uiBaseFontSize: settings.uiBaseFontSize,
codeFontSize: settings.codeFontSize,
syntaxTheme: settings.syntaxTheme,
});
}, [
isLoading,
selected,
settings.theme,
settings.uiFontFamily,
settings.monoFontFamily,
settings.uiBaseFontSize,
settings.codeFontSize,
settings.syntaxTheme,
]);
const select = useCallback(
(option: PluginThemeOption) => {
rememberPluginThemeHost(option);
void updateSettings({
theme: PLUGIN_THEME_PREFERENCE,
pluginThemeId: option.id,
});
},
[updateSettings],
);
const value = useMemo(() => ({ options, selected, select }), [options, selected, select]);
return (
<ContributedThemesContext.Provider value={value}>{children}</ContributedThemesContext.Provider>
);
}
export function useContributedThemes(): ContributedThemes {
const themes = useContext(ContributedThemesContext);
if (themes === null) throw new Error("useContributedThemes requires AppearanceProvider");
return themes;
}
@@ -17,6 +17,7 @@ import {
DEFAULT_CLIENT_SETTINGS,
DEFAULT_CODE_FONT_SIZE,
DEFAULT_TERMINAL_SCROLLBACK_LINES,
DEFAULT_THEME_PREFERENCE,
DEFAULT_UI_BASE_FONT_SIZE,
MAX_CODE_FONT_SIZE,
MAX_TERMINAL_SCROLLBACK_LINES,
@@ -49,6 +50,7 @@ export {
DEFAULT_CLIENT_SETTINGS,
DEFAULT_CODE_FONT_SIZE,
DEFAULT_TERMINAL_SCROLLBACK_LINES,
DEFAULT_THEME_PREFERENCE,
DEFAULT_UI_BASE_FONT_SIZE,
MAX_CODE_FONT_SIZE,
MAX_TERMINAL_SCROLLBACK_LINES,
@@ -403,6 +403,21 @@ describe("saveAppSettings", () => {
});
});
it("persists a selected plugin theme", async () => {
const deps = makeDeps();
const queryClient = new QueryClient();
await saveAppSettings({
queryClient,
updates: { theme: "plugin", pluginThemeId: "catppuccin/theme/mocha" },
deps,
});
const loaded = await loadAppSettingsFromStorage(deps);
expect(loaded.theme).toBe("plugin");
expect(loaded.pluginThemeId).toBe("catppuccin/theme/mocha");
});
// The row items are written as one object through one strict schema, so an item the schema
// does not know does not just fail to persist itself — it takes every sibling toggle with it.
it.each(SIDEBAR_ROW_ITEMS)("persists the %s row item being switched off", async (item) => {
+21 -4
View File
@@ -15,7 +15,12 @@ import {
type SidebarRowItems,
} from "@/components/sidebar/display-preferences/row-items";
import { isNative } from "@/constants/platform";
import { FONT_SIZE, THEME_OPTIONS, type ThemePreference } from "@/styles/theme";
import {
FONT_SIZE,
PLUGIN_THEME_PREFERENCE,
THEME_OPTIONS,
type ThemePreference,
} from "@/styles/theme";
import { z } from "zod";
import { readValidatedJson } from "@/storage/validated-storage";
import { APP_SETTINGS_KEY, LEGACY_SETTINGS_KEY } from "./keys";
@@ -32,8 +37,13 @@ export type WorkspaceTitleSource = "title" | "branch";
export type SidebarWorkspaceTrailing = "diff" | "timestamp" | "none";
export type ToolCallDetailLevel = "overview" | "detailed";
const VALID_THEMES = new Set<string>(THEME_OPTIONS.map((option) => option.name));
const ThemePreferenceSchema = z.enum(THEME_OPTIONS.map((option) => option.name));
const ThemePreferenceSchema = z.enum([
...THEME_OPTIONS.map((option) => option.name),
PLUGIN_THEME_PREFERENCE,
]);
const VALID_THEMES = new Set<string>(ThemePreferenceSchema.options);
/** Where the theme picker lands when the persisted preference cannot be honoured. */
export const DEFAULT_THEME_PREFERENCE = "auto" satisfies ThemePreference;
const VALID_SERVICE_URL_BEHAVIORS = new Set<ServiceUrlBehavior>(["ask", "in-app", "external"]);
const VALID_WORKSPACE_TITLE_SOURCES = new Set<WorkspaceTitleSource>(["title", "branch"]);
const VALID_SIDEBAR_WORKSPACE_TRAILINGS = new Set<SidebarWorkspaceTrailing>([
@@ -59,6 +69,8 @@ export const MAX_FONT_FAMILY_LENGTH = 200;
export interface AppSettings {
theme: ThemePreference;
/** Which contributed theme `theme: "plugin"` selects. */
pluginThemeId: string | null;
language: AppLanguage;
sendBehavior: SendBehavior;
serviceUrlBehavior: ServiceUrlBehavior;
@@ -103,6 +115,7 @@ const SidebarRowItemsSchema = z.strictObject({
const StoredAppSettingsSchema = z.strictObject({
theme: ThemePreferenceSchema.optional(),
pluginThemeId: z.string().nullish(),
language: z
.enum(["system", "ar", "en", "es", "fr", "ja", "ko", "pt-BR", "ru", "zh-CN"])
.optional(),
@@ -137,7 +150,8 @@ const LegacyRendererSettingsSchema = StoredAppSettingsSchema;
type StoredAppSettings = z.infer<typeof StoredAppSettingsSchema>;
export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
theme: "auto",
theme: DEFAULT_THEME_PREFERENCE,
pluginThemeId: null,
language: "system",
sendBehavior: "steer",
serviceUrlBehavior: "ask",
@@ -369,6 +383,9 @@ function pickEnumAppSettings(stored: StoredAppSettings): Partial<AppSettings> {
function pickAppSettings(stored: StoredAppSettings): Partial<AppSettings> {
const result: Partial<AppSettings> = {};
Object.assign(result, pickEnumAppSettings(stored));
if (typeof stored.pluginThemeId === "string") {
result.pluginThemeId = stored.pluginThemeId;
}
if (stored.sidebarRowItems !== undefined) {
result.sidebarRowItems = parseSidebarRowItems(stored.sidebarRowItems);
}
@@ -97,6 +97,7 @@ function plugin(onAgentSelect: AgentCommandItem["onSelect"]): InstalledPlugin {
},
],
attachmentSources: [],
themes: [],
};
}
@@ -0,0 +1,9 @@
const preferredHosts = new Map<string, string>();
export function getPreferredPluginContributionHost(contributionId: string): string | null {
return preferredHosts.get(contributionId) ?? null;
}
export function rememberPluginContributionHost(contributionId: string, serverId: string): void {
preferredHosts.set(contributionId, serverId);
}
+78
View File
@@ -139,6 +139,84 @@ describe("evaluatePluginClientBundle", () => {
).toThrow("Duplicate attachment source: issues");
});
it("collects a contributed theme", () => {
const plugin = evaluatePluginClientBundle(
"catppuccin",
bundle(`
plugin.addTheme({
id: "mocha",
name: "Catppuccin Mocha",
appearance: "dark",
colors: {
background: "#1e1e2e",
foreground: "#cdd6f4",
raised: "#313244",
control: "#45475a",
border: "#45475a",
accent: "#cba6f7",
mutedForeground: "#a6adc8",
ring: "#6c7086",
},
});
`),
);
expect(plugin.themes.map((theme) => [theme.id, theme.name])).toEqual([
["mocha", "Catppuccin Mocha"],
]);
expect(plugin.themes[0]?.colors.accent).toBe("#cba6f7");
});
it("rejects a theme with a color that is not a hex value", () => {
expect(() =>
evaluatePluginClientBundle(
"catppuccin",
bundle(`
plugin.addTheme({
id: "mocha",
name: "Catppuccin Mocha",
appearance: "dark",
colors: {
background: "rebeccapurple",
foreground: "#cdd6f4",
raised: "#313244",
control: "#45475a",
border: "#45475a",
mutedForeground: "#a6adc8",
ring: "#6c7086",
},
});
`),
),
).toThrow("Must be a hex color");
});
it("rejects duplicate theme ids", () => {
expect(() =>
evaluatePluginClientBundle(
"catppuccin",
bundle(`
const theme = {
id: "mocha",
name: "Catppuccin Mocha",
appearance: "dark",
colors: {
background: "#1e1e2e",
foreground: "#cdd6f4",
raised: "#313244",
control: "#45475a",
border: "#45475a",
mutedForeground: "#a6adc8",
ring: "#6c7086",
},
};
plugin.addTheme(theme);
plugin.addTheme(theme);
`),
),
).toThrow("Duplicate theme: mocha");
});
it("rejects a sidebar placement whose surface does not exist", () => {
expect(() =>
evaluatePluginClientBundle(
+12
View File
@@ -12,6 +12,7 @@ import {
type PluginCommandCenterItemContribution,
type PluginSidebarContribution,
type PluginSurfaceProps,
type PluginThemeContribution,
type PluginWorkspacePanelContribution,
usePaseo,
useAgent,
@@ -22,6 +23,7 @@ import { createPluginContext, type PluginRegistrationCollector } from "@getpaseo
import type { EvaluatedPlugin } from "./types";
import type { ComponentType } from "react";
import { resolvePluginIcon } from "./icons";
import { parsePluginThemeContribution } from "./themes";
const CONTRIBUTION_ID = /^[a-z][a-z0-9-]*$/;
@@ -38,12 +40,14 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate
workspacePanels: [],
commandCenterItems: [],
attachmentSources: [],
themes: [],
};
const surfaceIds = new Set<string>();
const sidebarItemIds = new Set<string>();
const workspacePanelIds = new Set<string>();
const commandCenterItemIds = new Set<string>();
const attachmentSourceIds = new Set<string>();
const themeIds = new Set<string>();
const pluginContext = createPluginContext({
addSurface(surfaceId: string, Component: ComponentType<PluginSurfaceProps>) {
const normalizedId = requireId(surfaceId, "surface id");
@@ -144,6 +148,13 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate
search: { ...contribution.search, name: method },
});
},
addTheme(contribution: PluginThemeContribution) {
const normalizedId = requireId(contribution.id, "theme id");
if (themeIds.has(normalizedId)) throw new Error(`Duplicate theme: ${normalizedId}`);
const theme = parsePluginThemeContribution({ ...contribution, id: normalizedId });
themeIds.add(normalizedId);
collector.themes.push(theme);
},
});
const runtimeRequire = (name: string): unknown => {
if (name === "react") return React;
@@ -205,5 +216,6 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate
workspacePanels: collector.workspacePanels,
commandCenterItems: collector.commandCenterItems,
attachmentSources: collector.attachmentSources,
themes: collector.themes,
};
}
@@ -22,6 +22,7 @@ function installed(serverId: string, contributionId = "main"): InstalledPlugin {
workspacePanels: [],
commandCenterItems: [],
attachmentSources: [],
themes: [],
};
}
@@ -1,7 +1,5 @@
import type { InstalledPlugin, PluginSidebarContribution } from "./types";
const preferredHosts = new Map<string, string>();
export interface PluginSidebarTarget {
plugin: InstalledPlugin;
item: PluginSidebarContribution;
@@ -38,11 +36,3 @@ export function groupPluginSidebarContributions(plugins: InstalledPlugin[]): Plu
}
return [...groups.values()];
}
export function getPreferredPluginContributionHost(groupKey: string): string | null {
return preferredHosts.get(groupKey) ?? null;
}
export function rememberPluginContributionHost(groupKey: string, serverId: string): void {
preferredHosts.set(groupKey, serverId);
}
+3 -1
View File
@@ -5,9 +5,11 @@ import { resolvePluginIcon } from "./icons";
import { useInstalledPlugins } from "./registry";
import { buildPluginSurfaceRoute, hostIdFromPathname } from "./routes";
import {
groupPluginSidebarContributions,
getPreferredPluginContributionHost,
rememberPluginContributionHost,
} from "./contribution-host";
import {
groupPluginSidebarContributions,
type PluginSidebarGroup,
type PluginSidebarTarget,
} from "./sidebar-groups";
@@ -27,6 +27,7 @@ function installation(
workspacePanels: [],
commandCenterItems: [],
attachmentSources: [],
themes: [],
};
}
+1 -1
View File
@@ -17,7 +17,7 @@ import { resolvePluginIcon } from "./icons";
import { toPluginTheme } from "./theme";
import { useInstalledPlugin, usePluginInstallations } from "./registry";
import { buildPluginSurfaceRoute } from "./routes";
import { rememberPluginContributionHost } from "./sidebar-groups";
import { rememberPluginContributionHost } from "./contribution-host";
import { SurfaceErrorBoundary } from "./surface-error-boundary";
import { createPluginSurfaceRuntime } from "./surface-runtime";
import { PluginRuntimeBoundary } from "./runtime-boundary";
+185 -1
View File
@@ -1,6 +1,63 @@
import { QueryClient } from "@tanstack/react-query";
import type { PluginThemeContribution } from "@getpaseo/plugin";
import { describe, expect, it } from "vitest";
import { lightTheme } from "@/styles/theme";
import { darkTheme, lightTheme } from "@/styles/theme";
import { collectPluginThemes, rememberPluginThemeHost } from "./themes";
import { toPluginTheme } from "./theme";
import type { InstalledPlugin } from "./types";
const MOCHA: PluginThemeContribution = {
id: "mocha",
name: "Catppuccin Mocha",
appearance: "dark",
colors: {
background: "#1e1e2e",
foreground: "#cdd6f4",
raised: "#313244",
control: "#45475a",
border: "#45475a",
accent: "#cba6f7",
mutedForeground: "#a6adc8",
ring: "#6c7086",
},
};
const MOCHA_FORK: PluginThemeContribution = {
...MOCHA,
colors: { ...MOCHA.colors, background: "#11111b", accent: "#f5c2e7" },
};
const LATTE: PluginThemeContribution = {
id: "latte",
name: "Catppuccin Latte",
appearance: "light",
colors: {
background: "#eff1f5",
foreground: "#4c4f69",
raised: "#e6e9ef",
control: "#dce0e8",
border: "#ccd0da",
accent: "#8839ef",
mutedForeground: "#6c6f85",
ring: "#9ca0b0",
},
};
function installed(serverId: string, themes: PluginThemeContribution[]): InstalledPlugin {
return {
id: "catppuccin",
cleanup: () => undefined,
serverId,
clientBundle: serverId,
queryClient: new QueryClient(),
surfaces: [],
sidebarItems: [],
workspacePanels: [],
commandCenterItems: [],
attachmentSources: [],
themes,
};
}
describe("toPluginTheme", () => {
it("maps the app theme into the plugin color tokens", () => {
@@ -16,3 +73,130 @@ describe("toPluginTheme", () => {
});
});
});
describe("plugin theme palettes", () => {
it("expands a contributed palette onto the semantic and terminal tokens", () => {
const option = collectPluginThemes([installed("host-a", [MOCHA])], new Set(["host-a"]))[0];
const theme = option.theme;
expect(theme.colorScheme).toBe("dark");
expect(theme.colors).toMatchObject({
surface0: "#1e1e2e",
surface1: "#313244",
surface2: "#45475a",
surfaceSidebar: "#1e1e2e",
foreground: "#cdd6f4",
foregroundMuted: "#a6adc8",
border: "#45475a",
accent: "#cba6f7",
accentBright: "#cba6f7",
accentForeground: "#1e1e2e",
ring: "#6c7086",
terminal: {
background: "#1e1e2e",
foreground: "#cdd6f4",
cursor: "#cdd6f4",
cursorAccent: "#1e1e2e",
black: "#45475a",
brightBlack: "#6c7086",
},
});
});
it("carries the accent on the foreground when no accent is given", () => {
const { accent: _accent, ...colors } = MOCHA.colors;
const contribution = { ...MOCHA, colors };
const theme = collectPluginThemes([installed("host-a", [contribution])], new Set(["host-a"]))[0]
.theme;
expect(theme.colors.accent).toBe("#cdd6f4");
expect(theme.colors.accentBright).toBe("#cdd6f4");
});
it("keeps the status and syntax tokens the built-in dark themes use", () => {
const theme = collectPluginThemes([installed("host-a", [MOCHA])], new Set(["host-a"]))[0].theme;
expect(theme.colors.statusDanger).toBe(darkTheme.colors.statusDanger);
expect(theme.colors.syntax).toEqual(darkTheme.colors.syntax);
});
it("derives a complete light theme from the same palette contract", () => {
const theme = collectPluginThemes([installed("host-a", [LATTE])], new Set(["host-a"]))[0].theme;
expect(theme.colorScheme).toBe("light");
expect(theme.colors).toMatchObject({
surface0: "#eff1f5",
surface1: "#e6e9ef",
surface2: "#dce0e8",
surfaceSidebar: "#dce0e8",
foreground: "#4c4f69",
border: "#ccd0da",
accent: "#8839ef",
ring: "#9ca0b0",
terminal: {
background: "#eff1f5",
foreground: "#4c4f69",
black: "#4c4f69",
},
});
expect(theme.colors.statusDanger).toBe(lightTheme.colors.statusDanger);
expect(theme.colors.syntax).toEqual(lightTheme.colors.syntax);
});
});
const SUPPORTED = new Set(["host-a", "host-b", "host-z"]);
describe("collectPluginThemes", () => {
it("coalesces the same contribution across hosts", () => {
const options = collectPluginThemes(
[installed("host-a", [MOCHA]), installed("host-b", [MOCHA])],
new Set(["host-a", "host-b"]),
);
expect(options.map((option) => option.id)).toEqual(["catppuccin/theme/mocha"]);
});
// COMPAT(pluginThemes): a daemon without the capability keeps `addTheme` in the server bundle
// it compiles, so its themes are not offered at all.
it("drops themes from a host that predates the pluginThemes capability", () => {
expect(collectPluginThemes([installed("old-host", [MOCHA])], new Set())).toEqual([]);
});
it("keeps themes from the supported host when one peer is too old", () => {
const options = collectPluginThemes(
[installed("new-host", [MOCHA]), installed("old-host", [MOCHA])],
new Set(["new-host"]),
);
expect(options.map((option) => option.id)).toEqual(["catppuccin/theme/mocha"]);
});
// Two hosts, same plugin and theme id, different palettes. The picked host has to survive a peer
// connecting or dropping, or the app repaints with no settings change. The remembered host is
// module state, so this runs the whole sequence in one test rather than leaning on test order.
it("pins the palette to the picked host across peer connect and disconnect", () => {
const bothHosts = () =>
collectPluginThemes(
[installed("host-a", [MOCHA]), installed("host-z", [MOCHA_FORK])],
SUPPORTED,
);
const beforePick = bothHosts();
expect(beforePick).toHaveLength(1);
expect(beforePick[0]?.serverId).toBe("host-a");
expect(beforePick[0]?.theme.colors.surface0).toBe("#1e1e2e");
const onlyHostZ = collectPluginThemes([installed("host-z", [MOCHA_FORK])], SUPPORTED);
expect(onlyHostZ[0]).toBeDefined();
rememberPluginThemeHost(onlyHostZ[0]);
const afterPick = bothHosts();
expect(afterPick).toHaveLength(1);
expect(afterPick[0]?.serverId).toBe("host-z");
expect(afterPick[0]?.theme.colors.surface0).toBe("#11111b");
const withoutHostZ = collectPluginThemes([installed("host-a", [MOCHA])], SUPPORTED);
expect(withoutHostZ[0]?.serverId).toBe("host-a");
expect(withoutHostZ[0]?.theme.colors.surface0).toBe("#1e1e2e");
});
});
+179
View File
@@ -0,0 +1,179 @@
import { useMemo } from "react";
import { z } from "zod";
import type { PluginThemeContribution } from "@getpaseo/plugin";
import { useHostFeatureMap } from "@/runtime/host-features";
import {
buildDarkSemanticColors,
buildDarkTheme,
buildLightSemanticColors,
buildLightTheme,
darkTheme,
lightTheme,
type Theme,
} from "@/styles/theme";
import {
getPreferredPluginContributionHost,
rememberPluginContributionHost,
} from "../contribution-host";
import { useInstalledPlugins } from "../registry";
import type { InstalledPlugin } from "../types";
const hexColorSchema = z
.string()
.regex(/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/, "Must be a hex color");
const contributionSchema: z.ZodType<PluginThemeContribution> = z.strictObject({
id: z.string(),
name: z.string().trim().min(1).max(60),
appearance: z.enum(["light", "dark"]),
colors: z.strictObject({
background: hexColorSchema,
foreground: hexColorSchema,
raised: hexColorSchema,
control: hexColorSchema,
border: hexColorSchema,
accent: hexColorSchema.optional(),
mutedForeground: hexColorSchema,
ring: hexColorSchema,
}),
});
export interface PluginThemeOption {
id: string;
serverId: string;
name: string;
swatch: string;
theme: Theme;
}
interface PluginThemeTarget {
serverId: string;
contribution: PluginThemeContribution;
}
export function parsePluginThemeContribution(value: unknown): PluginThemeContribution {
return contributionSchema.parse(value);
}
function buildDarkPluginTheme(contribution: PluginThemeContribution): Theme {
const colors = contribution.colors;
const accent = colors.accent ?? colors.foreground;
return buildDarkTheme(
buildDarkSemanticColors({
surface0: colors.background,
surface1: colors.raised,
surface2: colors.control,
surface3: colors.border,
surface4: colors.ring,
surfaceDiffEmpty: colors.raised,
surfaceSidebar: colors.background,
foreground: colors.foreground,
foregroundMuted: colors.mutedForeground,
foregroundExtraMuted: colors.ring,
border: colors.border,
borderAccent: colors.border,
accent,
accentBright: accent,
accentForeground: colors.background,
destructive: darkTheme.colors.destructive,
terminalBlack: colors.control,
terminalBrightBlack: colors.ring,
ring: colors.ring,
}),
);
}
function buildLightPluginTheme(contribution: PluginThemeContribution): Theme {
const colors = contribution.colors;
const accent = colors.accent ?? colors.foreground;
return buildLightTheme(
buildLightSemanticColors({
surface0: colors.background,
surface1: colors.raised,
surface2: colors.control,
surface3: colors.border,
surface4: colors.ring,
surfaceDiffEmpty: colors.raised,
surfaceSidebar: colors.control,
foreground: colors.foreground,
foregroundMuted: colors.mutedForeground,
foregroundExtraMuted: colors.ring,
border: colors.border,
borderAccent: colors.border,
accent,
accentBright: accent,
accentForeground: colors.background,
primary: colors.foreground,
primaryForeground: colors.background,
destructive: lightTheme.colors.destructive,
terminalBlack: colors.foreground,
terminalBrightBlack: colors.ring,
ring: colors.ring,
}),
);
}
function buildPluginTheme(contribution: PluginThemeContribution): Theme {
return contribution.appearance === "light"
? buildLightPluginTheme(contribution)
: buildDarkPluginTheme(contribution);
}
function selectTarget(id: string, targets: PluginThemeTarget[]): PluginThemeTarget {
const preferredHost = getPreferredPluginContributionHost(id);
return targets.find((target) => target.serverId === preferredHost) ?? targets[0];
}
export function collectPluginThemes(
plugins: InstalledPlugin[],
supportedHosts: ReadonlySet<string>,
): PluginThemeOption[] {
const targetsById = new Map<string, PluginThemeTarget[]>();
for (const plugin of plugins) {
if (!supportedHosts.has(plugin.serverId)) continue;
for (const contribution of plugin.themes) {
const id = `${plugin.id}/theme/${contribution.id}`;
const target = { serverId: plugin.serverId, contribution };
const targets = targetsById.get(id);
if (targets) targets.push(target);
else targetsById.set(id, [target]);
}
}
return [...targetsById].map(([id, targets]) => {
const target = selectTarget(id, targets);
return {
id,
serverId: target.serverId,
name: target.contribution.name,
swatch: target.contribution.colors.background,
theme: buildPluginTheme(target.contribution),
};
});
}
export function rememberPluginThemeHost(option: PluginThemeOption): void {
rememberPluginContributionHost(option.id, option.serverId);
}
function supportedThemeHosts(support: ReadonlyMap<string, boolean>): Set<string> {
const serverIds = new Set<string>();
for (const [serverId, supported] of support) {
if (supported) serverIds.add(serverId);
}
return serverIds;
}
export function usePluginThemeCatalog(): PluginThemeOption[] {
const plugins = useInstalledPlugins();
const serverIds = useMemo(
() => [...new Set(plugins.map((plugin) => plugin.serverId))],
[plugins],
);
// COMPAT(pluginThemes): added in v0.5.0, remove gate after 2027-08-20.
const support = useHostFeatureMap(serverIds, "pluginThemes");
return useMemo(
() => collectPluginThemes(plugins, supportedThemeHosts(support)),
[plugins, support],
);
}
+3
View File
@@ -4,6 +4,7 @@ import type {
PluginCommandCenterItemContribution,
PluginSidebarContribution,
PluginSurfaceContribution,
PluginThemeContribution,
PluginWorkspacePanelContribution,
} from "@getpaseo/plugin";
@@ -15,6 +16,7 @@ export interface EvaluatedPlugin {
workspacePanels: PluginWorkspacePanelContribution[];
commandCenterItems: PluginCommandCenterItemContribution[];
attachmentSources: PluginAttachmentSourceContribution[];
themes: PluginThemeContribution[];
}
export interface InstalledPlugin extends EvaluatedPlugin {
@@ -28,5 +30,6 @@ export type {
PluginCommandCenterItemContribution,
PluginSidebarContribution,
PluginSurfaceContribution,
PluginThemeContribution,
PluginWorkspacePanelContribution,
};
@@ -23,6 +23,7 @@ function installed(): InstalledPlugin {
],
commandCenterItems: [],
attachmentSources: [],
themes: [],
};
}
@@ -18,6 +18,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { Switch } from "@/components/ui/switch";
import { SettingsSection } from "@/screens/settings/settings-section";
import { useContributedThemes } from "@/appearance/provider";
import { EditingTextInput as TextInput } from "@/components/ui/text-input";
import {
MAX_CODE_FONT_SIZE,
@@ -28,16 +29,19 @@ import {
sanitizeFontFamily,
useAppSettings,
type AppSettings,
DEFAULT_THEME_PREFERENCE,
} from "@/hooks/use-settings";
import {
DEFAULT_MONO_FONT_STACK,
DEFAULT_UI_FONT_STACK,
ICON_SIZE,
PLUGIN_THEME_PREFERENCE,
THEME_OPTIONS,
THEME_SWATCHES,
type Theme,
} from "@/styles/theme";
import { isNative } from "@/constants/platform";
import type { PluginThemeOption } from "@/plugins/themes";
import { settingsStyles } from "@/styles/settings";
import { AppearancePreview } from "./appearance-preview";
@@ -54,7 +58,9 @@ const ThemedChevronDown = withUnistyles(ChevronDown);
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
function getThemeLabel(t: TFunction, value: AppSettings["theme"]): string {
type BuiltInThemePreference = Exclude<AppSettings["theme"], typeof PLUGIN_THEME_PREFERENCE>;
function getThemeLabel(t: TFunction, value: BuiltInThemePreference): string {
return t(`settings.appearance.theme.options.${value}`);
}
@@ -83,7 +89,7 @@ function dropdownTriggerStyle({ pressed }: PressableStateCallbackType) {
// ---------------------------------------------------------------------------
interface ThemeLeadingProps {
themeValue: AppSettings["theme"];
themeValue: BuiltInThemePreference;
}
function ThemeLeading({ themeValue }: ThemeLeadingProps) {
@@ -109,9 +115,9 @@ function ThemeSwatch({ color }: ThemeSwatchProps) {
}
interface ThemeMenuItemProps {
themeValue: AppSettings["theme"];
themeValue: BuiltInThemePreference;
selected: boolean;
onChange: (theme: AppSettings["theme"]) => void;
onChange: (theme: BuiltInThemePreference) => void;
}
function ThemeMenuItem({ themeValue, selected, onChange }: ThemeMenuItemProps) {
@@ -127,14 +133,45 @@ function ThemeMenuItem({ themeValue, selected, onChange }: ThemeMenuItemProps) {
);
}
interface ThemeRowProps {
value: AppSettings["theme"];
onChange: (theme: AppSettings["theme"]) => void;
interface PluginThemeMenuItemProps {
option: PluginThemeOption;
selected: boolean;
onSelect: (option: PluginThemeOption) => void;
}
function ThemeRow({ value, onChange }: ThemeRowProps) {
function PluginThemeMenuItem({ option, selected, onSelect }: PluginThemeMenuItemProps) {
const handleSelect = useCallback(() => {
onSelect(option);
}, [onSelect, option]);
const leading = useMemo(() => <ThemeSwatch color={option.swatch} />, [option.swatch]);
return (
<DropdownMenuItem selected={selected} onSelect={handleSelect} leading={leading}>
{option.name}
</DropdownMenuItem>
);
}
interface ThemeRowProps {
value: AppSettings["theme"];
pluginThemes: PluginThemeOption[];
selectedPluginTheme: PluginThemeOption | null;
onChange: (theme: BuiltInThemePreference) => void;
onSelectPluginTheme: (option: PluginThemeOption) => void;
}
function ThemeRow({
value,
pluginThemes,
selectedPluginTheme,
onChange,
onSelectPluginTheme,
}: ThemeRowProps) {
const { t } = useTranslation();
const selectedLabel = getThemeLabel(t, value);
// A selected contribution that is no longer installed shows the fallback the app renders.
const builtInValue = value === PLUGIN_THEME_PREFERENCE ? DEFAULT_THEME_PREFERENCE : value;
const selectedLabel = selectedPluginTheme
? selectedPluginTheme.name
: getThemeLabel(t, builtInValue);
return (
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
@@ -147,7 +184,11 @@ function ThemeRow({ value, onChange }: ThemeRowProps) {
value: selectedLabel,
})}
>
<ThemeLeading themeValue={value} />
{selectedPluginTheme ? (
<ThemeSwatch color={selectedPluginTheme.swatch} />
) : (
<ThemeLeading themeValue={builtInValue} />
)}
<Text style={styles.triggerText}>{selectedLabel}</Text>
<ThemedChevronDown size={ICON_SIZE.sm} uniProps={mutedColorMapping} />
</DropdownMenuTrigger>
@@ -161,12 +202,21 @@ function ThemeRow({ value, onChange }: ThemeRowProps) {
) : null}
<ThemeMenuItem
themeValue={option.name}
selected={value === option.name}
selected={selectedPluginTheme === null && builtInValue === option.name}
onChange={onChange}
/>
</Fragment>
);
})}
{pluginThemes.length > 0 ? <DropdownMenuSeparator /> : null}
{pluginThemes.map((option) => (
<PluginThemeMenuItem
key={option.id}
option={option}
selected={selectedPluginTheme?.id === option.id}
onSelect={onSelectPluginTheme}
/>
))}
</DropdownMenuContent>
</DropdownMenu>
</View>
@@ -465,6 +515,11 @@ function SyntaxRow({ value, onChange }: SyntaxRowProps) {
export function AppearanceSection() {
const { t } = useTranslation();
const { settings, updateSettings } = useAppSettings();
const {
options: pluginThemes,
selected: selectedPluginTheme,
select: selectPluginTheme,
} = useContributedThemes();
const showInterfaceFontFamilyRow = !isNative;
const uiFontPlaceholder = resolveDefaultStackPlaceholder(t, DEFAULT_UI_FONT_STACK);
const monoFontPlaceholder = resolveDefaultStackPlaceholder(t, DEFAULT_MONO_FONT_STACK);
@@ -483,12 +538,19 @@ export function AppearanceSection() {
}, [settings.codeFontSize]);
const handleThemeChange = useCallback(
(theme: AppSettings["theme"]) => {
(theme: BuiltInThemePreference) => {
void updateSettings({ theme });
},
[updateSettings],
);
const handlePluginThemeChange = useCallback(
(option: PluginThemeOption) => {
selectPluginTheme(option);
},
[selectPluginTheme],
);
const handleSyntaxThemeChange = useCallback(
(syntaxTheme: SyntaxThemeId) => {
void updateSettings({ syntaxTheme });
@@ -594,7 +656,13 @@ export function AppearanceSection() {
<View>
<SettingsSection title={t("settings.appearance.theme.title")}>
<View style={settingsStyles.card}>
<ThemeRow value={settings.theme} onChange={handleThemeChange} />
<ThemeRow
value={settings.theme}
pluginThemes={pluginThemes}
selectedPluginTheme={selectedPluginTheme}
onChange={handleThemeChange}
onSelectPluginTheme={handlePluginThemeChange}
/>
</View>
</SettingsSection>
<SettingsSection title={t("settings.appearance.detailLevel.title")}>
+15
View File
@@ -75,3 +75,18 @@ describe("Sidebar interaction surfaces", () => {
},
);
});
describe("Built-in light theme", () => {
it("preserves its authored aliases and terminal contrast through the semantic builder", () => {
expect(lightTheme.colors).toMatchObject({
primary: "#18181b",
primaryForeground: "#fafafa",
destructiveForeground: "#ffffff",
successForeground: "#ffffff",
terminal: {
black: "#1a1a1e",
brightBlack: "#3f3f46",
},
});
});
});
+179 -113
View File
@@ -203,95 +203,136 @@ const darkStatusDotColors = {
statusDotRunning: "#5caaf6",
};
// Semantic color tokens - Layer-based system
const lightSurfaces = {
// Surfaces (layers) - shifted one step lighter
surface0: "#ffffff", // App background
surface1: "#fafafa", // Subtle hover (was zinc-100, now zinc-50)
surface2: "#f4f4f5", // Elevated: badges, inputs, sheets (was zinc-200, now zinc-100)
surface3: "#e4e4e7", // Highest elevation (was zinc-300, now zinc-200)
surface4: "#d4d4d8", // Extra emphasis (was zinc-400, now zinc-300)
export interface LightThemeConfig {
surface0: string;
surface1: string;
surface2: string;
surface3: string;
surface4: string;
surfaceDiffEmpty: string;
surfaceSidebar: string;
foreground: string;
foregroundMuted: string;
foregroundExtraMuted: string;
border: string;
borderAccent: string;
accent: string;
accentBright: string;
accentForeground?: string;
primary: string;
primaryForeground: string;
destructive: string;
terminalBlack: string;
terminalBrightBlack: string;
ring: string;
}
const lightTerminalAnsi = {
red: "#dc2626",
green: "#16a34a",
yellow: "#ca8a04",
blue: "#2563eb",
magenta: "#9333ea",
cyan: "#0891b2",
white: "#ffffff",
brightRed: "#ef4444",
brightGreen: "#22c55e",
brightYellow: "#f59e0b",
brightBlue: "#3b82f6",
brightMagenta: "#a855f7",
brightCyan: "#06b6d4",
brightWhite: "#fafafa",
} as const;
const lightSemanticColors = {
...lightSurfaces,
surfaceDiffEmpty: "#f6f6f6", // Empty side of split diff rows, between surface1 and surface2 and biased toward surface2
surfaceSidebar: lightSurfaces.surface2, // Sidebar background (darker than main)
surfaceSidebarHover: lightSurfaces.surface1,
surfaceSidebarSelected: lightSurfaces.surface2,
surfaceWorkspace: lightSurfaces.surface0, // Workspace main background
export function buildLightSemanticColors(tint: LightThemeConfig) {
return {
surface0: tint.surface0,
surface1: tint.surface1,
surface2: tint.surface2,
surface3: tint.surface3,
surface4: tint.surface4,
surfaceDiffEmpty: tint.surfaceDiffEmpty,
surfaceSidebar: tint.surfaceSidebar,
surfaceSidebarHover: tint.surface1,
surfaceSidebarSelected: tint.surface2,
surfaceWorkspace: tint.surface0,
// Text
foreground: tint.foreground,
foregroundMuted: tint.foregroundMuted,
foregroundExtraMuted: tint.foregroundExtraMuted,
border: tint.border,
borderAccent: tint.borderAccent,
accent: tint.accent,
accentBright: tint.accentBright,
accentForeground: tint.accentForeground ?? tint.surface0,
destructive: tint.destructive,
destructiveForeground: tint.surface0,
success: tint.accent,
successForeground: tint.surface0,
background: tint.surface0,
popover: tint.surface0,
popoverForeground: tint.foreground,
primary: tint.primary,
primaryForeground: tint.primaryForeground,
secondary: tint.surface2,
secondaryForeground: tint.foreground,
muted: tint.surface2,
mutedForeground: tint.foregroundMuted,
accentBorder: tint.borderAccent,
input: tint.surface2,
ring: tint.ring,
...lightDiffColors,
...lightStatusColors,
...lightStatusDotColors,
terminal: {
background: tint.surface0,
foreground: tint.foreground,
cursor: tint.foreground,
cursorAccent: tint.surface0,
selectionBackground: "rgba(0, 0, 0, 0.15)",
selectionForeground: tint.foreground,
black: tint.terminalBlack,
...lightTerminalAnsi,
brightBlack: tint.terminalBrightBlack,
},
};
}
const lightSemanticColors = buildLightSemanticColors({
surface0: "#ffffff",
surface1: "#fafafa",
surface2: "#f4f4f5",
surface3: "#e4e4e7",
surface4: "#d4d4d8",
surfaceDiffEmpty: "#f6f6f6",
surfaceSidebar: "#f4f4f5",
foreground: "#1a1a1e",
foregroundMuted: "#71717a",
foregroundExtraMuted: "#a1a1aa",
// Borders - shifted one step lighter
border: "#e4e4e7", // (was zinc-200, now zinc-200 - keep for contrast)
borderAccent: "#ececf1", // Softer accent border for low-emphasis outlines
// Brand
border: "#e4e4e7",
borderAccent: "#ececf1",
accent: "#20744A",
accentBright: "#239956",
accentForeground: "#ffffff",
// Semantic
destructive: "#b04138", // dark warm red on white — calm but unambiguously red
destructiveForeground: "#ffffff",
success: "#20744A",
successForeground: "#ffffff",
// Legacy aliases (for gradual migration)
background: "#ffffff",
popover: "#ffffff",
popoverForeground: "#1a1a1e",
primary: "#18181b",
primaryForeground: "#fafafa",
secondary: "#f4f4f5",
secondaryForeground: "#1a1a1e",
muted: "#f4f4f5",
mutedForeground: "#71717a",
accentBorder: "#ececf1",
input: "#f4f4f5",
destructive: "#b04138",
terminalBlack: "#1a1a1e",
terminalBrightBlack: "#3f3f46",
ring: "#18181b",
...lightDiffColors,
...lightStatusColors,
...lightStatusDotColors,
terminal: {
background: "#ffffff",
foreground: "#1a1a1e",
cursor: "#1a1a1e",
cursorAccent: "#ffffff",
selectionBackground: "rgba(0, 0, 0, 0.15)",
selectionForeground: "#1a1a1e",
black: "#1a1a1e",
red: "#dc2626",
green: "#16a34a",
yellow: "#ca8a04",
blue: "#2563eb",
magenta: "#9333ea",
cyan: "#0891b2",
white: "#ffffff",
brightBlack: "#3f3f46",
brightRed: "#ef4444",
brightGreen: "#22c55e",
brightYellow: "#f59e0b",
brightBlue: "#3b82f6",
brightMagenta: "#a855f7",
brightCyan: "#06b6d4",
brightWhite: "#fafafa",
},
} as const;
});
// ---------------------------------------------------------------------------
// Dark theme variant builder
// ---------------------------------------------------------------------------
interface DarkThemeConfig {
export interface DarkThemeConfig {
surface0: string;
surface1: string;
surface2: string;
@@ -309,6 +350,8 @@ interface DarkThemeConfig {
destructive: string;
terminalBlack: string;
terminalBrightBlack: string;
foreground?: string;
ring?: string;
}
const darkTerminalAnsi = {
@@ -328,7 +371,9 @@ const darkTerminalAnsi = {
brightWhite: "#f0f0f2",
} as const;
function buildDarkSemanticColors(tint: DarkThemeConfig) {
export function buildDarkSemanticColors(tint: DarkThemeConfig) {
const foreground = tint.foreground ?? "#fafafa";
const ring = tint.ring ?? "#d4d4d8";
return {
surface0: tint.surface0,
surface1: tint.surface1,
@@ -341,7 +386,7 @@ function buildDarkSemanticColors(tint: DarkThemeConfig) {
surfaceSidebarSelected: tint.surface2,
surfaceWorkspace: tint.surface1,
foreground: "#fafafa",
foreground,
foregroundMuted: tint.foregroundMuted,
foregroundExtraMuted: tint.foregroundExtraMuted,
@@ -360,16 +405,16 @@ function buildDarkSemanticColors(tint: DarkThemeConfig) {
// Legacy aliases (for gradual migration)
background: tint.surface0,
popover: tint.surface2,
popoverForeground: "#fafafa",
primary: "#fafafa",
popoverForeground: foreground,
primary: foreground,
primaryForeground: tint.surface0,
secondary: tint.surface2,
secondaryForeground: "#fafafa",
secondaryForeground: foreground,
muted: tint.surface2,
mutedForeground: tint.foregroundMuted,
accentBorder: tint.borderAccent,
input: tint.surface2,
ring: "#d4d4d8",
ring,
...darkDiffColors,
...darkStatusColors,
@@ -377,11 +422,11 @@ function buildDarkSemanticColors(tint: DarkThemeConfig) {
terminal: {
background: tint.surface0,
foreground: "#fafafa",
cursor: "#fafafa",
foreground,
cursor: foreground,
cursorAccent: tint.surface0,
selectionBackground: "rgba(255, 255, 255, 0.2)",
selectionForeground: "#fafafa",
selectionForeground: foreground,
black: tint.terminalBlack,
...darkTerminalAnsi,
brightBlack: tint.terminalBrightBlack,
@@ -627,7 +672,7 @@ const darkShadow = {
},
} as const;
function buildDarkTheme(semanticColors: ReturnType<typeof buildDarkSemanticColors>) {
export function buildDarkTheme(semanticColors: ReturnType<typeof buildDarkSemanticColors>) {
return {
colorScheme: "dark" as const,
colors: {
@@ -668,36 +713,42 @@ const pureBlackDarkColors = buildDarkSemanticColors({
export const darkPureBlackTheme = buildDarkTheme(pureBlackDarkColors);
export const lightTheme = {
colorScheme: "light" as const,
colors: {
...lightSemanticColors,
palette: baseColors,
syntax: lightHighlightColors,
const lightShadow = {
sm: {
shadowColor: "rgba(0, 0, 0, 0.02)",
shadowOffset: { width: 0, height: 2 },
shadowRadius: 8,
elevation: 2,
},
shadow: {
sm: {
shadowColor: "rgba(0, 0, 0, 0.02)",
shadowOffset: { width: 0, height: 2 },
shadowRadius: 8,
elevation: 2,
},
md: {
shadowColor: "rgba(0, 0, 0, 0.04)",
shadowOffset: { width: 0, height: 4 },
shadowRadius: 16,
elevation: 4,
},
lg: {
shadowColor: "rgba(0, 0, 0, 0.08)",
shadowOffset: { width: 0, height: 8 },
shadowRadius: 24,
elevation: 8,
},
md: {
shadowColor: "rgba(0, 0, 0, 0.04)",
shadowOffset: { width: 0, height: 4 },
shadowRadius: 16,
elevation: 4,
},
lg: {
shadowColor: "rgba(0, 0, 0, 0.08)",
shadowOffset: { width: 0, height: 8 },
shadowRadius: 24,
elevation: 8,
},
...commonTheme,
} as const;
export function buildLightTheme(semanticColors: ReturnType<typeof buildLightSemanticColors>) {
return {
colorScheme: "light" as const,
colors: {
...semanticColors,
palette: baseColors,
syntax: lightHighlightColors,
},
shadow: lightShadow,
...commonTheme,
} as const;
}
export const lightTheme = buildLightTheme(lightSemanticColors);
// Keep compatibility with existing code
export const theme = darkTheme;
@@ -754,8 +805,16 @@ export const THEME_OPTIONS = [
},
] as const;
export type ThemePreference = (typeof THEME_OPTIONS)[number]["name"];
export type ThemeName = Exclude<ThemePreference, "auto">;
export const PLUGIN_THEME_PREFERENCE = "plugin";
export const PLUGIN_THEME_NAMES = {
light: "pluginLight",
dark: "pluginDark",
} as const;
export type ThemePreference =
| (typeof THEME_OPTIONS)[number]["name"]
| typeof PLUGIN_THEME_PREFERENCE;
export type ThemeName = Exclude<ThemePreference, "auto" | typeof PLUGIN_THEME_PREFERENCE>;
type ConcreteThemeOption = Exclude<(typeof THEME_OPTIONS)[number], { name: "auto" }>;
export type Theme = ConcreteThemeOption["theme"];
@@ -773,6 +832,9 @@ type ThemeSwatches = {
type RegisteredThemes = {
[Option in ConcreteThemeOption as Option["unistylesName"]]: Option["theme"];
} & {
pluginLight: typeof lightTheme;
pluginDark: typeof darkTheme;
};
export const THEME_TO_UNISTYLES = Object.fromEntries(
@@ -783,9 +845,13 @@ 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 const REGISTERED_THEMES = {
...Object.fromEntries(
CONCRETE_THEME_OPTIONS.map((option) => [option.unistylesName, option.theme]),
),
[PLUGIN_THEME_NAMES.light]: lightTheme,
[PLUGIN_THEME_NAMES.dark]: darkTheme,
} as RegisteredThemes;
export function getNextThemePreference(current: ThemePreference): ThemePreference {
const currentIndex = THEME_OPTIONS.findIndex((option) => option.name === current);
@@ -154,6 +154,24 @@ declare module "@getpaseo/plugin" {
surface: string;
}
export interface PluginThemeColors {
background: string;
foreground: string;
raised: string;
control: string;
border: string;
accent?: string;
mutedForeground: string;
ring: string;
}
export interface PluginThemeContribution {
id: string;
name: string;
appearance: "light" | "dark";
colors: PluginThemeColors;
}
export interface PluginSurfaceContribution {
id: string;
Component: ComponentType<PluginSurfaceProps>;
@@ -203,6 +221,7 @@ declare module "@getpaseo/plugin" {
addWorkspacePanel(contribution: PluginWorkspacePanelContribution): void;
addCommandCenterItem(contribution: PluginCommandCenterItemContribution): void;
addAttachmentSource(contribution: PluginAttachmentSourceContribution): void;
addTheme(contribution: PluginThemeContribution): void;
}
export type PluginCleanup = () => void | Promise<void>;
+19
View File
@@ -102,6 +102,24 @@ export interface PluginSidebarContribution {
surface: string;
}
export interface PluginThemeColors {
background: string;
foreground: string;
raised: string;
control: string;
border: string;
accent?: string;
mutedForeground: string;
ring: string;
}
export interface PluginThemeContribution {
id: string;
name: string;
appearance: "light" | "dark";
colors: PluginThemeColors;
}
export interface PluginAttachmentSourceContribution {
id: string;
title: string;
@@ -175,6 +193,7 @@ export interface PluginContext {
addWorkspacePanel(contribution: PluginWorkspacePanelContribution): void;
addCommandCenterItem(contribution: PluginCommandCenterItemContribution): void;
addAttachmentSource(contribution: PluginAttachmentSourceContribution): void;
addTheme(contribution: PluginThemeContribution): void;
}
export type PluginCleanup = () => void | Promise<void>;
+7
View File
@@ -7,6 +7,7 @@ import type {
PluginSidebarContribution,
PluginSurfaceContribution,
PluginSurfaceProps,
PluginThemeContribution,
PluginWorkspacePanelContribution,
} from "./contracts.js";
import { PluginRpcProvider } from "./rpc-context.js";
@@ -20,6 +21,7 @@ interface PluginCollector {
addWorkspacePanel(contribution: PluginWorkspacePanelContribution): void;
addCommandCenterItem(contribution: PluginCommandCenterItemContribution): void;
addAttachmentSource(contribution: PluginAttachmentSourceContribution): void;
addTheme(contribution: PluginThemeContribution): void;
}
export interface PluginRegistrationCollector {
@@ -28,6 +30,7 @@ export interface PluginRegistrationCollector {
workspacePanels: PluginWorkspacePanelContribution[];
commandCenterItems: PluginCommandCenterItemContribution[];
attachmentSources: PluginAttachmentSourceContribution[];
themes: PluginThemeContribution[];
}
export function createPluginContext(
@@ -39,6 +42,7 @@ export function createPluginContext(
| "addWorkspacePanel"
| "addCommandCenterItem"
| "addAttachmentSource"
| "addTheme"
> {
return {
addSurface(id, Component) {
@@ -56,6 +60,9 @@ export function createPluginContext(
addAttachmentSource(contribution) {
collector.addAttachmentSource(contribution);
},
addTheme(contribution) {
collector.addTheme(contribution);
},
};
}
+2
View File
@@ -24,6 +24,8 @@ export type {
PluginSidebarContribution,
PluginSurfaceContribution,
PluginSurfaceProps,
PluginThemeColors,
PluginThemeContribution,
PluginWorkspaceCommandContext,
PluginWorkspacePanelContribution,
PluginWorkspacePanelProps,
@@ -115,6 +115,28 @@ describe("plugin protocol compatibility", () => {
expect(current.payload.features?.pluginLogs).toBe(true);
});
it("keeps the plugin themes capability optional for older server info", () => {
const older = StatusMessageSchema.parse({
type: "status",
payload: {
status: "server_info",
serverId: "older-host",
features: { plugins: true },
},
});
const current = StatusMessageSchema.parse({
type: "status",
payload: {
status: "server_info",
serverId: "current-host",
features: { plugins: true, pluginThemes: true },
},
});
expect(older.payload.features?.pluginThemes).toBeUndefined();
expect(current.payload.features?.pluginThemes).toBe(true);
});
it("keeps the catalog change notification safe for older clients", () => {
expect(
StatusMessageSchema.parse({
+4
View File
@@ -3324,6 +3324,10 @@ export const ServerInfoStatusPayloadSchema = z
pluginManagement: z.boolean().optional(),
// COMPAT(pluginLogs): added in v0.4.0, remove gate after 2027-08-16.
pluginLogs: z.boolean().optional(),
// COMPAT(pluginThemes): added in v0.5.0, remove gate after 2027-08-20.
// A daemon that predates this flag keeps `addTheme` in the server bundle it compiles,
// so a theme plugin cannot start there at all.
pluginThemes: z.boolean().optional(),
// COMPAT(skillManagement): added in v0.4.0, remove gate after 2027-08-16.
skillManagement: z.boolean().optional(),
// COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23.
@@ -81,6 +81,7 @@ const REGISTRATIONS_REMOVED_BY_TARGET: Record<PluginBuildTarget, ReadonlySet<str
"addWorkspacePanel",
"addCommandCenterItem",
"addAttachmentSource",
"addTheme",
]),
};
@@ -1676,6 +1676,8 @@ export class VoiceAssistantWebSocketServer {
plugins: true,
pluginManagement: true,
pluginLogs: true,
// COMPAT(pluginThemes): added in v0.5.0, remove gate after 2027-08-20.
pluginThemes: true,
// COMPAT(skillManagement): added in v0.4.0, remove gate after 2027-08-16.
skillManagement: true,
// COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23.