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:
+26
-3
@@ -1,7 +1,7 @@
|
|||||||
# Local plugins
|
# Local plugins
|
||||||
|
|
||||||
Local plugins contribute daemon RPCs, native app surfaces, workspace panels, Command Center items,
|
Local plugins contribute daemon RPCs, native app surfaces, workspace panels, Command Center items,
|
||||||
and composer attachment sources from one `index.ts`. Paseo executes the server contribution in a
|
app themes, and composer attachment sources from one `index.ts`. Paseo executes the server contribution in a
|
||||||
subprocess and evaluates the client contribution in the app runtime. Plugin code is trusted code;
|
subprocess and evaluates the client contribution in the app runtime. Plugin code is trusted code;
|
||||||
this first slice does not sandbox it.
|
this first slice does not sandbox it.
|
||||||
|
|
||||||
@@ -194,5 +194,28 @@ on several hosts are not coalesced. The selected snapshot submits as a text atta
|
|||||||
external-resource presentation, so it remains readable if the plugin is removed or an older peer
|
external-resource presentation, so it remains readable if the plugin is removed or an older peer
|
||||||
drops the optional presentation fields.
|
drops the optional presentation fields.
|
||||||
|
|
||||||
See `plugin-examples/local-plugin` for a native surface and `plugin-examples/linear` for a complete
|
## Contribute a theme
|
||||||
attachment-source example.
|
|
||||||
|
`addTheme` takes a small light or dark palette and a display name. Paseo expands it through the
|
||||||
|
same semantic builders as the built-in themes, so plugins do not depend on the complete app token
|
||||||
|
contract. Unistyles needs every theme name at `StyleSheet.configure` time, so
|
||||||
|
`packages/app/src/styles/theme.ts` reserves one light and one dark plugin slot. The appearance
|
||||||
|
provider rewrites the matching slot when the selection changes. See [unistyles.md](unistyles.md)
|
||||||
|
for the runtime-patching rules the appearance settings share.
|
||||||
|
|
||||||
|
`addTheme` is a client registration, so the compiler strips it from the backend bundle. A daemon
|
||||||
|
that predates it does not, and the plugin fails to start there. Daemons advertise
|
||||||
|
`features.pluginThemes` in `server_info`; the plugin theme catalog is the one place the app reads it, and
|
||||||
|
a host without it contributes no themes.
|
||||||
|
|
||||||
|
The selection persists as `theme: "plugin"` plus a `pluginThemeId` of `<pluginId>/theme/<themeId>`,
|
||||||
|
so equal themes on several hosts coalesce the way sidebar contributions do. Two hosts can answer
|
||||||
|
that id with different palettes, so picking a theme records its host through
|
||||||
|
`rememberPluginContributionHost` and resolution prefers it; a peer connecting or dropping then does
|
||||||
|
not repaint the app. Without a preference the sorted registry snapshot decides, so the result is
|
||||||
|
stable rather than arrival-ordered. The app resolves that id
|
||||||
|
against the installed catalog on every change; an id nothing contributes falls back to the default
|
||||||
|
preference instead of painting the reserved slot's placeholder colors.
|
||||||
|
|
||||||
|
See `plugin-examples/local-plugin` for a native surface, `plugin-examples/linear` for a complete
|
||||||
|
attachment-source example, and `plugin-examples/catppuccin` for a theme.
|
||||||
|
|||||||
+2
-1
@@ -335,13 +335,14 @@ If we ever need to avoid the transition entirely, store at least the theme prefe
|
|||||||
|
|
||||||
## Runtime Theme Patching For User Preferences
|
## Runtime Theme Patching For User Preferences
|
||||||
|
|
||||||
Appearance settings (UI/mono font family, font sizes, syntax-highlight theme) are applied by patching every registered theme at runtime with `UnistylesRuntime.updateTheme(name, updater)` — not by threading preference reads through components. `applyAppearance` in `packages/app/src/screens/settings/appearance/apply-appearance.ts` runs from a `ProvidersWrapper` effect on settings load/change and loops all six theme keys, returning `{ ...theme, fontFamily, fontSize, lineHeight, colors.syntax }`.
|
Appearance settings (theme, UI/mono font family, font sizes, syntax-highlight theme) are owned by `packages/app/src/appearance`. Its provider subscribes once and synchronizes Unistyles when settings or plugin contributions change. `applyAppearance` patches every key in `REGISTERED_THEMES`, returning `{ ...theme, fontFamily, fontSize, lineHeight, colors.syntax }`.
|
||||||
|
|
||||||
This works without `useUnistyles()` because every consumer already reads these tokens through `StyleSheet.create((theme) => …)` (or the `withUnistyles`/`uniProps` path for the markdown renderer), so patching the theme repaints tracked views through the native ShadowRegistry with no React re-render.
|
This works without `useUnistyles()` because every consumer already reads these tokens through `StyleSheet.create((theme) => …)` (or the `withUnistyles`/`uniProps` path for the markdown renderer), so patching the theme repaints tracked views through the native ShadowRegistry with no React re-render.
|
||||||
|
|
||||||
Gotchas:
|
Gotchas:
|
||||||
|
|
||||||
- **Patch all themes, not just the active one.** The active theme can change and adaptive mode can flip light/dark; patching every key keeps the active key current and makes ordering vs `setTheme`/`setAdaptiveThemes` irrelevant. The effect depends on the settings values (not on `theme`), so it cannot loop.
|
- **Patch all themes, not just the active one.** The active theme can change and adaptive mode can flip light/dark; patching every key keeps the active key current and makes ordering vs `setTheme`/`setAdaptiveThemes` irrelevant. The effect depends on the settings values (not on `theme`), so it cannot loop.
|
||||||
|
- **The reserved plugin keys are the exception to that ordering.** A plugin-contributed theme is rebuilt from its palette, which discards the appearance patch, so the appearance provider writes the matching light or dark slot before applying font and syntax preferences. See [plugins.md](plugins.md).
|
||||||
- **Narrow the discriminated union before spreading.** `updateTheme`'s updater returns the theme union; spreading the union widens `colorScheme` to `"light" | "dark"`, which is assignable to neither concrete member. Branch on `t.colorScheme` so each branch spreads a single narrowed theme type (no `as`).
|
- **Narrow the discriminated union before spreading.** `updateTheme`'s updater returns the theme union; spreading the union widens `colorScheme` to `"light" | "dark"`, which is assignable to neither concrete member. Branch on `t.colorScheme` so each branch spreads a single narrowed theme type (no `as`).
|
||||||
- **`lineHeight.diff` is the code/diff line-height axis** — it is coupled to the code-font-size control (≈ `codeFontSize * 1.5`). Do NOT use it for prose. Markdown body line-height scales with the UI ramp (`Math.round(theme.fontSize.base * 1.4)`); routing prose through `lineHeight.diff` clips text at small code sizes.
|
- **`lineHeight.diff` is the code/diff line-height axis** — it is coupled to the code-font-size control (≈ `codeFontSize * 1.5`). Do NOT use it for prose. Markdown body line-height scales with the UI ramp (`Math.round(theme.fontSize.base * 1.4)`); routing prose through `lineHeight.diff` clips text at small code sizes.
|
||||||
- **High-churn draft values** (live-while-typing in the appearance preview) bypass the theme: apply them as inline styles marked with `inlineUnistylesStyle` so per-keystroke values don't grow the `#unistyles-web` CSS registry.
|
- **High-churn draft values** (live-while-typing in the appearance preview) bypass the theme: apply them as inline styles marked with `inlineUnistylesStyle` so per-keystroke values don't grow the `#unistyles-web` CSS registry.
|
||||||
|
|||||||
@@ -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 { GestureDetector, GestureHandlerRootView } from "react-native-gesture-handler";
|
||||||
import { KeyboardProvider } from "react-native-keyboard-controller";
|
import { KeyboardProvider } from "react-native-keyboard-controller";
|
||||||
import { SafeAreaProvider } from "react-native-safe-area-context";
|
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 { CommandCenter } from "@/command-center/command-center";
|
||||||
import { CommandCenterRootActions } from "@/command-center/root-registration";
|
import { CommandCenterRootActions } from "@/command-center/root-registration";
|
||||||
import { CommandCenterProvider } from "@/command-center/provider";
|
import { CommandCenterProvider } from "@/command-center/provider";
|
||||||
@@ -110,10 +111,9 @@ import {
|
|||||||
useHosts,
|
useHosts,
|
||||||
} from "@/runtime/host-runtime";
|
} from "@/runtime/host-runtime";
|
||||||
import { getDaemonStartService } from "@/runtime/daemon-start-service";
|
import { getDaemonStartService } from "@/runtime/daemon-start-service";
|
||||||
import { applyAppearance } from "@/screens/settings/appearance/apply-appearance";
|
|
||||||
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
|
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
|
||||||
import { flushDraftPersistStorage } from "@/stores/draft-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 { useSessionStore } from "@/stores/session-store";
|
||||||
import { installWebScrollbarStyles } from "@/styles/install-web-scrollbar-styles";
|
import { installWebScrollbarStyles } from "@/styles/install-web-scrollbar-styles";
|
||||||
import type { HostProfile } from "@/types/host-connection";
|
import type { HostProfile } from "@/types/host-connection";
|
||||||
@@ -646,59 +646,29 @@ function MobileGestureWrapper({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ProvidersWrapper({ children }: { children: ReactNode }) {
|
function ProvidersWrapper({ children }: { children: ReactNode }) {
|
||||||
const { settings, isLoading: settingsLoading } = useAppSettings();
|
|
||||||
const { upsertConnectionFromOfferUrl } = useHostMutations();
|
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 (
|
return (
|
||||||
<VoiceProvider>
|
<AppearanceProvider>
|
||||||
<DesktopWindowControlsSync enabled={!settingsLoading} />
|
<VoiceProvider>
|
||||||
<OfferLinkListener upsertDaemonFromOfferUrl={upsertConnectionFromOfferUrl} />
|
<DesktopWindowControlsSync />
|
||||||
<HostSessionManager />
|
<OfferLinkListener upsertDaemonFromOfferUrl={upsertConnectionFromOfferUrl} />
|
||||||
<FaviconStatusSync />
|
<HostSessionManager />
|
||||||
<AppearanceStyleBoundary>{children}</AppearanceStyleBoundary>
|
<FaviconStatusSync />
|
||||||
</VoiceProvider>
|
<AppearanceStyleBoundary>{children}</AppearanceStyleBoundary>
|
||||||
|
</VoiceProvider>
|
||||||
|
</AppearanceProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DesktopWindowControlsSync({ enabled }: { enabled: boolean }) {
|
function DesktopWindowControlsSync() {
|
||||||
|
const { isLoading } = useAppSettings();
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const surface0 = theme.colors.surface0;
|
const surface0 = theme.colors.surface0;
|
||||||
const foreground = theme.colors.foreground;
|
const foreground = theme.colors.foreground;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled || isNative) return;
|
if (isLoading || isNative) return;
|
||||||
void updateDesktopWindowControls({
|
void updateDesktopWindowControls({
|
||||||
backgroundColor: surface0,
|
backgroundColor: surface0,
|
||||||
foregroundColor: foreground,
|
foregroundColor: foreground,
|
||||||
@@ -706,7 +676,7 @@ function DesktopWindowControlsSync({ enabled }: { enabled: boolean }) {
|
|||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
console.warn("[DesktopWindow] Failed to update window controls overlay", error);
|
console.warn("[DesktopWindow] Failed to update window controls overlay", error);
|
||||||
});
|
});
|
||||||
}, [enabled, surface0, foreground]);
|
}, [isLoading, surface0, foreground]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { darkHighlightColors, resolveSyntaxColors } from "@getpaseo/highlight";
|
import { darkHighlightColors, resolveSyntaxColors } from "@getpaseo/highlight";
|
||||||
import { DEFAULT_UI_FONT_STACK, REGISTERED_THEMES } from "@/styles/theme";
|
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
|
// Override the global react-native-unistyles mock (vitest.setup.ts) so that
|
||||||
// UnistylesRuntime.updateTheme is a spy that records (themeName, updater) calls.
|
// 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_CLIENT_SETTINGS,
|
||||||
DEFAULT_CODE_FONT_SIZE,
|
DEFAULT_CODE_FONT_SIZE,
|
||||||
DEFAULT_TERMINAL_SCROLLBACK_LINES,
|
DEFAULT_TERMINAL_SCROLLBACK_LINES,
|
||||||
|
DEFAULT_THEME_PREFERENCE,
|
||||||
DEFAULT_UI_BASE_FONT_SIZE,
|
DEFAULT_UI_BASE_FONT_SIZE,
|
||||||
MAX_CODE_FONT_SIZE,
|
MAX_CODE_FONT_SIZE,
|
||||||
MAX_TERMINAL_SCROLLBACK_LINES,
|
MAX_TERMINAL_SCROLLBACK_LINES,
|
||||||
@@ -49,6 +50,7 @@ export {
|
|||||||
DEFAULT_CLIENT_SETTINGS,
|
DEFAULT_CLIENT_SETTINGS,
|
||||||
DEFAULT_CODE_FONT_SIZE,
|
DEFAULT_CODE_FONT_SIZE,
|
||||||
DEFAULT_TERMINAL_SCROLLBACK_LINES,
|
DEFAULT_TERMINAL_SCROLLBACK_LINES,
|
||||||
|
DEFAULT_THEME_PREFERENCE,
|
||||||
DEFAULT_UI_BASE_FONT_SIZE,
|
DEFAULT_UI_BASE_FONT_SIZE,
|
||||||
MAX_CODE_FONT_SIZE,
|
MAX_CODE_FONT_SIZE,
|
||||||
MAX_TERMINAL_SCROLLBACK_LINES,
|
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
|
// 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.
|
// 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) => {
|
it.each(SIDEBAR_ROW_ITEMS)("persists the %s row item being switched off", async (item) => {
|
||||||
|
|||||||
@@ -15,7 +15,12 @@ import {
|
|||||||
type SidebarRowItems,
|
type SidebarRowItems,
|
||||||
} from "@/components/sidebar/display-preferences/row-items";
|
} from "@/components/sidebar/display-preferences/row-items";
|
||||||
import { isNative } from "@/constants/platform";
|
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 { z } from "zod";
|
||||||
import { readValidatedJson } from "@/storage/validated-storage";
|
import { readValidatedJson } from "@/storage/validated-storage";
|
||||||
import { APP_SETTINGS_KEY, LEGACY_SETTINGS_KEY } from "./keys";
|
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 SidebarWorkspaceTrailing = "diff" | "timestamp" | "none";
|
||||||
export type ToolCallDetailLevel = "overview" | "detailed";
|
export type ToolCallDetailLevel = "overview" | "detailed";
|
||||||
|
|
||||||
const VALID_THEMES = new Set<string>(THEME_OPTIONS.map((option) => option.name));
|
const ThemePreferenceSchema = z.enum([
|
||||||
const ThemePreferenceSchema = z.enum(THEME_OPTIONS.map((option) => option.name));
|
...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_SERVICE_URL_BEHAVIORS = new Set<ServiceUrlBehavior>(["ask", "in-app", "external"]);
|
||||||
const VALID_WORKSPACE_TITLE_SOURCES = new Set<WorkspaceTitleSource>(["title", "branch"]);
|
const VALID_WORKSPACE_TITLE_SOURCES = new Set<WorkspaceTitleSource>(["title", "branch"]);
|
||||||
const VALID_SIDEBAR_WORKSPACE_TRAILINGS = new Set<SidebarWorkspaceTrailing>([
|
const VALID_SIDEBAR_WORKSPACE_TRAILINGS = new Set<SidebarWorkspaceTrailing>([
|
||||||
@@ -59,6 +69,8 @@ export const MAX_FONT_FAMILY_LENGTH = 200;
|
|||||||
|
|
||||||
export interface AppSettings {
|
export interface AppSettings {
|
||||||
theme: ThemePreference;
|
theme: ThemePreference;
|
||||||
|
/** Which contributed theme `theme: "plugin"` selects. */
|
||||||
|
pluginThemeId: string | null;
|
||||||
language: AppLanguage;
|
language: AppLanguage;
|
||||||
sendBehavior: SendBehavior;
|
sendBehavior: SendBehavior;
|
||||||
serviceUrlBehavior: ServiceUrlBehavior;
|
serviceUrlBehavior: ServiceUrlBehavior;
|
||||||
@@ -103,6 +115,7 @@ const SidebarRowItemsSchema = z.strictObject({
|
|||||||
|
|
||||||
const StoredAppSettingsSchema = z.strictObject({
|
const StoredAppSettingsSchema = z.strictObject({
|
||||||
theme: ThemePreferenceSchema.optional(),
|
theme: ThemePreferenceSchema.optional(),
|
||||||
|
pluginThemeId: z.string().nullish(),
|
||||||
language: z
|
language: z
|
||||||
.enum(["system", "ar", "en", "es", "fr", "ja", "ko", "pt-BR", "ru", "zh-CN"])
|
.enum(["system", "ar", "en", "es", "fr", "ja", "ko", "pt-BR", "ru", "zh-CN"])
|
||||||
.optional(),
|
.optional(),
|
||||||
@@ -137,7 +150,8 @@ const LegacyRendererSettingsSchema = StoredAppSettingsSchema;
|
|||||||
type StoredAppSettings = z.infer<typeof StoredAppSettingsSchema>;
|
type StoredAppSettings = z.infer<typeof StoredAppSettingsSchema>;
|
||||||
|
|
||||||
export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
|
export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
|
||||||
theme: "auto",
|
theme: DEFAULT_THEME_PREFERENCE,
|
||||||
|
pluginThemeId: null,
|
||||||
language: "system",
|
language: "system",
|
||||||
sendBehavior: "steer",
|
sendBehavior: "steer",
|
||||||
serviceUrlBehavior: "ask",
|
serviceUrlBehavior: "ask",
|
||||||
@@ -369,6 +383,9 @@ function pickEnumAppSettings(stored: StoredAppSettings): Partial<AppSettings> {
|
|||||||
function pickAppSettings(stored: StoredAppSettings): Partial<AppSettings> {
|
function pickAppSettings(stored: StoredAppSettings): Partial<AppSettings> {
|
||||||
const result: Partial<AppSettings> = {};
|
const result: Partial<AppSettings> = {};
|
||||||
Object.assign(result, pickEnumAppSettings(stored));
|
Object.assign(result, pickEnumAppSettings(stored));
|
||||||
|
if (typeof stored.pluginThemeId === "string") {
|
||||||
|
result.pluginThemeId = stored.pluginThemeId;
|
||||||
|
}
|
||||||
if (stored.sidebarRowItems !== undefined) {
|
if (stored.sidebarRowItems !== undefined) {
|
||||||
result.sidebarRowItems = parseSidebarRowItems(stored.sidebarRowItems);
|
result.sidebarRowItems = parseSidebarRowItems(stored.sidebarRowItems);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ function plugin(onAgentSelect: AgentCommandItem["onSelect"]): InstalledPlugin {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
attachmentSources: [],
|
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");
|
).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", () => {
|
it("rejects a sidebar placement whose surface does not exist", () => {
|
||||||
expect(() =>
|
expect(() =>
|
||||||
evaluatePluginClientBundle(
|
evaluatePluginClientBundle(
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
type PluginCommandCenterItemContribution,
|
type PluginCommandCenterItemContribution,
|
||||||
type PluginSidebarContribution,
|
type PluginSidebarContribution,
|
||||||
type PluginSurfaceProps,
|
type PluginSurfaceProps,
|
||||||
|
type PluginThemeContribution,
|
||||||
type PluginWorkspacePanelContribution,
|
type PluginWorkspacePanelContribution,
|
||||||
usePaseo,
|
usePaseo,
|
||||||
useAgent,
|
useAgent,
|
||||||
@@ -22,6 +23,7 @@ import { createPluginContext, type PluginRegistrationCollector } from "@getpaseo
|
|||||||
import type { EvaluatedPlugin } from "./types";
|
import type { EvaluatedPlugin } from "./types";
|
||||||
import type { ComponentType } from "react";
|
import type { ComponentType } from "react";
|
||||||
import { resolvePluginIcon } from "./icons";
|
import { resolvePluginIcon } from "./icons";
|
||||||
|
import { parsePluginThemeContribution } from "./themes";
|
||||||
|
|
||||||
const CONTRIBUTION_ID = /^[a-z][a-z0-9-]*$/;
|
const CONTRIBUTION_ID = /^[a-z][a-z0-9-]*$/;
|
||||||
|
|
||||||
@@ -38,12 +40,14 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate
|
|||||||
workspacePanels: [],
|
workspacePanels: [],
|
||||||
commandCenterItems: [],
|
commandCenterItems: [],
|
||||||
attachmentSources: [],
|
attachmentSources: [],
|
||||||
|
themes: [],
|
||||||
};
|
};
|
||||||
const surfaceIds = new Set<string>();
|
const surfaceIds = new Set<string>();
|
||||||
const sidebarItemIds = new Set<string>();
|
const sidebarItemIds = new Set<string>();
|
||||||
const workspacePanelIds = new Set<string>();
|
const workspacePanelIds = new Set<string>();
|
||||||
const commandCenterItemIds = new Set<string>();
|
const commandCenterItemIds = new Set<string>();
|
||||||
const attachmentSourceIds = new Set<string>();
|
const attachmentSourceIds = new Set<string>();
|
||||||
|
const themeIds = new Set<string>();
|
||||||
const pluginContext = createPluginContext({
|
const pluginContext = createPluginContext({
|
||||||
addSurface(surfaceId: string, Component: ComponentType<PluginSurfaceProps>) {
|
addSurface(surfaceId: string, Component: ComponentType<PluginSurfaceProps>) {
|
||||||
const normalizedId = requireId(surfaceId, "surface id");
|
const normalizedId = requireId(surfaceId, "surface id");
|
||||||
@@ -144,6 +148,13 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate
|
|||||||
search: { ...contribution.search, name: method },
|
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 => {
|
const runtimeRequire = (name: string): unknown => {
|
||||||
if (name === "react") return React;
|
if (name === "react") return React;
|
||||||
@@ -205,5 +216,6 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate
|
|||||||
workspacePanels: collector.workspacePanels,
|
workspacePanels: collector.workspacePanels,
|
||||||
commandCenterItems: collector.commandCenterItems,
|
commandCenterItems: collector.commandCenterItems,
|
||||||
attachmentSources: collector.attachmentSources,
|
attachmentSources: collector.attachmentSources,
|
||||||
|
themes: collector.themes,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ function installed(serverId: string, contributionId = "main"): InstalledPlugin {
|
|||||||
workspacePanels: [],
|
workspacePanels: [],
|
||||||
commandCenterItems: [],
|
commandCenterItems: [],
|
||||||
attachmentSources: [],
|
attachmentSources: [],
|
||||||
|
themes: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import type { InstalledPlugin, PluginSidebarContribution } from "./types";
|
import type { InstalledPlugin, PluginSidebarContribution } from "./types";
|
||||||
|
|
||||||
const preferredHosts = new Map<string, string>();
|
|
||||||
|
|
||||||
export interface PluginSidebarTarget {
|
export interface PluginSidebarTarget {
|
||||||
plugin: InstalledPlugin;
|
plugin: InstalledPlugin;
|
||||||
item: PluginSidebarContribution;
|
item: PluginSidebarContribution;
|
||||||
@@ -38,11 +36,3 @@ export function groupPluginSidebarContributions(plugins: InstalledPlugin[]): Plu
|
|||||||
}
|
}
|
||||||
return [...groups.values()];
|
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 { useInstalledPlugins } from "./registry";
|
||||||
import { buildPluginSurfaceRoute, hostIdFromPathname } from "./routes";
|
import { buildPluginSurfaceRoute, hostIdFromPathname } from "./routes";
|
||||||
import {
|
import {
|
||||||
groupPluginSidebarContributions,
|
|
||||||
getPreferredPluginContributionHost,
|
getPreferredPluginContributionHost,
|
||||||
rememberPluginContributionHost,
|
rememberPluginContributionHost,
|
||||||
|
} from "./contribution-host";
|
||||||
|
import {
|
||||||
|
groupPluginSidebarContributions,
|
||||||
type PluginSidebarGroup,
|
type PluginSidebarGroup,
|
||||||
type PluginSidebarTarget,
|
type PluginSidebarTarget,
|
||||||
} from "./sidebar-groups";
|
} from "./sidebar-groups";
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ function installation(
|
|||||||
workspacePanels: [],
|
workspacePanels: [],
|
||||||
commandCenterItems: [],
|
commandCenterItems: [],
|
||||||
attachmentSources: [],
|
attachmentSources: [],
|
||||||
|
themes: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { resolvePluginIcon } from "./icons";
|
|||||||
import { toPluginTheme } from "./theme";
|
import { toPluginTheme } from "./theme";
|
||||||
import { useInstalledPlugin, usePluginInstallations } from "./registry";
|
import { useInstalledPlugin, usePluginInstallations } from "./registry";
|
||||||
import { buildPluginSurfaceRoute } from "./routes";
|
import { buildPluginSurfaceRoute } from "./routes";
|
||||||
import { rememberPluginContributionHost } from "./sidebar-groups";
|
import { rememberPluginContributionHost } from "./contribution-host";
|
||||||
import { SurfaceErrorBoundary } from "./surface-error-boundary";
|
import { SurfaceErrorBoundary } from "./surface-error-boundary";
|
||||||
import { createPluginSurfaceRuntime } from "./surface-runtime";
|
import { createPluginSurfaceRuntime } from "./surface-runtime";
|
||||||
import { PluginRuntimeBoundary } from "./runtime-boundary";
|
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 { 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 { 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", () => {
|
describe("toPluginTheme", () => {
|
||||||
it("maps the app theme into the plugin color tokens", () => {
|
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,
|
PluginCommandCenterItemContribution,
|
||||||
PluginSidebarContribution,
|
PluginSidebarContribution,
|
||||||
PluginSurfaceContribution,
|
PluginSurfaceContribution,
|
||||||
|
PluginThemeContribution,
|
||||||
PluginWorkspacePanelContribution,
|
PluginWorkspacePanelContribution,
|
||||||
} from "@getpaseo/plugin";
|
} from "@getpaseo/plugin";
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ export interface EvaluatedPlugin {
|
|||||||
workspacePanels: PluginWorkspacePanelContribution[];
|
workspacePanels: PluginWorkspacePanelContribution[];
|
||||||
commandCenterItems: PluginCommandCenterItemContribution[];
|
commandCenterItems: PluginCommandCenterItemContribution[];
|
||||||
attachmentSources: PluginAttachmentSourceContribution[];
|
attachmentSources: PluginAttachmentSourceContribution[];
|
||||||
|
themes: PluginThemeContribution[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InstalledPlugin extends EvaluatedPlugin {
|
export interface InstalledPlugin extends EvaluatedPlugin {
|
||||||
@@ -28,5 +30,6 @@ export type {
|
|||||||
PluginCommandCenterItemContribution,
|
PluginCommandCenterItemContribution,
|
||||||
PluginSidebarContribution,
|
PluginSidebarContribution,
|
||||||
PluginSurfaceContribution,
|
PluginSurfaceContribution,
|
||||||
|
PluginThemeContribution,
|
||||||
PluginWorkspacePanelContribution,
|
PluginWorkspacePanelContribution,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ function installed(): InstalledPlugin {
|
|||||||
],
|
],
|
||||||
commandCenterItems: [],
|
commandCenterItems: [],
|
||||||
attachmentSources: [],
|
attachmentSources: [],
|
||||||
|
themes: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { SettingsSection } from "@/screens/settings/settings-section";
|
import { SettingsSection } from "@/screens/settings/settings-section";
|
||||||
|
import { useContributedThemes } from "@/appearance/provider";
|
||||||
import { EditingTextInput as TextInput } from "@/components/ui/text-input";
|
import { EditingTextInput as TextInput } from "@/components/ui/text-input";
|
||||||
import {
|
import {
|
||||||
MAX_CODE_FONT_SIZE,
|
MAX_CODE_FONT_SIZE,
|
||||||
@@ -28,16 +29,19 @@ import {
|
|||||||
sanitizeFontFamily,
|
sanitizeFontFamily,
|
||||||
useAppSettings,
|
useAppSettings,
|
||||||
type AppSettings,
|
type AppSettings,
|
||||||
|
DEFAULT_THEME_PREFERENCE,
|
||||||
} from "@/hooks/use-settings";
|
} from "@/hooks/use-settings";
|
||||||
import {
|
import {
|
||||||
DEFAULT_MONO_FONT_STACK,
|
DEFAULT_MONO_FONT_STACK,
|
||||||
DEFAULT_UI_FONT_STACK,
|
DEFAULT_UI_FONT_STACK,
|
||||||
ICON_SIZE,
|
ICON_SIZE,
|
||||||
|
PLUGIN_THEME_PREFERENCE,
|
||||||
THEME_OPTIONS,
|
THEME_OPTIONS,
|
||||||
THEME_SWATCHES,
|
THEME_SWATCHES,
|
||||||
type Theme,
|
type Theme,
|
||||||
} from "@/styles/theme";
|
} from "@/styles/theme";
|
||||||
import { isNative } from "@/constants/platform";
|
import { isNative } from "@/constants/platform";
|
||||||
|
import type { PluginThemeOption } from "@/plugins/themes";
|
||||||
import { settingsStyles } from "@/styles/settings";
|
import { settingsStyles } from "@/styles/settings";
|
||||||
import { AppearancePreview } from "./appearance-preview";
|
import { AppearancePreview } from "./appearance-preview";
|
||||||
|
|
||||||
@@ -54,7 +58,9 @@ const ThemedChevronDown = withUnistyles(ChevronDown);
|
|||||||
|
|
||||||
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
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}`);
|
return t(`settings.appearance.theme.options.${value}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,7 +89,7 @@ function dropdownTriggerStyle({ pressed }: PressableStateCallbackType) {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
interface ThemeLeadingProps {
|
interface ThemeLeadingProps {
|
||||||
themeValue: AppSettings["theme"];
|
themeValue: BuiltInThemePreference;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ThemeLeading({ themeValue }: ThemeLeadingProps) {
|
function ThemeLeading({ themeValue }: ThemeLeadingProps) {
|
||||||
@@ -109,9 +115,9 @@ function ThemeSwatch({ color }: ThemeSwatchProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ThemeMenuItemProps {
|
interface ThemeMenuItemProps {
|
||||||
themeValue: AppSettings["theme"];
|
themeValue: BuiltInThemePreference;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
onChange: (theme: AppSettings["theme"]) => void;
|
onChange: (theme: BuiltInThemePreference) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ThemeMenuItem({ themeValue, selected, onChange }: ThemeMenuItemProps) {
|
function ThemeMenuItem({ themeValue, selected, onChange }: ThemeMenuItemProps) {
|
||||||
@@ -127,14 +133,45 @@ function ThemeMenuItem({ themeValue, selected, onChange }: ThemeMenuItemProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ThemeRowProps {
|
interface PluginThemeMenuItemProps {
|
||||||
value: AppSettings["theme"];
|
option: PluginThemeOption;
|
||||||
onChange: (theme: AppSettings["theme"]) => void;
|
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 { 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 (
|
return (
|
||||||
<View style={settingsStyles.row}>
|
<View style={settingsStyles.row}>
|
||||||
<View style={settingsStyles.rowContent}>
|
<View style={settingsStyles.rowContent}>
|
||||||
@@ -147,7 +184,11 @@ function ThemeRow({ value, onChange }: ThemeRowProps) {
|
|||||||
value: selectedLabel,
|
value: selectedLabel,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<ThemeLeading themeValue={value} />
|
{selectedPluginTheme ? (
|
||||||
|
<ThemeSwatch color={selectedPluginTheme.swatch} />
|
||||||
|
) : (
|
||||||
|
<ThemeLeading themeValue={builtInValue} />
|
||||||
|
)}
|
||||||
<Text style={styles.triggerText}>{selectedLabel}</Text>
|
<Text style={styles.triggerText}>{selectedLabel}</Text>
|
||||||
<ThemedChevronDown size={ICON_SIZE.sm} uniProps={mutedColorMapping} />
|
<ThemedChevronDown size={ICON_SIZE.sm} uniProps={mutedColorMapping} />
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
@@ -161,12 +202,21 @@ function ThemeRow({ value, onChange }: ThemeRowProps) {
|
|||||||
) : null}
|
) : null}
|
||||||
<ThemeMenuItem
|
<ThemeMenuItem
|
||||||
themeValue={option.name}
|
themeValue={option.name}
|
||||||
selected={value === option.name}
|
selected={selectedPluginTheme === null && builtInValue === option.name}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
/>
|
/>
|
||||||
</Fragment>
|
</Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{pluginThemes.length > 0 ? <DropdownMenuSeparator /> : null}
|
||||||
|
{pluginThemes.map((option) => (
|
||||||
|
<PluginThemeMenuItem
|
||||||
|
key={option.id}
|
||||||
|
option={option}
|
||||||
|
selected={selectedPluginTheme?.id === option.id}
|
||||||
|
onSelect={onSelectPluginTheme}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</View>
|
</View>
|
||||||
@@ -465,6 +515,11 @@ function SyntaxRow({ value, onChange }: SyntaxRowProps) {
|
|||||||
export function AppearanceSection() {
|
export function AppearanceSection() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { settings, updateSettings } = useAppSettings();
|
const { settings, updateSettings } = useAppSettings();
|
||||||
|
const {
|
||||||
|
options: pluginThemes,
|
||||||
|
selected: selectedPluginTheme,
|
||||||
|
select: selectPluginTheme,
|
||||||
|
} = useContributedThemes();
|
||||||
const showInterfaceFontFamilyRow = !isNative;
|
const showInterfaceFontFamilyRow = !isNative;
|
||||||
const uiFontPlaceholder = resolveDefaultStackPlaceholder(t, DEFAULT_UI_FONT_STACK);
|
const uiFontPlaceholder = resolveDefaultStackPlaceholder(t, DEFAULT_UI_FONT_STACK);
|
||||||
const monoFontPlaceholder = resolveDefaultStackPlaceholder(t, DEFAULT_MONO_FONT_STACK);
|
const monoFontPlaceholder = resolveDefaultStackPlaceholder(t, DEFAULT_MONO_FONT_STACK);
|
||||||
@@ -483,12 +538,19 @@ export function AppearanceSection() {
|
|||||||
}, [settings.codeFontSize]);
|
}, [settings.codeFontSize]);
|
||||||
|
|
||||||
const handleThemeChange = useCallback(
|
const handleThemeChange = useCallback(
|
||||||
(theme: AppSettings["theme"]) => {
|
(theme: BuiltInThemePreference) => {
|
||||||
void updateSettings({ theme });
|
void updateSettings({ theme });
|
||||||
},
|
},
|
||||||
[updateSettings],
|
[updateSettings],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handlePluginThemeChange = useCallback(
|
||||||
|
(option: PluginThemeOption) => {
|
||||||
|
selectPluginTheme(option);
|
||||||
|
},
|
||||||
|
[selectPluginTheme],
|
||||||
|
);
|
||||||
|
|
||||||
const handleSyntaxThemeChange = useCallback(
|
const handleSyntaxThemeChange = useCallback(
|
||||||
(syntaxTheme: SyntaxThemeId) => {
|
(syntaxTheme: SyntaxThemeId) => {
|
||||||
void updateSettings({ syntaxTheme });
|
void updateSettings({ syntaxTheme });
|
||||||
@@ -594,7 +656,13 @@ export function AppearanceSection() {
|
|||||||
<View>
|
<View>
|
||||||
<SettingsSection title={t("settings.appearance.theme.title")}>
|
<SettingsSection title={t("settings.appearance.theme.title")}>
|
||||||
<View style={settingsStyles.card}>
|
<View style={settingsStyles.card}>
|
||||||
<ThemeRow value={settings.theme} onChange={handleThemeChange} />
|
<ThemeRow
|
||||||
|
value={settings.theme}
|
||||||
|
pluginThemes={pluginThemes}
|
||||||
|
selectedPluginTheme={selectedPluginTheme}
|
||||||
|
onChange={handleThemeChange}
|
||||||
|
onSelectPluginTheme={handlePluginThemeChange}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
<SettingsSection title={t("settings.appearance.detailLevel.title")}>
|
<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",
|
statusDotRunning: "#5caaf6",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Semantic color tokens - Layer-based system
|
export interface LightThemeConfig {
|
||||||
const lightSurfaces = {
|
surface0: string;
|
||||||
// Surfaces (layers) - shifted one step lighter
|
surface1: string;
|
||||||
surface0: "#ffffff", // App background
|
surface2: string;
|
||||||
surface1: "#fafafa", // Subtle hover (was zinc-100, now zinc-50)
|
surface3: string;
|
||||||
surface2: "#f4f4f5", // Elevated: badges, inputs, sheets (was zinc-200, now zinc-100)
|
surface4: string;
|
||||||
surface3: "#e4e4e7", // Highest elevation (was zinc-300, now zinc-200)
|
surfaceDiffEmpty: string;
|
||||||
surface4: "#d4d4d8", // Extra emphasis (was zinc-400, now zinc-300)
|
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;
|
} as const;
|
||||||
|
|
||||||
const lightSemanticColors = {
|
export function buildLightSemanticColors(tint: LightThemeConfig) {
|
||||||
...lightSurfaces,
|
return {
|
||||||
surfaceDiffEmpty: "#f6f6f6", // Empty side of split diff rows, between surface1 and surface2 and biased toward surface2
|
surface0: tint.surface0,
|
||||||
surfaceSidebar: lightSurfaces.surface2, // Sidebar background (darker than main)
|
surface1: tint.surface1,
|
||||||
surfaceSidebarHover: lightSurfaces.surface1,
|
surface2: tint.surface2,
|
||||||
surfaceSidebarSelected: lightSurfaces.surface2,
|
surface3: tint.surface3,
|
||||||
surfaceWorkspace: lightSurfaces.surface0, // Workspace main background
|
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",
|
foreground: "#1a1a1e",
|
||||||
foregroundMuted: "#71717a",
|
foregroundMuted: "#71717a",
|
||||||
foregroundExtraMuted: "#a1a1aa",
|
foregroundExtraMuted: "#a1a1aa",
|
||||||
|
border: "#e4e4e7",
|
||||||
// Borders - shifted one step lighter
|
borderAccent: "#ececf1",
|
||||||
border: "#e4e4e7", // (was zinc-200, now zinc-200 - keep for contrast)
|
|
||||||
borderAccent: "#ececf1", // Softer accent border for low-emphasis outlines
|
|
||||||
|
|
||||||
// Brand
|
|
||||||
accent: "#20744A",
|
accent: "#20744A",
|
||||||
accentBright: "#239956",
|
accentBright: "#239956",
|
||||||
accentForeground: "#ffffff",
|
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",
|
primary: "#18181b",
|
||||||
primaryForeground: "#fafafa",
|
primaryForeground: "#fafafa",
|
||||||
secondary: "#f4f4f5",
|
destructive: "#b04138",
|
||||||
secondaryForeground: "#1a1a1e",
|
terminalBlack: "#1a1a1e",
|
||||||
muted: "#f4f4f5",
|
terminalBrightBlack: "#3f3f46",
|
||||||
mutedForeground: "#71717a",
|
|
||||||
accentBorder: "#ececf1",
|
|
||||||
input: "#f4f4f5",
|
|
||||||
ring: "#18181b",
|
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
|
// Dark theme variant builder
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
interface DarkThemeConfig {
|
export interface DarkThemeConfig {
|
||||||
surface0: string;
|
surface0: string;
|
||||||
surface1: string;
|
surface1: string;
|
||||||
surface2: string;
|
surface2: string;
|
||||||
@@ -309,6 +350,8 @@ interface DarkThemeConfig {
|
|||||||
destructive: string;
|
destructive: string;
|
||||||
terminalBlack: string;
|
terminalBlack: string;
|
||||||
terminalBrightBlack: string;
|
terminalBrightBlack: string;
|
||||||
|
foreground?: string;
|
||||||
|
ring?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const darkTerminalAnsi = {
|
const darkTerminalAnsi = {
|
||||||
@@ -328,7 +371,9 @@ const darkTerminalAnsi = {
|
|||||||
brightWhite: "#f0f0f2",
|
brightWhite: "#f0f0f2",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
function buildDarkSemanticColors(tint: DarkThemeConfig) {
|
export function buildDarkSemanticColors(tint: DarkThemeConfig) {
|
||||||
|
const foreground = tint.foreground ?? "#fafafa";
|
||||||
|
const ring = tint.ring ?? "#d4d4d8";
|
||||||
return {
|
return {
|
||||||
surface0: tint.surface0,
|
surface0: tint.surface0,
|
||||||
surface1: tint.surface1,
|
surface1: tint.surface1,
|
||||||
@@ -341,7 +386,7 @@ function buildDarkSemanticColors(tint: DarkThemeConfig) {
|
|||||||
surfaceSidebarSelected: tint.surface2,
|
surfaceSidebarSelected: tint.surface2,
|
||||||
surfaceWorkspace: tint.surface1,
|
surfaceWorkspace: tint.surface1,
|
||||||
|
|
||||||
foreground: "#fafafa",
|
foreground,
|
||||||
foregroundMuted: tint.foregroundMuted,
|
foregroundMuted: tint.foregroundMuted,
|
||||||
foregroundExtraMuted: tint.foregroundExtraMuted,
|
foregroundExtraMuted: tint.foregroundExtraMuted,
|
||||||
|
|
||||||
@@ -360,16 +405,16 @@ function buildDarkSemanticColors(tint: DarkThemeConfig) {
|
|||||||
// Legacy aliases (for gradual migration)
|
// Legacy aliases (for gradual migration)
|
||||||
background: tint.surface0,
|
background: tint.surface0,
|
||||||
popover: tint.surface2,
|
popover: tint.surface2,
|
||||||
popoverForeground: "#fafafa",
|
popoverForeground: foreground,
|
||||||
primary: "#fafafa",
|
primary: foreground,
|
||||||
primaryForeground: tint.surface0,
|
primaryForeground: tint.surface0,
|
||||||
secondary: tint.surface2,
|
secondary: tint.surface2,
|
||||||
secondaryForeground: "#fafafa",
|
secondaryForeground: foreground,
|
||||||
muted: tint.surface2,
|
muted: tint.surface2,
|
||||||
mutedForeground: tint.foregroundMuted,
|
mutedForeground: tint.foregroundMuted,
|
||||||
accentBorder: tint.borderAccent,
|
accentBorder: tint.borderAccent,
|
||||||
input: tint.surface2,
|
input: tint.surface2,
|
||||||
ring: "#d4d4d8",
|
ring,
|
||||||
|
|
||||||
...darkDiffColors,
|
...darkDiffColors,
|
||||||
...darkStatusColors,
|
...darkStatusColors,
|
||||||
@@ -377,11 +422,11 @@ function buildDarkSemanticColors(tint: DarkThemeConfig) {
|
|||||||
|
|
||||||
terminal: {
|
terminal: {
|
||||||
background: tint.surface0,
|
background: tint.surface0,
|
||||||
foreground: "#fafafa",
|
foreground,
|
||||||
cursor: "#fafafa",
|
cursor: foreground,
|
||||||
cursorAccent: tint.surface0,
|
cursorAccent: tint.surface0,
|
||||||
selectionBackground: "rgba(255, 255, 255, 0.2)",
|
selectionBackground: "rgba(255, 255, 255, 0.2)",
|
||||||
selectionForeground: "#fafafa",
|
selectionForeground: foreground,
|
||||||
black: tint.terminalBlack,
|
black: tint.terminalBlack,
|
||||||
...darkTerminalAnsi,
|
...darkTerminalAnsi,
|
||||||
brightBlack: tint.terminalBrightBlack,
|
brightBlack: tint.terminalBrightBlack,
|
||||||
@@ -627,7 +672,7 @@ const darkShadow = {
|
|||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
function buildDarkTheme(semanticColors: ReturnType<typeof buildDarkSemanticColors>) {
|
export function buildDarkTheme(semanticColors: ReturnType<typeof buildDarkSemanticColors>) {
|
||||||
return {
|
return {
|
||||||
colorScheme: "dark" as const,
|
colorScheme: "dark" as const,
|
||||||
colors: {
|
colors: {
|
||||||
@@ -668,36 +713,42 @@ const pureBlackDarkColors = buildDarkSemanticColors({
|
|||||||
|
|
||||||
export const darkPureBlackTheme = buildDarkTheme(pureBlackDarkColors);
|
export const darkPureBlackTheme = buildDarkTheme(pureBlackDarkColors);
|
||||||
|
|
||||||
export const lightTheme = {
|
const lightShadow = {
|
||||||
colorScheme: "light" as const,
|
sm: {
|
||||||
colors: {
|
shadowColor: "rgba(0, 0, 0, 0.02)",
|
||||||
...lightSemanticColors,
|
shadowOffset: { width: 0, height: 2 },
|
||||||
palette: baseColors,
|
shadowRadius: 8,
|
||||||
syntax: lightHighlightColors,
|
elevation: 2,
|
||||||
},
|
},
|
||||||
shadow: {
|
md: {
|
||||||
sm: {
|
shadowColor: "rgba(0, 0, 0, 0.04)",
|
||||||
shadowColor: "rgba(0, 0, 0, 0.02)",
|
shadowOffset: { width: 0, height: 4 },
|
||||||
shadowOffset: { width: 0, height: 2 },
|
shadowRadius: 16,
|
||||||
shadowRadius: 8,
|
elevation: 4,
|
||||||
elevation: 2,
|
},
|
||||||
},
|
lg: {
|
||||||
md: {
|
shadowColor: "rgba(0, 0, 0, 0.08)",
|
||||||
shadowColor: "rgba(0, 0, 0, 0.04)",
|
shadowOffset: { width: 0, height: 8 },
|
||||||
shadowOffset: { width: 0, height: 4 },
|
shadowRadius: 24,
|
||||||
shadowRadius: 16,
|
elevation: 8,
|
||||||
elevation: 4,
|
|
||||||
},
|
|
||||||
lg: {
|
|
||||||
shadowColor: "rgba(0, 0, 0, 0.08)",
|
|
||||||
shadowOffset: { width: 0, height: 8 },
|
|
||||||
shadowRadius: 24,
|
|
||||||
elevation: 8,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
...commonTheme,
|
|
||||||
} as const;
|
} 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
|
// Keep compatibility with existing code
|
||||||
export const theme = darkTheme;
|
export const theme = darkTheme;
|
||||||
|
|
||||||
@@ -754,8 +805,16 @@ export const THEME_OPTIONS = [
|
|||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type ThemePreference = (typeof THEME_OPTIONS)[number]["name"];
|
export const PLUGIN_THEME_PREFERENCE = "plugin";
|
||||||
export type ThemeName = Exclude<ThemePreference, "auto">;
|
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" }>;
|
type ConcreteThemeOption = Exclude<(typeof THEME_OPTIONS)[number], { name: "auto" }>;
|
||||||
export type Theme = ConcreteThemeOption["theme"];
|
export type Theme = ConcreteThemeOption["theme"];
|
||||||
|
|
||||||
@@ -773,6 +832,9 @@ type ThemeSwatches = {
|
|||||||
|
|
||||||
type RegisteredThemes = {
|
type RegisteredThemes = {
|
||||||
[Option in ConcreteThemeOption as Option["unistylesName"]]: Option["theme"];
|
[Option in ConcreteThemeOption as Option["unistylesName"]]: Option["theme"];
|
||||||
|
} & {
|
||||||
|
pluginLight: typeof lightTheme;
|
||||||
|
pluginDark: typeof darkTheme;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const THEME_TO_UNISTYLES = Object.fromEntries(
|
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]),
|
CONCRETE_THEME_OPTIONS.map((option) => [option.name, option.swatch]),
|
||||||
) as ThemeSwatches;
|
) as ThemeSwatches;
|
||||||
|
|
||||||
export const REGISTERED_THEMES = Object.fromEntries(
|
export const REGISTERED_THEMES = {
|
||||||
CONCRETE_THEME_OPTIONS.map((option) => [option.unistylesName, option.theme]),
|
...Object.fromEntries(
|
||||||
) as RegisteredThemes;
|
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 {
|
export function getNextThemePreference(current: ThemePreference): ThemePreference {
|
||||||
const currentIndex = THEME_OPTIONS.findIndex((option) => option.name === current);
|
const currentIndex = THEME_OPTIONS.findIndex((option) => option.name === current);
|
||||||
|
|||||||
@@ -154,6 +154,24 @@ declare module "@getpaseo/plugin" {
|
|||||||
surface: string;
|
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 {
|
export interface PluginSurfaceContribution {
|
||||||
id: string;
|
id: string;
|
||||||
Component: ComponentType<PluginSurfaceProps>;
|
Component: ComponentType<PluginSurfaceProps>;
|
||||||
@@ -203,6 +221,7 @@ declare module "@getpaseo/plugin" {
|
|||||||
addWorkspacePanel(contribution: PluginWorkspacePanelContribution): void;
|
addWorkspacePanel(contribution: PluginWorkspacePanelContribution): void;
|
||||||
addCommandCenterItem(contribution: PluginCommandCenterItemContribution): void;
|
addCommandCenterItem(contribution: PluginCommandCenterItemContribution): void;
|
||||||
addAttachmentSource(contribution: PluginAttachmentSourceContribution): void;
|
addAttachmentSource(contribution: PluginAttachmentSourceContribution): void;
|
||||||
|
addTheme(contribution: PluginThemeContribution): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PluginCleanup = () => void | Promise<void>;
|
export type PluginCleanup = () => void | Promise<void>;
|
||||||
|
|||||||
@@ -102,6 +102,24 @@ export interface PluginSidebarContribution {
|
|||||||
surface: string;
|
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 {
|
export interface PluginAttachmentSourceContribution {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -175,6 +193,7 @@ export interface PluginContext {
|
|||||||
addWorkspacePanel(contribution: PluginWorkspacePanelContribution): void;
|
addWorkspacePanel(contribution: PluginWorkspacePanelContribution): void;
|
||||||
addCommandCenterItem(contribution: PluginCommandCenterItemContribution): void;
|
addCommandCenterItem(contribution: PluginCommandCenterItemContribution): void;
|
||||||
addAttachmentSource(contribution: PluginAttachmentSourceContribution): void;
|
addAttachmentSource(contribution: PluginAttachmentSourceContribution): void;
|
||||||
|
addTheme(contribution: PluginThemeContribution): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PluginCleanup = () => void | Promise<void>;
|
export type PluginCleanup = () => void | Promise<void>;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
PluginSidebarContribution,
|
PluginSidebarContribution,
|
||||||
PluginSurfaceContribution,
|
PluginSurfaceContribution,
|
||||||
PluginSurfaceProps,
|
PluginSurfaceProps,
|
||||||
|
PluginThemeContribution,
|
||||||
PluginWorkspacePanelContribution,
|
PluginWorkspacePanelContribution,
|
||||||
} from "./contracts.js";
|
} from "./contracts.js";
|
||||||
import { PluginRpcProvider } from "./rpc-context.js";
|
import { PluginRpcProvider } from "./rpc-context.js";
|
||||||
@@ -20,6 +21,7 @@ interface PluginCollector {
|
|||||||
addWorkspacePanel(contribution: PluginWorkspacePanelContribution): void;
|
addWorkspacePanel(contribution: PluginWorkspacePanelContribution): void;
|
||||||
addCommandCenterItem(contribution: PluginCommandCenterItemContribution): void;
|
addCommandCenterItem(contribution: PluginCommandCenterItemContribution): void;
|
||||||
addAttachmentSource(contribution: PluginAttachmentSourceContribution): void;
|
addAttachmentSource(contribution: PluginAttachmentSourceContribution): void;
|
||||||
|
addTheme(contribution: PluginThemeContribution): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PluginRegistrationCollector {
|
export interface PluginRegistrationCollector {
|
||||||
@@ -28,6 +30,7 @@ export interface PluginRegistrationCollector {
|
|||||||
workspacePanels: PluginWorkspacePanelContribution[];
|
workspacePanels: PluginWorkspacePanelContribution[];
|
||||||
commandCenterItems: PluginCommandCenterItemContribution[];
|
commandCenterItems: PluginCommandCenterItemContribution[];
|
||||||
attachmentSources: PluginAttachmentSourceContribution[];
|
attachmentSources: PluginAttachmentSourceContribution[];
|
||||||
|
themes: PluginThemeContribution[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createPluginContext(
|
export function createPluginContext(
|
||||||
@@ -39,6 +42,7 @@ export function createPluginContext(
|
|||||||
| "addWorkspacePanel"
|
| "addWorkspacePanel"
|
||||||
| "addCommandCenterItem"
|
| "addCommandCenterItem"
|
||||||
| "addAttachmentSource"
|
| "addAttachmentSource"
|
||||||
|
| "addTheme"
|
||||||
> {
|
> {
|
||||||
return {
|
return {
|
||||||
addSurface(id, Component) {
|
addSurface(id, Component) {
|
||||||
@@ -56,6 +60,9 @@ export function createPluginContext(
|
|||||||
addAttachmentSource(contribution) {
|
addAttachmentSource(contribution) {
|
||||||
collector.addAttachmentSource(contribution);
|
collector.addAttachmentSource(contribution);
|
||||||
},
|
},
|
||||||
|
addTheme(contribution) {
|
||||||
|
collector.addTheme(contribution);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export type {
|
|||||||
PluginSidebarContribution,
|
PluginSidebarContribution,
|
||||||
PluginSurfaceContribution,
|
PluginSurfaceContribution,
|
||||||
PluginSurfaceProps,
|
PluginSurfaceProps,
|
||||||
|
PluginThemeColors,
|
||||||
|
PluginThemeContribution,
|
||||||
PluginWorkspaceCommandContext,
|
PluginWorkspaceCommandContext,
|
||||||
PluginWorkspacePanelContribution,
|
PluginWorkspacePanelContribution,
|
||||||
PluginWorkspacePanelProps,
|
PluginWorkspacePanelProps,
|
||||||
|
|||||||
@@ -115,6 +115,28 @@ describe("plugin protocol compatibility", () => {
|
|||||||
expect(current.payload.features?.pluginLogs).toBe(true);
|
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", () => {
|
it("keeps the catalog change notification safe for older clients", () => {
|
||||||
expect(
|
expect(
|
||||||
StatusMessageSchema.parse({
|
StatusMessageSchema.parse({
|
||||||
|
|||||||
@@ -3324,6 +3324,10 @@ export const ServerInfoStatusPayloadSchema = z
|
|||||||
pluginManagement: z.boolean().optional(),
|
pluginManagement: z.boolean().optional(),
|
||||||
// COMPAT(pluginLogs): added in v0.4.0, remove gate after 2027-08-16.
|
// COMPAT(pluginLogs): added in v0.4.0, remove gate after 2027-08-16.
|
||||||
pluginLogs: z.boolean().optional(),
|
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.
|
// COMPAT(skillManagement): added in v0.4.0, remove gate after 2027-08-16.
|
||||||
skillManagement: z.boolean().optional(),
|
skillManagement: z.boolean().optional(),
|
||||||
// COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23.
|
// 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",
|
"addWorkspacePanel",
|
||||||
"addCommandCenterItem",
|
"addCommandCenterItem",
|
||||||
"addAttachmentSource",
|
"addAttachmentSource",
|
||||||
|
"addTheme",
|
||||||
]),
|
]),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1676,6 +1676,8 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
plugins: true,
|
plugins: true,
|
||||||
pluginManagement: true,
|
pluginManagement: true,
|
||||||
pluginLogs: 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.
|
// COMPAT(skillManagement): added in v0.4.0, remove gate after 2027-08-16.
|
||||||
skillManagement: true,
|
skillManagement: true,
|
||||||
// COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23.
|
// COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23.
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Catppuccin plugin example
|
||||||
|
|
||||||
|
This example adds **Catppuccin Mocha** to Settings → Appearance. Paseo ships Catppuccin as a
|
||||||
|
syntax-highlight theme; this contributes it as an app theme.
|
||||||
|
|
||||||
|
A theme is data, so the whole plugin is one `addTheme` call in `index.ts` — no client file, no RPC.
|
||||||
|
|
||||||
|
Register it in `$PASEO_HOME/config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"pluginsEnabled": true,
|
||||||
|
"plugins": {
|
||||||
|
"catppuccin": {
|
||||||
|
"source": "directory",
|
||||||
|
"path": "/absolute/path/to/paseo/plugin-examples/catppuccin"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then run `paseo reload` and pick **Catppuccin Mocha** in Settings → Appearance.
|
||||||
|
|
||||||
|
The colors come straight from the [Catppuccin Mocha](https://catppuccin.com/palette/) palette:
|
||||||
|
`base`, `text`, `surface0`, `surface1`, `mauve`, `subtext0`, and `overlay0`. Paseo expands them
|
||||||
|
into the full token set, so `accent` (`mauve`) drives buttons and selection while `border`
|
||||||
|
(`surface1`, shared with `control`) stays the border and raised-surface tint.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { PluginContext } from "@getpaseo/plugin";
|
||||||
|
|
||||||
|
export default function contribute(plugin: PluginContext) {
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"id": "catppuccin"
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ category: Plugins
|
|||||||
See the [plugin roadmap](https://github.com/getpaseo/paseo/labels/plugins) for planned contribution
|
See the [plugin roadmap](https://github.com/getpaseo/paseo/labels/plugins) for planned contribution
|
||||||
surfaces and their current status.
|
surfaces and their current status.
|
||||||
|
|
||||||
Paseo plugins add native workspace panels, Command Center items, global surfaces, daemon behavior, and composer attachment sources. They run on every Paseo client connected to the host, including mobile.
|
Paseo plugins add native workspace panels, Command Center items, global surfaces, app themes, daemon behavior, and composer attachment sources. They run on every Paseo client connected to the host, including mobile.
|
||||||
|
|
||||||
Plugins are trusted local code. Install only code you trust: backend code runs unsandboxed with access to the daemon machine, and client contributions run inside the Paseo app.
|
Plugins are trusted local code. Install only code you trust: backend code runs unsandboxed with access to the daemon machine, and client contributions run inside the Paseo app.
|
||||||
|
|
||||||
@@ -153,5 +153,5 @@ security behavior.
|
|||||||
|
|
||||||
## Next
|
## Next
|
||||||
|
|
||||||
- [Plugin reference](/docs/plugins/reference), add daemon behavior, use the Paseo SDK, contribute attachments, and manage lifecycle.
|
- [Plugin reference](/docs/plugins/reference), add daemon behavior, use the Paseo SDK, contribute themes and attachments, and manage lifecycle.
|
||||||
- [TypeScript SDK](/docs/sdk), the workspace, agent, provider, and config API exposed inside plugins.
|
- [TypeScript SDK](/docs/sdk), the workspace, agent, provider, and config API exposed inside plugins.
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ Local plugins are directory sources installed into one Paseo daemon. A plugin ca
|
|||||||
- React Native surfaces and sidebar items to Paseo clients;
|
- React Native surfaces and sidebar items to Paseo clients;
|
||||||
- workspace and agent panels opened as workspace tabs;
|
- workspace and agent panels opened as workspace tabs;
|
||||||
- global, workspace, and agent actions in the Command Center;
|
- global, workspace, and agent actions in the Command Center;
|
||||||
|
- dark themes in Settings → Appearance;
|
||||||
- schema-validated RPC handlers running beside the daemon;
|
- schema-validated RPC handlers running beside the daemon;
|
||||||
- normal Paseo operations through the TypeScript SDK;
|
- normal Paseo operations through the TypeScript SDK;
|
||||||
- searchable external resources in the message composer.
|
- searchable external resources in the message composer.
|
||||||
@@ -150,13 +151,16 @@ Plugin UI runs on desktop, browser, iOS, and Android, across every Paseo theme.
|
|||||||
|
|
||||||
Recreate styles when `theme` or `layout.compact` changes.
|
Recreate styles when `theme` or `layout.compact` changes.
|
||||||
|
|
||||||
| Key | Required for | Use it for |
|
| Key | Required for | Use it for |
|
||||||
| ------------------------------ | -------------------------- | ----------------------------------- |
|
| ------------------------------- | -------------------------- | ----------------------------------- |
|
||||||
| `theme.colors.foreground` | Every primary `Text` | Titles and body copy |
|
| `theme.colors.foreground` | Every primary `Text` | Titles and body copy |
|
||||||
| `theme.colors.foregroundMuted` | Secondary `Text` | Labels and supporting copy |
|
| `theme.colors.foregroundMuted` | Secondary `Text` | Labels and supporting copy |
|
||||||
| `theme.colors.surface0` | Root view | Panel background |
|
| `theme.colors.surface0` | Root view | Panel background |
|
||||||
| `layout.compact` | Padding and stacking | `true` on mobile and narrow windows |
|
| `theme.colors.accent` | Primary action fills | Buttons and selected states |
|
||||||
| `layout.platform` | Platform-specific behavior | `ios`, `android`, or `web` |
|
| `theme.colors.accentForeground` | Text on an accent fill | Button labels |
|
||||||
|
| `theme.colors.statusDanger` | Failure copy | Error messages and destructive text |
|
||||||
|
| `layout.compact` | Padding and stacking | `true` on mobile and narrow windows |
|
||||||
|
| `layout.platform` | Platform-specific behavior | `ios`, `android`, or `web` |
|
||||||
|
|
||||||
Do not hardcode `#000`, `#fff`, or React Native's default text color. Primary copy uses `foreground`. Labels use `foregroundMuted`. Tighten padding when `layout.compact` is true.
|
Do not hardcode `#000`, `#fff`, or React Native's default text color. Primary copy uses `foreground`. Labels use `foregroundMuted`. Tighten padding when `layout.compact` is true.
|
||||||
|
|
||||||
@@ -164,6 +168,60 @@ Workspace and agent panels receive the same `theme` and `layout` fields.
|
|||||||
|
|
||||||
Client code can import `react`, `react-native`, `@tanstack/react-query`, `zod`, `@getpaseo/plugin`, and `@getpaseo/plugin/server`. Install them locally for typechecking; Paseo provides the client runtime instances.
|
Client code can import `react`, `react-native`, `@tanstack/react-query`, `zod`, `@getpaseo/plugin`, and `@getpaseo/plugin/server`. Install them locally for typechecking; Paseo provides the client runtime instances.
|
||||||
|
|
||||||
|
## Contribute a theme
|
||||||
|
|
||||||
|
`addTheme` adds a light or dark theme to Settings → Appearance, listed under the built-ins by its `name`. A
|
||||||
|
theme is data, so it needs no client file:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type { PluginContext } from "@getpaseo/plugin";
|
||||||
|
|
||||||
|
export default function contribute(plugin: PluginContext) {
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Every color is a hex string; anything else fails to load. Paseo expands the palette into the full
|
||||||
|
token set the built-in dark themes use, so a contributed theme covers panels, menus, diffs, status
|
||||||
|
colors, and the terminal without listing them.
|
||||||
|
|
||||||
|
| Color | Becomes |
|
||||||
|
| ----------------- | ----------------------------------------------------------------- |
|
||||||
|
| `background` | App, workspace, and terminal background |
|
||||||
|
| `foreground` | Primary text, terminal foreground and cursor |
|
||||||
|
| `raised` | Cards, popovers, and hovered rows |
|
||||||
|
| `control` | Inputs, secondary fills, and the light-theme sidebar |
|
||||||
|
| `border` | Borders and the highest raised-surface tint |
|
||||||
|
| `accent` | Buttons, selection, and focus. Optional; `foreground` if omitted. |
|
||||||
|
| `mutedForeground` | Secondary text |
|
||||||
|
| `ring` | Focus rings, scrollbars, and terminal bright black |
|
||||||
|
|
||||||
|
`appearance` is `"light"` or `"dark"`. Paseo uses it to select the matching surface, status,
|
||||||
|
diff, syntax, terminal, and shadow derivation.
|
||||||
|
|
||||||
|
Only one contributed theme is active at a time. Selecting one persists the choice; if the plugin is
|
||||||
|
later disabled or removed, Paseo falls back to the default theme rather than leaving the app
|
||||||
|
unpainted.
|
||||||
|
|
||||||
|
Themes need a host that supports them. A daemon released before `addTheme` compiles the call into
|
||||||
|
the plugin's backend bundle, where it does not exist, and the plugin fails to start with
|
||||||
|
`plugin.addTheme is not a function`. Update the host.
|
||||||
|
|
||||||
## Workspace panels
|
## Workspace panels
|
||||||
|
|
||||||
Register one panel for workspace or agent context:
|
Register one panel for workspace or agent context:
|
||||||
|
|||||||
Reference in New Issue
Block a user