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:
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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
-1
@@ -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.
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,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);
|
||||
}
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
@@ -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")}>
|
||||
|
||||
@@ -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
@@ -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>;
|
||||
|
||||
@@ -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,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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user