diff --git a/docs/reference/plugin-themes.md b/docs/reference/plugin-themes.md new file mode 100644 index 00000000000..d8ac2b0ad1c --- /dev/null +++ b/docs/reference/plugin-themes.md @@ -0,0 +1,36 @@ +# Plugin themes + +Plugins contribute three kinds of theme through `orca-plugin.json` `contributes`: +`themes` (app colors), `terminalThemes` (an ANSI palette) and, separately, icon themes. +Each entry is `{ "id", "label", "path" }` pointing at a JSON file of at most 16 KiB. + +## App themes + +```json +{ "base": "dark", "tokens": { "--background": "#1b1430", "--foreground": "#e8e0ff" } } +``` + +- `base` (`light` or `dark`) becomes the app's light/dark mode while the theme is selected. +- `--background` and `--foreground` are required. Every other public token is optional; an + omitted token is derived from the ones you set (cards and sidebars from `--background`, text + from `--foreground`, sidebar accents from `--accent`, rings from `--primary`, and so on), so a + two-token theme still colors the whole app. +- The public tokens are `PLUGIN_APP_THEME_TOKENS` in + `src/shared/plugins/plugin-theme-artifact.ts`. Other variables cannot be set. +- Values must be colors: hex, a color function (`rgb`, `hsl`, `oklch`, …) or a color name. + `transparent`, `currentColor`, CSS-wide keywords, lengths, `calc()`, `var()` and `url()` are + refused. +- Each text/surface pair (for example `--background`/`--foreground`, + `--primary`/`--primary-foreground`) must be opaque with at least 4.5:1 contrast; a pair that + fails is dropped and keeps Orca's built-in colors. +- Trust and approval dialogs always use Orca's own colors, whatever theme is active. + +## Terminal themes + +```json +{ "schemaVersion": 1, "mode": "dark", "terminal": { "background": "#1b1430", "foreground": "#e8e0ff", "red": "#ff6b8b" } } +``` + +Colors are hex. A palette needs `background`, `foreground` and at least one ANSI color. When a +user selects it, Orca saves a copy of the palette in settings so hosts, headless servers and +relays answer terminal color queries without the plugin loaded. diff --git a/src/main/ipc/plugins.ts b/src/main/ipc/plugins.ts index 1799cde774f..dd2fdb490a9 100644 --- a/src/main/ipc/plugins.ts +++ b/src/main/ipc/plugins.ts @@ -1,3 +1,4 @@ +import { pluginThemeSettingsAfterRemoval } from '../../shared/plugin-theme-settings' import { ipcMain } from 'electron' import { z } from 'zod' import type { Store } from '../persistence' @@ -111,6 +112,14 @@ export function registerPluginHandlers( await pluginService.whenReady() return pluginService.contentPacks.languagePacks.list() }) + ipcMain.handle('plugins:listThemes', async () => { + await pluginService.whenReady() + return pluginService.contentPacks.themes.list() + }) + ipcMain.handle('plugins:listTerminalThemes', async () => { + await pluginService.whenReady() + return pluginService.contentPacks.terminalThemes.list() + }) ipcMain.handle('plugins:consent', async (event, args: unknown) => { await pluginService.whenReady() const parsed = parsePluginConsentArgs(args) @@ -222,7 +231,11 @@ export function registerPluginHandlers( (pluginKey) => pluginKey !== parsed.pluginKey ) store.updateSettings( - { pluginConsents: consents, disabledPlugins }, + { + pluginConsents: consents, + disabledPlugins, + ...pluginThemeSettingsAfterRemoval(settings, parsed.pluginKey) + }, { notifyListeners: true, originWebContentsId: event.sender.id } ) await pluginService.refresh() diff --git a/src/main/persistence/applying-settings/settings-update.ts b/src/main/persistence/applying-settings/settings-update.ts index 52adef6bfcf..0d2187d2b76 100644 --- a/src/main/persistence/applying-settings/settings-update.ts +++ b/src/main/persistence/applying-settings/settings-update.ts @@ -10,6 +10,7 @@ import { sanitizeAgentLaunchProfileUpdates } from '../../../shared/tui-agent-lau import { normalizeTerminalQuickCommands } from '../../../shared/terminal-quick-commands' import { assertTerminalQuickCommandsStorable } from '../../../shared/terminal-quick-command-prompt-limit' import { normalizeTerminalCustomThemes } from '../../../shared/terminal-custom-themes' +import { normalizePluginThemeSettings } from '../../../shared/plugin-theme-settings' import { normalizeTerminalDefaultShell } from '../../../shared/terminal-default-shell' import { normalizeTerminalCursorStyleDefault } from '../../../shared/terminal-cursor-style-settings' import { normalizeDesktopTerminalScrollbackRows } from '../../../shared/terminal-scrollback-policy' @@ -131,6 +132,7 @@ export function updateSettings( updates.terminalCustomThemes ) } + Object.assign(sanitizedUpdates, normalizePluginThemeSettings(updates)) if ('terminalCursorStyle' in updates) { Object.assign( sanitizedUpdates, diff --git a/src/main/persistence/loading-store/normalize-loaded-global-settings.ts b/src/main/persistence/loading-store/normalize-loaded-global-settings.ts index 889e5001309..c039b6b2cfe 100644 --- a/src/main/persistence/loading-store/normalize-loaded-global-settings.ts +++ b/src/main/persistence/loading-store/normalize-loaded-global-settings.ts @@ -6,6 +6,7 @@ import { normalizeOpenInApplications } from '../../../shared/open-in-application import { normalizeTerminalShortcutPolicy } from '../../../shared/keybindings' import { normalizeAppIconId } from '../../../shared/app-icon' import { normalizeTerminalCustomThemes } from '../../../shared/terminal-custom-themes' +import { normalizePluginThemeSettings } from '../../../shared/plugin-theme-settings' import { normalizeTerminalDefaultShell } from '../../../shared/terminal-default-shell' import { projectSourceControlAiToLegacyCommitMessageAi } from '../../../shared/source-control-ai' import { normalizeNativeChatShellEnvironmentVariables } from '../../../shared/native-chat/native-chat-shell-environment' @@ -120,6 +121,7 @@ export function normalizeLoadedGlobalSettings( terminalDefaultShell: normalizeTerminalDefaultShell(parsed.settings?.terminalDefaultShell), terminalQuickCommands: normalizeTerminalQuickCommands(parsed.settings?.terminalQuickCommands), terminalCustomThemes: normalizeTerminalCustomThemes(parsed.settings?.terminalCustomThemes), + ...normalizePluginThemeSettings(parsed.settings ?? {}), appIcon: normalizeAppIconId(parsed.settings?.appIcon), mobilePairingCustomAddress, mobilePairingCustomAddresses, diff --git a/src/main/plugins/plugin-artifact-validation.ts b/src/main/plugins/plugin-artifact-validation.ts index b024fa383cb..4a8d71011bb 100644 --- a/src/main/plugins/plugin-artifact-validation.ts +++ b/src/main/plugins/plugin-artifact-validation.ts @@ -12,6 +12,8 @@ const PLUGIN_ICON_MAX_BYTES = 2 * 1024 * 1024 export const PLUGIN_LANGUAGE_PACK_MAX_BYTES = 5 * 1024 * 1024 export const PLUGIN_VM_RECIPE_MAX_BYTES = 256 * 1024 const PLUGIN_AGENT_PROFILE_MAX_BYTES = 1024 * 1024 +// Why 16 KiB: a theme is a few dozen tokens or palette slots. +export const PLUGIN_THEME_MAX_BYTES = 16 * 1024 type DeclaredArtifact = | { label: string; path: string; kind: 'file'; maxBytes: number } @@ -37,6 +39,12 @@ function declaredArtifactPaths(manifest: PluginManifest): DeclaredArtifact[] { ), ...c.agents.flatMap((agent) => declaredFile('agent profile', agent.path, PLUGIN_AGENT_PROFILE_MAX_BYTES) + ), + ...c.themes.flatMap((theme) => + declaredFile(`theme "${theme.id}"`, theme.path, PLUGIN_THEME_MAX_BYTES) + ), + ...c.terminalThemes.flatMap((theme) => + declaredFile(`terminal theme "${theme.id}"`, theme.path, PLUGIN_THEME_MAX_BYTES) ) ] } diff --git a/src/main/plugins/plugin-content-pack-registry.ts b/src/main/plugins/plugin-content-pack-registry.ts index 7035281f504..7edbf1a9432 100644 --- a/src/main/plugins/plugin-content-pack-registry.ts +++ b/src/main/plugins/plugin-content-pack-registry.ts @@ -5,6 +5,10 @@ import { type ValidDiscoveredPlugin } from './plugin-discovery' import { PluginLanguagePackRegistry } from './plugin-language-pack-registry' +import { + createPluginTerminalThemeRegistry, + createPluginThemeRegistry +} from './plugin-theme-registries' import { PluginVmRecipeRegistry } from './plugin-vm-recipe-registry' import { PluginCommandRegistry } from './plugin-command-registry' import { verifyInstructionalPluginContent } from './plugin-instructional-content-integrity' @@ -20,6 +24,8 @@ type ContentFamily = { export class PluginContentPackRegistry { readonly languagePacks: PluginLanguagePackRegistry + readonly themes: ReturnType + readonly terminalThemes: ReturnType readonly vmRecipes: PluginVmRecipeRegistry readonly commands: PluginCommandRegistry private readonly activationErrors = new Map() @@ -30,6 +36,8 @@ export class PluginContentPackRegistry { private readonly isKilled: (pluginKey: string) => boolean ) { this.languagePacks = new PluginLanguagePackRegistry(contentVerifier) + this.themes = createPluginThemeRegistry(contentVerifier) + this.terminalThemes = createPluginTerminalThemeRegistry(contentVerifier) this.vmRecipes = new PluginVmRecipeRegistry() this.commands = new PluginCommandRegistry() } @@ -112,6 +120,8 @@ export class PluginContentPackRegistry { private families(keybindings: KeybindingOverrides = {}): readonly ContentFamily[] { return [ this.languagePacks, + this.themes, + this.terminalThemes, this.vmRecipes, { reconcile: async (discovered, isApproved) => diff --git a/src/main/plugins/plugin-marketplace-fetch.test.ts b/src/main/plugins/plugin-marketplace-fetch.test.ts index 5f91aaea204..b966c3a8222 100644 --- a/src/main/plugins/plugin-marketplace-fetch.test.ts +++ b/src/main/plugins/plugin-marketplace-fetch.test.ts @@ -30,7 +30,7 @@ describe('parsePluginMarketplaceIndex', () => { owner: 'samples', futureHeader: true, plugins: [ - { ...entry, id: 'samples.theme', release: { ...release, types: ['app-theme'] } }, + { ...entry, id: 'samples.theme', release: { ...release, types: ['icon-theme'] } }, { ...entry, id: 'samples.broken', release: { ...release, commit: 'nope' } }, { ...entry, release: { ...release, futureField: 1 }, futureField: 1 } ] diff --git a/src/main/plugins/plugin-marketplace-service.test.ts b/src/main/plugins/plugin-marketplace-service.test.ts index ae4aa84d0ec..138cc4afa03 100644 --- a/src/main/plugins/plugin-marketplace-service.test.ts +++ b/src/main/plugins/plugin-marketplace-service.test.ts @@ -183,7 +183,7 @@ describe('PluginMarketplaceService', () => { { id: 'community.midnight', source: { kind: 'git', url: 'https://github.com/community/midnight.git', ref: 'v1' }, - categories: ['themes', 'official'] + categories: ['icon-themes', 'official'] }, { id: 'community.recipes', @@ -199,6 +199,7 @@ describe('PluginMarketplaceService', () => { const added = await service.addSource(source()) + // The icon-theme pack is filtered out; only the supported recipe listing remains. await expect(service.listPlugins()).resolves.toEqual([ expect.objectContaining({ pluginKey: 'community.recipes' }) ]) diff --git a/src/main/plugins/plugin-theme-registries.test.ts b/src/main/plugins/plugin-theme-registries.test.ts new file mode 100644 index 00000000000..b2dd7a26f08 --- /dev/null +++ b/src/main/plugins/plugin-theme-registries.test.ts @@ -0,0 +1,98 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { fingerprintPluginConsent } from '../../shared/plugins/plugin-consent-fingerprint' +import { pluginManifestSchema } from '../../shared/plugins/plugin-manifest' +import { PluginContentVerifier } from './plugin-content-integrity' +import type { ValidDiscoveredPlugin } from './plugin-discovery' +import { + createPluginTerminalThemeRegistry, + createPluginThemeRegistry +} from './plugin-theme-registries' + +const roots: string[] = [] +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +async function themePlugin(files: { app?: unknown; terminal?: unknown }) { + const rootDir = await mkdtemp(join(tmpdir(), 'orca-plugin-theme-registry-')) + roots.push(rootDir) + await writeFile(join(rootDir, 'app.json'), JSON.stringify(files.app ?? {})) + await writeFile(join(rootDir, 'terminal.json'), JSON.stringify(files.terminal ?? {})) + const manifest = pluginManifestSchema.parse({ + manifestVersion: 1, + id: 'midnight', + publisher: 'samples', + name: 'Midnight', + version: '1.0.0', + engines: { orca: '>=1.0.0' }, + pluginApi: 1, + contributes: { + themes: [{ id: 'midnight', label: 'Midnight', path: 'app.json' }], + terminalThemes: [{ id: 'midnight', label: 'Midnight', path: 'terminal.json' }] + } + }) + const plugin: ValidDiscoveredPlugin = { + pluginKey: 'samples.midnight', + rootDir, + manifest, + consentFingerprint: fingerprintPluginConsent(manifest), + contentHash: null, + isDev: true + } + return plugin +} + +const terminal = { + schemaVersion: 1, + mode: 'dark', + terminal: { background: '#000000', foreground: '#ffffff', red: '#ff0000' } +} + +describe('plugin theme registries', () => { + it('loads app and terminal themes under host-owned ids', async () => { + const plugin = await themePlugin({ + app: { base: 'dark', tokens: { '--background': '#101010', '--foreground': '#f0f0f0' } }, + terminal + }) + const themes = createPluginThemeRegistry(new PluginContentVerifier()) + const terminalThemes = createPluginTerminalThemeRegistry(new PluginContentVerifier()) + + await themes.reconcile([plugin], () => true) + await terminalThemes.reconcile([plugin], () => true) + + expect(themes.list()).toEqual([ + expect.objectContaining({ + id: 'plugin:samples.midnight/midnight', + pluginKey: 'samples.midnight', + pluginName: 'Midnight', + label: 'Midnight', + base: 'dark', + tokens: expect.objectContaining({ '--background': '#101010', '--card': '#101010' }) + }) + ]) + expect(terminalThemes.list()).toEqual([ + expect.objectContaining({ id: 'plugin:samples.midnight/midnight', mode: 'dark' }) + ]) + }) + + it('reports an invalid theme and drops it, then clears when the plugin is disabled', async () => { + const plugin = await themePlugin({ + app: { base: 'dark', tokens: { '--background': 'url(https://example.com)' } }, + terminal: { ...terminal, terminal: { background: '#000000' } } + }) + const themes = createPluginThemeRegistry(new PluginContentVerifier()) + const terminalThemes = createPluginTerminalThemeRegistry(new PluginContentVerifier()) + + await themes.reconcile([plugin], () => true) + await terminalThemes.reconcile([plugin], () => true) + expect(themes.list()).toEqual([]) + expect(themes.error(plugin.pluginKey)).toContain('theme "midnight" is invalid') + expect(terminalThemes.error(plugin.pluginKey)).toContain('requires background, foreground') + + await themes.reconcile([plugin], () => false) + expect(themes.error(plugin.pluginKey)).toBeNull() + }) +}) diff --git a/src/main/plugins/plugin-theme-registries.ts b/src/main/plugins/plugin-theme-registries.ts new file mode 100644 index 00000000000..494fa49db4c --- /dev/null +++ b/src/main/plugins/plugin-theme-registries.ts @@ -0,0 +1,70 @@ +import { createHash } from 'node:crypto' +import type { PluginThemeContribution } from '../../shared/plugins/plugin-content-pack-contributions' +import { + parsePluginAppThemeArtifact, + type PluginThemeRegistration +} from '../../shared/plugins/plugin-theme-artifact' +import { + parsePluginTerminalThemeArtifact, + type PluginTerminalThemeRegistration +} from '../../shared/plugins/plugin-terminal-theme-artifact' +import { + PLUGIN_THEME_MAX_BYTES, + readContainedPluginArtifactText +} from './plugin-artifact-validation' +import { PluginArtifactPackRegistry } from './plugin-artifact-pack-registry' +import type { PluginContentVerifier } from './plugin-content-integrity' +import type { ValidDiscoveredPlugin } from './plugin-discovery' + +async function readTheme( + plugin: ValidDiscoveredPlugin, + contribution: PluginThemeContribution, + kind: string, + parse: (text: string) => T +): Promise< + T & { + id: `plugin:${string}` + revision: string + pluginKey: string + pluginName: string + label: string + } +> { + const text = await readContainedPluginArtifactText( + plugin.rootDir, + contribution.path, + PLUGIN_THEME_MAX_BYTES + ) + try { + return { + ...parse(text), + id: `plugin:${plugin.pluginKey}/${contribution.id}`, + revision: createHash('sha256').update(text).digest('hex').slice(0, 32), + pluginKey: plugin.pluginKey, + pluginName: plugin.manifest.name, + label: contribution.label + } + } catch (error) { + throw new Error( + `${kind} "${contribution.id}" is invalid: ${error instanceof Error ? error.message : String(error)}` + ) + } +} + +export function createPluginThemeRegistry(contentVerifier: PluginContentVerifier) { + return new PluginArtifactPackRegistry({ + contentVerifier, + contributions: (plugin) => plugin.manifest.contributes.themes, + load: (plugin, contribution) => + readTheme(plugin, contribution, 'theme', parsePluginAppThemeArtifact) + }) +} + +export function createPluginTerminalThemeRegistry(contentVerifier: PluginContentVerifier) { + return new PluginArtifactPackRegistry({ + contentVerifier, + contributions: (plugin) => plugin.manifest.contributes.terminalThemes, + load: (plugin, contribution) => + readTheme(plugin, contribution, 'terminal theme', parsePluginTerminalThemeArtifact) + }) +} diff --git a/src/preload/api/plugin-host-api.ts b/src/preload/api/plugin-host-api.ts index 89165079495..9858f93c470 100644 --- a/src/preload/api/plugin-host-api.ts +++ b/src/preload/api/plugin-host-api.ts @@ -1,3 +1,5 @@ +import type { PluginThemeRegistration } from '../../shared/plugins/plugin-theme-artifact' +import type { PluginTerminalThemeRegistration } from '../../shared/plugins/plugin-terminal-theme-artifact' import type { PluginPanelActionOutcome, PluginPanelEntry @@ -139,6 +141,8 @@ export type PluginMarketplaceHostInstallPreview = { export type PluginsApi = { list: () => Promise listLanguagePacks: () => Promise + listThemes: () => Promise + listTerminalThemes: () => Promise /** Approval is bound to the current capability and trusted-worker fingerprint. */ consent: (args: PluginConsentRequest) => Promise setEnabled: (args: { pluginKey: string; enabled: boolean }) => Promise diff --git a/src/preload/api/plugins-bridge.ts b/src/preload/api/plugins-bridge.ts index 0e529e74d96..92b01be3dd5 100644 --- a/src/preload/api/plugins-bridge.ts +++ b/src/preload/api/plugins-bridge.ts @@ -16,6 +16,8 @@ import type { export const pluginsApi = { list: (): Promise => ipcRenderer.invoke('plugins:list'), listLanguagePacks: () => ipcRenderer.invoke('plugins:listLanguagePacks'), + listThemes: () => ipcRenderer.invoke('plugins:listThemes'), + listTerminalThemes: () => ipcRenderer.invoke('plugins:listTerminalThemes'), consent: (args: PluginConsentRequest): Promise => ipcRenderer.invoke('plugins:consent', args), setEnabled: (args: { pluginKey: string; enabled: boolean }): Promise => diff --git a/src/renderer/src/app-shell/use-document-appearance.test.tsx b/src/renderer/src/app-shell/use-document-appearance.test.tsx index b06b9c7517c..f61c2257c8d 100644 --- a/src/renderer/src/app-shell/use-document-appearance.test.tsx +++ b/src/renderer/src/app-shell/use-document-appearance.test.tsx @@ -3,14 +3,18 @@ import { act, renderHook } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getDefaultSettings } from '../../../shared/constants' import { useAppStore } from '../store' +import { usePluginThemeStore } from '../store/plugin-themes' const mocks = vi.hoisted(() => ({ applyDocumentTheme: vi.fn(), + applyPluginAppTheme: vi.fn(), buildAppFontFamily: vi.fn((fontFamily: string | null | undefined) => fontFamily ?? '') })) vi.mock('../lib/document-theme', () => ({ - applyDocumentTheme: mocks.applyDocumentTheme + applyDocumentTheme: mocks.applyDocumentTheme, + applyPluginAppTheme: mocks.applyPluginAppTheme, + readCachedPluginAppTheme: () => null })) vi.mock('@/lib/app-font-family', () => ({ @@ -75,4 +79,44 @@ describe('useDocumentAppearance', () => { expect(mocks.buildAppFontFamily).toHaveBeenCalledTimes(2) unmount() }) + + it('applies a selected plugin theme and restores the earlier choice when it disappears', async () => { + const midnight = { + id: 'plugin:samples.midnight/midnight' as const, + revision: 'r1', + pluginKey: 'samples.midnight', + pluginName: 'Midnight', + label: 'Midnight', + base: 'light' as const, + tokens: { '--background': '#fafafa' } + } + usePluginThemeStore.setState({ entries: [midnight], loaded: true }) + useAppStore.setState({ + updateSettings: async (updates) => { + const current = useAppStore.getState().settings ?? getDefaultSettings('/tmp') + useAppStore.setState({ settings: { ...current, ...updates } }) + } + }) + act(() => { + const settings = useAppStore.getState().settings! + useAppStore.setState({ + settings: { + ...settings, + theme: midnight.base, + themeBeforePlugin: 'dark', + pluginAppTheme: midnight.id + } + }) + }) + const { unmount } = renderHook(() => useDocumentAppearance()) + expect(mocks.applyPluginAppTheme).toHaveBeenLastCalledWith(midnight) + expect(mocks.applyDocumentTheme).toHaveBeenLastCalledWith('light') + + act(() => usePluginThemeStore.setState({ entries: [] })) + expect(mocks.applyPluginAppTheme).toHaveBeenLastCalledWith(null) + // The earlier built-in choice comes back with the built-in colors. + await vi.waitFor(() => expect(useAppStore.getState().settings?.theme).toBe('dark')) + unmount() + usePluginThemeStore.setState({ entries: [], loaded: false }) + }) }) diff --git a/src/renderer/src/app-shell/use-document-appearance.ts b/src/renderer/src/app-shell/use-document-appearance.ts index 25f20d89783..ed7c9b8795c 100644 --- a/src/renderer/src/app-shell/use-document-appearance.ts +++ b/src/renderer/src/app-shell/use-document-appearance.ts @@ -1,11 +1,25 @@ import { useEffect } from 'react' import { buildAppFontFamily } from '@/lib/app-font-family' -import { applyDocumentTheme } from '../lib/document-theme' +import { applyDocumentTheme, applyPluginAppTheme } from '../lib/document-theme' +import { + usePluginAppThemeSync, + usePluginTerminalPaletteRefresh, + usePluginThemeStore, + usePluginThemes +} from '../store/plugin-themes' import { scheduleRuntimeGraphSync } from '../runtime/sync-runtime-graph' import { useAppStore } from '../store' export function useDocumentAppearance(): void { + const selectedPluginTheme = useAppStore((s) => s.settings?.pluginAppTheme) + // Why find by id: a disabled or removed plugin drops out of the list, which restores built-in colors. + const pluginTheme = usePluginThemes().find((entry) => entry.id === selectedPluginTheme) ?? null + const pluginThemesLoaded = usePluginThemeStore((s) => s.loaded) + // Why settings.theme alone: choosing a plugin theme stores its base there, so every reader of + // the light/dark mode agrees with the painted colors. const theme = useAppStore((s) => s.settings?.theme) + usePluginTerminalPaletteRefresh() + usePluginAppThemeSync() const appFontFamily = useAppStore((s) => s.settings?.appFontFamily) useEffect(() => { @@ -31,6 +45,14 @@ export function useDocumentAppearance(): void { return () => mq.removeEventListener('change', handler) }, [theme]) + useEffect(() => { + // Why wait for the list: until it loads, the startup cache is still the right paint. + // Why theme too: contrast is checked against the built-ins of the current mode. + if (pluginThemesLoaded && theme) { + applyPluginAppTheme(pluginTheme) + } + }, [pluginTheme, pluginThemesLoaded, theme]) + useEffect(() => { document.documentElement.style.setProperty( '--app-font-family', diff --git a/src/renderer/src/components/settings/AppearanceInterfaceSection.tsx b/src/renderer/src/components/settings/AppearanceInterfaceSection.tsx index 3e5d495d993..d404ce83c3b 100644 --- a/src/renderer/src/components/settings/AppearanceInterfaceSection.tsx +++ b/src/renderer/src/components/settings/AppearanceInterfaceSection.tsx @@ -8,12 +8,7 @@ import { AppearanceAdvancedDisclosure } from './AppearanceAdvancedDisclosure' import { useAppStore } from '../../store' import { useShortcutKeyComboDetails } from '@/hooks/useShortcutLabel' import { ShortcutHintList } from './AppearanceShortcutHintList' -import { - FontAutocomplete, - SettingsRow, - SettingsSegmentedControl, - SettingsSwitchRow -} from './SettingsFormControls' +import { FontAutocomplete, SettingsRow, SettingsSwitchRow } from './SettingsFormControls' import { DEFAULT_APP_FONT_FAMILY } from '../../../../shared/constants' import { getLanguageEntries, @@ -33,6 +28,7 @@ import { translate } from '@/i18n/i18n' import type { UiLanguage } from '../../../../shared/ui-language' import { matchesSettingsSearch, normalizeSettingsSearchQuery } from './settings-search' import { usePluginLanguagePacks } from '@/store/plugin-language-packs' +import { AppearanceThemeControl } from './AppearanceThemeControl' type AppearanceInterfaceSectionProps = { settings: GlobalSettings @@ -87,27 +83,11 @@ export function AppearanceInterfaceSection({ { - updateSettings({ theme: option }) - applyTheme(option) - }} - options={[ - { - value: 'system', - label: translate('auto.components.settings.AppearancePane.fb0e0b4453', 'System') - }, - { - value: 'dark', - label: translate('auto.components.settings.AppearancePane.7d26ccabe8', 'Dark') - }, - { - value: 'light', - label: translate('auto.components.settings.AppearancePane.fd89b5487c', 'Light') - } - ]} + } /> diff --git a/src/renderer/src/components/settings/AppearanceThemeControl.test.tsx b/src/renderer/src/components/settings/AppearanceThemeControl.test.tsx new file mode 100644 index 00000000000..ef69830631f --- /dev/null +++ b/src/renderer/src/components/settings/AppearanceThemeControl.test.tsx @@ -0,0 +1,104 @@ +// @vitest-environment happy-dom +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import { getDefaultSettings } from '../../../../shared/constants' +import { AppearanceThemeControl } from './AppearanceThemeControl' + +const themes = vi.hoisted((): { app: unknown[]; terminal: unknown[] } => ({ + app: [], + terminal: [] +})) +vi.mock('@/store/plugin-themes', () => ({ + usePluginThemes: () => themes.app, + usePluginTerminalThemes: () => themes.terminal +})) + +afterEach(() => { + cleanup() + themes.app = [] + themes.terminal = [] +}) + +const dusk = { + id: 'plugin:samples.dusk/dusk', + pluginKey: 'samples.dusk', + revision: 'r1', + pluginName: 'Dusk', + label: 'Dusk', + base: 'dark', + tokens: { '--background': '#1b1430', '--foreground': '#e8e0ff' } +} + +it('keeps the System/Dark/Light segments until a plugin offers a theme', () => { + render( + + ) + expect(screen.getByRole('radiogroup', { name: 'Theme' })).toBeDefined() +}) + +it('lists plugin themes in the one Theme select and offers the plugin terminal colors', () => { + themes.app = [dusk] + themes.terminal = [ + { + ...dusk, + mode: 'dark', + terminal: { background: '#1b1430', foreground: '#e8e0ff', red: '#f00' } + } + ] + const updateSettings = vi.fn() + render( + + ) + expect(screen.getByRole('combobox', { name: 'Theme' }).textContent).toContain('Dusk') + screen.getByRole('button', { name: 'Use its terminal colors' }).click() + expect(updateSettings).toHaveBeenCalledWith({ + terminalThemeDark: 'plugin:samples.dusk/dusk', + terminalPluginPalettes: [ + { + id: 'plugin:samples.dusk/dusk', + colors: { background: '#1b1430', foreground: '#e8e0ff', red: '#f00' } + } + ] + }) + // Overrides would otherwise mask the palette the user just chose. + expect(updateSettings.mock.calls[0]?.[0]).toHaveProperty('terminalColorOverrides', undefined) +}) + +it('remembers the built-in choice and names the publisher when theme names collide', async () => { + themes.app = [ + dusk, + { ...dusk, id: 'plugin:acme.dusk/dusk', pluginKey: 'acme.dusk', pluginName: 'Acme Dusk' } + ] + const updateSettings = vi.fn() + render( + + ) + screen.getByRole('combobox', { name: 'Theme' }).click() + const option = await screen.findByRole('option', { name: 'Dusk · acme' }) + option.click() + expect(updateSettings).toHaveBeenCalledWith({ + theme: 'dark', + pluginAppTheme: 'plugin:acme.dusk/dusk', + themeBeforePlugin: 'system' + }) +}) diff --git a/src/renderer/src/components/settings/AppearanceThemeControl.tsx b/src/renderer/src/components/settings/AppearanceThemeControl.tsx new file mode 100644 index 00000000000..c9febb8aa21 --- /dev/null +++ b/src/renderer/src/components/settings/AppearanceThemeControl.tsx @@ -0,0 +1,146 @@ +import type React from 'react' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import { Button } from '../ui/button' +import { + Select, + SelectContent, + SelectItem, + SelectSeparator, + SelectTrigger, + SelectValue +} from '../ui/select' +import { SettingsSegmentedControl } from './SettingsFormControls' +import { translate } from '@/i18n/i18n' +import { usePluginTerminalThemes, usePluginThemes } from '@/store/plugin-themes' +import { withTerminalPluginPalettes } from '@/lib/terminal-plugin-palettes' + +type BuiltInTheme = GlobalSettings['theme'] + +type AppearanceThemeControlProps = { + label: string + settings: GlobalSettings + updateSettings: (updates: Partial) => void + applyTheme: (theme: BuiltInTheme) => void +} + +function builtInOptions(): { value: BuiltInTheme; label: string }[] { + return [ + { + value: 'system', + label: translate('auto.components.settings.AppearancePane.fb0e0b4453', 'System') + }, + { + value: 'dark', + label: translate('auto.components.settings.AppearancePane.7d26ccabe8', 'Dark') + }, + { + value: 'light', + label: translate('auto.components.settings.AppearancePane.fd89b5487c', 'Light') + } + ] +} + +/** One Theme choice: System, Dark and Light, then any enabled plugin themes. A plugin theme + * stores its base in `theme`, so every light/dark reader follows the painted colors. */ +export function AppearanceThemeControl({ + label, + settings, + updateSettings, + applyTheme +}: AppearanceThemeControlProps): React.JSX.Element { + const pluginThemes = usePluginThemes() + const pluginTerminalThemes = usePluginTerminalThemes() + const selectBuiltIn = (theme: BuiltInTheme): void => { + updateSettings({ theme, pluginAppTheme: null, themeBeforePlugin: null }) + applyTheme(theme) + } + if (pluginThemes.length === 0) { + return ( + + ) + } + const active = pluginThemes.find((theme) => theme.id === settings.pluginAppTheme) + const terminalSlot = + active?.base === 'light' && settings.terminalUseSeparateLightTheme + ? 'terminalThemeLight' + : 'terminalThemeDark' + const matchingTerminal = active + ? (pluginTerminalThemes.find( + (theme) => theme.pluginKey === active.pluginKey && theme.id === active.id + ) ?? pluginTerminalThemes.find((theme) => theme.pluginKey === active.pluginKey)) + : undefined + return ( +
+ + {matchingTerminal && settings[terminalSlot] !== matchingTerminal.id ? ( + + ) : null} +
+ ) +} diff --git a/src/renderer/src/components/settings/PluginMarketplaceBrowser.test.tsx b/src/renderer/src/components/settings/PluginMarketplaceBrowser.test.tsx index ad9e3ccc924..08ad725852a 100644 --- a/src/renderer/src/components/settings/PluginMarketplaceBrowser.test.tsx +++ b/src/renderer/src/components/settings/PluginMarketplaceBrowser.test.tsx @@ -72,7 +72,9 @@ const preview: PluginMarketplaceHostInstallPreview = { languagePacks: [], keybindings: [], vmRecipes: [], - agents: [] + agents: [], + themes: [], + terminalThemes: [] }, capabilities: [{ kind: 'workspace:read' }] } diff --git a/src/renderer/src/components/settings/TerminalThemePicker.tsx b/src/renderer/src/components/settings/TerminalThemePicker.tsx index 38a34639356..c1c6ffe9db2 100644 --- a/src/renderer/src/components/settings/TerminalThemePicker.tsx +++ b/src/renderer/src/components/settings/TerminalThemePicker.tsx @@ -70,6 +70,10 @@ export function ThemePicker({ themes: matchingThemes .filter((theme) => theme.group === 'imported') .slice(0, MAX_THEME_RESULTS) + }, + { + label: translate('auto.components.settings.SettingsFormControls.plugin_themes', 'Plugins'), + themes: matchingThemes.filter((theme) => theme.group === 'plugin').slice(0, MAX_THEME_RESULTS) } ].filter((group) => group.themes.length > 0) const visibleThemeCount = groupedThemes.reduce((sum, group) => sum + group.themes.length, 0) @@ -146,17 +150,24 @@ export function ThemePicker({ {theme.label} {theme.sourceLabel ? ( - {translate( - 'auto.components.settings.SettingsFormControls.imported_from', - 'Imported from {{value0}}', - { value0: theme.sourceLabel } - )} + {theme.group === 'plugin' + ? translate( + 'auto.components.settings.SettingsFormControls.from_plugin', + 'From {{value0}}', + { value0: theme.sourceLabel } + ) + : translate( + 'auto.components.settings.SettingsFormControls.imported_from', + 'Imported from {{value0}}', + { value0: theme.sourceLabel } + )} {theme.mode && theme.mode !== 'unknown' ? ` · ${theme.mode}` : ''} ) : null} - {/* Hide current-row swatches so the Current label cannot shift their grid. */} - {theme.group === 'imported' && + {/* Why: hide swatches on the current row so the color grid + doesn't shift left to make room for the "Current" label. */} + {theme.group !== 'built-in' && theme.previewTheme && selectedTheme !== theme.value ? ( diff --git a/src/renderer/src/components/settings/TerminalThemeSections.lifecycle.test.ts b/src/renderer/src/components/settings/TerminalThemeSections.lifecycle.test.ts index fed924cfceb..462e05c9526 100644 --- a/src/renderer/src/components/settings/TerminalThemeSections.lifecycle.test.ts +++ b/src/renderer/src/components/settings/TerminalThemeSections.lifecycle.test.ts @@ -5,6 +5,12 @@ import type { UseWarpThemeImportReturn } from './useWarpThemeImport' let themeTarget: 'dark' | 'light' | undefined = 'dark' +// Why: these tests drive hooks through a stubbed React, so the plugin theme store is stubbed too. +vi.mock('@/store/plugin-themes', () => ({ + usePluginTerminalThemes: () => [], + usePluginTerminalThemeStore: { getState: () => ({ entries: [] }) } +})) + vi.mock('react', async () => { const actual = await vi.importActual('react') return { diff --git a/src/renderer/src/components/settings/TerminalThemeSections.tsx b/src/renderer/src/components/settings/TerminalThemeSections.tsx index c64c6272873..abea84bc6f9 100644 --- a/src/renderer/src/components/settings/TerminalThemeSections.tsx +++ b/src/renderer/src/components/settings/TerminalThemeSections.tsx @@ -1,3 +1,5 @@ +import { usePluginTerminalThemes } from '@/store/plugin-themes' +import { withTerminalPluginPalettes } from '@/lib/terminal-plugin-palettes' import { useState, type Dispatch, type ReactNode, type SetStateAction } from 'react' import type { GlobalSettings } from '../../../../shared/global-settings-types' import { @@ -95,7 +97,8 @@ export function TerminalThemeCatalogSection({ rememberTerminalThemeTarget(nextTarget) setTargetState(nextTarget) } - const themeOptions = getAvailableTerminalThemeOptions(settings) + const pluginTerminalThemes = usePluginTerminalThemes() + const themeOptions = getAvailableTerminalThemeOptions(settings, pluginTerminalThemes) const isLightTarget = target === 'light' const matchDarkMode = !settings.terminalUseSeparateLightTheme const lightModeMatchesDark = isLightTarget && matchDarkMode @@ -255,15 +258,14 @@ export function TerminalThemeCatalogSection({ const hasColorOverrides = Object.keys(settings.terminalColorOverrides ?? {}).length > 0 updateSettings( - isLightTarget - ? { - terminalThemeLight: theme, - ...(hasColorOverrides ? { terminalColorOverrides: undefined } : {}) - } - : { - terminalThemeDark: theme, - ...(hasColorOverrides ? { terminalColorOverrides: undefined } : {}) - } + withTerminalPluginPalettes( + settings, + { + [isLightTarget ? 'terminalThemeLight' : 'terminalThemeDark']: theme, + ...(hasColorOverrides ? { terminalColorOverrides: undefined } : {}) + }, + pluginTerminalThemes + ) ) }} importedHighlightSignal={importedHighlightSignal} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index de2aff446fd..ecfc0b5c1d8 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -8355,7 +8355,9 @@ "fbb428db98": "Selected:", "search_terminal_themes": "Search terminal themes", "cb330ef7f8": " of {{value0}}", - "c822571b2e": " matching \"{{value0}}\"" + "c822571b2e": " matching \"{{value0}}\"", + "plugin_themes": "Plugins", + "from_plugin": "From {{value0}}" }, "SettingsSidebar": { "e0900f83e7": "SSH", @@ -12496,6 +12498,9 @@ "shortcut": "shortcut" } }, + "AppearanceThemeControl": { + "useTerminalTheme": "Use its terminal colors" + }, "notification": { "settings": { "copy": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 5359668998b..c9e7cb8046c 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -7944,7 +7944,9 @@ "builtin_themes": "Integrados", "imported_from": "Importado desde {{value0}}", "imported_themes": "Importados", - "search_terminal_themes": "Buscar temas de terminal" + "search_terminal_themes": "Buscar temas de terminal", + "plugin_themes": "Plugins", + "from_plugin": "De {{value0}}" }, "SettingsSidebar": { "e0900f83e7": "SSH", @@ -12094,6 +12096,9 @@ "noMatches": "No hay compilaciones coincidentes.", "searchPlaceholder": "Buscar compilaciones…" }, + "AppearanceThemeControl": { + "useTerminalTheme": "Usar sus colores de terminal" + }, "notification": { "settings": { "copy": { diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index a243674e670..6584a9b95cc 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -8168,7 +8168,9 @@ "fbb428db98": "Sélectionné :", "search_terminal_themes": "Rechercher des thèmes de terminal", "cb330ef7f8": " sur {{value0}}", - "c822571b2e": " correspondant à \"{{value0}}\"" + "c822571b2e": " correspondant à \"{{value0}}\"", + "plugin_themes": "Plugins", + "from_plugin": "De {{value0}}" }, "SettingsSidebar": { "e0900f83e7": "SSH", @@ -12280,6 +12282,9 @@ "searchLabel": "Rechercher des versions", "noMatches": "Aucune version correspondante." }, + "AppearanceThemeControl": { + "useTerminalTheme": "Utiliser ses couleurs de terminal" + }, "notification": { "settings": { "copy": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 310c36ccf94..2bcb7ea6671 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -7949,7 +7949,9 @@ "fbb428db98": "選択済み:", "search_terminal_themes": "ターミナルテーマを検索", "cb330ef7f8": "/{{value0}}", - "c822571b2e": " 「{{value0}}」に一致" + "c822571b2e": " 「{{value0}}」に一致", + "plugin_themes": "プラグイン", + "from_plugin": "{{value0}} から" }, "SettingsSidebar": { "e0900f83e7": "SSH", @@ -12077,6 +12079,9 @@ "searchPlaceholder": "ビルドを検索…", "noMatches": "一致するビルドはありません。" }, + "AppearanceThemeControl": { + "useTerminalTheme": "このテーマのターミナル配色を使う" + }, "notification": { "settings": { "copy": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index a6865420000..a974fd6f6ce 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -7912,7 +7912,9 @@ "builtin_themes": "기본 제공", "imported_from": "{{value0}}에서 가져옴", "imported_themes": "가져옴", - "search_terminal_themes": "터미널 테마 검색" + "search_terminal_themes": "터미널 테마 검색", + "plugin_themes": "플러그인", + "from_plugin": "{{value0}}에서" }, "SettingsSidebar": { "e0900f83e7": "SSH", @@ -12077,6 +12079,9 @@ "searchPlaceholder": "빌드 검색…", "searchLabel": "빌드 검색" }, + "AppearanceThemeControl": { + "useTerminalTheme": "이 테마의 터미널 색상 사용" + }, "notification": { "settings": { "copy": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index ba72a57f72b..95d886d0751 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -7912,7 +7912,9 @@ "builtin_themes": "内置", "imported_from": "从 {{value0}} 导入", "imported_themes": "已导入", - "search_terminal_themes": "搜索终端主题" + "search_terminal_themes": "搜索终端主题", + "plugin_themes": "插件", + "from_plugin": "来自 {{value0}}" }, "SettingsSidebar": { "e0900f83e7": "SSH", @@ -12110,6 +12112,9 @@ "noMatches": "没有匹配的版本。", "searchLabel": "搜索构建" }, + "AppearanceThemeControl": { + "useTerminalTheme": "使用其终端配色" + }, "notification": { "settings": { "copy": { diff --git a/src/renderer/src/lib/document-theme.test.ts b/src/renderer/src/lib/document-theme.test.ts index 759f4ceb12c..2deacee4c66 100644 --- a/src/renderer/src/lib/document-theme.test.ts +++ b/src/renderer/src/lib/document-theme.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it } from 'vitest' -import { applyDocumentTheme, THEME_TRANSITION_DISABLED_CLASS } from './document-theme' +import { parsePluginAppThemeArtifact } from '../../../shared/plugins/plugin-theme-artifact' +import { + applyCachedPluginAppTheme, + applyDocumentTheme, + applyPluginAppTheme, + readCachedPluginAppTheme, + THEME_TRANSITION_DISABLED_CLASS +} from './document-theme' class FakeClassList { private readonly tokens = new Set() @@ -118,3 +125,173 @@ describe('document theme', () => { expect(frames.pendingCount()).toBe(0) }) }) + +describe('applyPluginAppTheme', () => { + function fakeRoot() { + const properties = new Map() + return { + properties, + style: { + setProperty: (name: string, value: string | null) => { + properties.set(name, value ?? '') + }, + removeProperty: (name: string) => { + const previous = properties.get(name) ?? '' + properties.delete(name) + return previous + } + } + } + } + function memoryStorage(): Storage { + const values = new Map() + return { + get length() { + return values.size + }, + clear: () => values.clear(), + getItem: (key) => values.get(key) ?? null, + key: (index) => [...values.keys()][index] ?? null, + removeItem: (key) => { + values.delete(key) + }, + setItem: (key, value) => { + values.set(key, value) + } + } + } + // Test probe: #rrggbb or #rrggbbaa. + const probe = (value: string) => { + const match = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})?$/i.exec(value) + if (!match) { + return null + } + const [r = 0, g = 0, b = 0] = match.slice(1, 4).map((hex) => Number.parseInt(hex, 16)) + return [r, g, b, Number.parseInt(match[4] ?? 'ff', 16) / 255] as const + } + // Like the browser, resolves color-mix(in srgb, A N%, B) for derived tokens. + const mixingProbe = (value: string) => { + const mix = /^color-mix\(in srgb, (#[0-9a-f]{6}) (\d+)%, (#[0-9a-f]{6})\)$/i.exec(value) + if (!mix) { + return probe(value) + } + const a = probe(mix[1] ?? '') + const b = probe(mix[3] ?? '') + const weight = Number(mix[2]) / 100 + if (!a || !b) { + return null + } + const channel = (i: number) => (a[i] ?? 0) * weight + (b[i] ?? 0) * (1 - weight) + return [channel(0), channel(1), channel(2), 1] as const + } + // Orca's dark built-ins, as the document would report them. + const darkBuiltIns = () => ({ + '--background': '#0a0a0a', + '--foreground': '#fafafa', + '--muted-foreground': '#a1a1a1', + '--border': '#ffffff12', + '--ring': '#737373' + }) + const options = (root = fakeRoot()) => ({ root, probe, builtIns: darkBuiltIns, storage: null }) + const theme = (tokens: Record) => ({ + id: 'plugin:samples.midnight/midnight' as const, + revision: 'r1', + pluginKey: 'samples.midnight', + pluginName: 'Midnight', + label: 'Midnight', + base: 'dark' as const, + tokens + }) + const painted = (tokens: Record) => { + const root = fakeRoot() + applyPluginAppTheme(theme(tokens), options(root)) + return Object.fromEntries(root.properties) + } + + it('overlays tokens, drops ones a new theme stops setting, and restores built-ins on removal', () => { + const root = fakeRoot() + applyPluginAppTheme(theme({ '--background': '#000000', '--border': '#111111' }), options(root)) + expect(Object.fromEntries(root.properties)).toEqual({ + '--background': '#000000', + '--border': '#111111' + }) + applyPluginAppTheme(theme({ '--background': '#222222' }), options(root)) + expect(Object.fromEntries(root.properties)).toEqual({ '--background': '#222222' }) + applyPluginAppTheme(null, options(root)) + expect(root.properties.size).toBe(0) + }) + + it('checks contrast on the effective set, so dropping one pair cannot expose another', () => { + // White background passes with black text, but fails against muted text; dropping the + // background leaves black text on the dark built-in background, so that drops too. + expect( + painted({ + '--background': '#ffffff', + '--foreground': '#000000', + '--muted-foreground': '#ffffff' + }) + ).toEqual({}) + }) + + it('checks plugin colors against the built-ins they sit on', () => { + // A light plugin surface would put the dark built-in light text on near-white. + expect(painted({ '--background': '#f4f4f4' })).toEqual({}) + expect(painted({ '--background': '#151520' })).toEqual({ '--background': '#151520' }) + }) + + it('keeps text readable on the editor surface notebooks paint on', () => { + const tokens = { '--background': '#000000', '--foreground': '#ffffff' } + expect(painted({ ...tokens, '--editor-surface': '#ffffff' })).not.toHaveProperty( + '--editor-surface' + ) + expect(painted({ ...tokens, '--editor-surface': '#111111' })).toHaveProperty( + '--editor-surface', + '#111111' + ) + }) + + it('rejects invisible rings and borders in any spelling', () => { + expect( + painted({ '--ring': '#0a0a0a', '--border': '#00000000', '--input': '#ffffff03' }) + ).toEqual({}) + expect(painted({ '--ring': '#ff2bd6', '--border': '#ffffff1a' })).toEqual({ + '--ring': '#ff2bd6', + '--border': '#ffffff1a' + }) + }) + + it('keeps the startup cache tied to the theme id and revision until cleared', () => { + const storage = memoryStorage() + applyPluginAppTheme(theme({ '--background': '#101018' }), { ...options(), storage }) + expect(readCachedPluginAppTheme(storage)).toEqual({ + id: 'plugin:samples.midnight/midnight', + revision: 'r1', + base: 'dark', + tokens: { '--background': '#101018' } + }) + const startup = fakeRoot() + const cached = readCachedPluginAppTheme(storage) + if (cached) { + applyCachedPluginAppTheme(cached, { root: startup, probe, builtIns: darkBuiltIns }) + } + expect(startup.properties.get('--background')).toBe('#101018') + + applyPluginAppTheme(null, { ...options(), storage }) + expect(readCachedPluginAppTheme(storage)).toBeNull() + }) + + it('paints a minimal two-token theme with readable derived muted text, borders and accents', () => { + const { tokens } = parsePluginAppThemeArtifact( + JSON.stringify({ + base: 'dark', + tokens: { '--background': '#1b1430', '--foreground': '#e8e0ff' } + }) + ) + const root = fakeRoot() + applyPluginAppTheme(theme(tokens), { ...options(root), probe: mixingProbe }) + for (const token of ['--muted-foreground', '--border', '--accent', '--sidebar-accent']) { + expect(root.properties.get(token)).toMatch(/^color-mix/) + } + expect(root.properties.get('--background')).toBe('#1b1430') + }) +}) diff --git a/src/renderer/src/lib/document-theme.ts b/src/renderer/src/lib/document-theme.ts index 9354caf0590..3e6a20a7aa3 100644 --- a/src/renderer/src/lib/document-theme.ts +++ b/src/renderer/src/lib/document-theme.ts @@ -1,4 +1,9 @@ import type { GlobalSettings } from '../../../shared/global-settings-types' +import { + PLUGIN_APP_THEME_TOKENS, + type PluginThemeRegistration +} from '../../../shared/plugins/plugin-theme-artifact' +import { browserColorProbe, safePluginThemeTokens, type ColorProbe } from './plugin-theme-tokens' export type DocumentThemePreference = GlobalSettings['theme'] @@ -95,3 +100,132 @@ export function applyDocumentTheme( }) pendingTransitionDisableFrames.push(firstFrame) } + +type PluginThemeRoot = { + style: Pick +} + +export type CachedPluginAppTheme = { + id: string + revision: string + base: 'light' | 'dark' + tokens: Record +} + +type PluginThemePaintOptions = { + root?: PluginThemeRoot + probe?: ColorProbe + /** Built-in token values for the current light/dark mode; read from the document by default. */ + builtIns?: () => Record +} + +const PUBLIC_TOKENS = new Set(PLUGIN_APP_THEME_TOKENS) +const appliedPluginTokens = new WeakMap() +// Why: plugin themes load after discovery; repainting from this cache before first paint +// avoids flashing the built-in theme on every launch. +const PLUGIN_THEME_CACHE_KEY = 'orca.pluginAppTheme.lastApplied' + +function setPluginTokens(root: PluginThemeRoot, tokens: Record): void { + for (const token of appliedPluginTokens.get(root) ?? []) { + if (!tokens[token]) { + root.style.removeProperty(token) + } + } + for (const [token, value] of Object.entries(tokens)) { + root.style.setProperty(token, value) + } + appliedPluginTokens.set(root, Object.keys(tokens)) +} + +function documentBuiltIns(): Record { + const style = getComputedStyle(document.documentElement) + return Object.fromEntries( + PLUGIN_APP_THEME_TOKENS.map((token) => [token, style.getPropertyValue(token).trim()]) + ) +} + +/** Paints tokens that stay readable over this mode's built-ins; returns what was painted. */ +function paintPluginTokens( + tokens: Record, + options: PluginThemePaintOptions +): Record { + const root = options.root ?? document.documentElement + // Why clear first: built-ins must be read without the previous theme's overrides. + setPluginTokens(root, {}) + const safe = safePluginThemeTokens( + tokens, + (options.builtIns ?? documentBuiltIns)(), + options.probe ?? browserColorProbe + ) + setPluginTokens(root, safe) + return safe +} + +function writeCache(storage: Storage | null, cached: CachedPluginAppTheme | null): void { + try { + if (cached) { + storage?.setItem(PLUGIN_THEME_CACHE_KEY, JSON.stringify(cached)) + } else { + storage?.removeItem(PLUGIN_THEME_CACHE_KEY) + } + } catch { + // Storage can be full or unavailable; the cache only shortens startup flashes. + } +} + +/** Overlays a plugin theme's safe tokens; tokens it no longer sets fall back to main.css. Call + * after the document's light/dark mode matches the theme's base. */ +export function applyPluginAppTheme( + theme: PluginThemeRegistration | null, + options: PluginThemePaintOptions & { storage?: Storage | null } = {} +): void { + const storage = options.storage === undefined ? globalThis.localStorage : options.storage + if (!theme) { + setPluginTokens(options.root ?? document.documentElement, {}) + writeCache(storage, null) + return + } + const tokens = paintPluginTokens(theme.tokens, options) + writeCache(storage, { id: theme.id, revision: theme.revision, base: theme.base, tokens }) +} + +/** The last applied plugin theme, for painting before settings and plugins load. */ +export function readCachedPluginAppTheme( + storage: Storage | null = globalThis.localStorage ?? null +): CachedPluginAppTheme | null { + try { + const cached: unknown = JSON.parse(storage?.getItem(PLUGIN_THEME_CACHE_KEY) ?? 'null') + if ( + typeof cached !== 'object' || + cached === null || + !('id' in cached) || + typeof cached.id !== 'string' || + !('revision' in cached) || + typeof cached.revision !== 'string' || + !('base' in cached) || + (cached.base !== 'light' && cached.base !== 'dark') || + !('tokens' in cached) || + typeof cached.tokens !== 'object' || + cached.tokens === null + ) { + return null + } + const tokens = Object.fromEntries( + Object.entries(cached.tokens).filter( + (entry): entry is [string, string] => + PUBLIC_TOKENS.has(entry[0]) && typeof entry[1] === 'string' + ) + ) + return { id: cached.id, revision: cached.revision, base: cached.base, tokens } + } catch { + return null + } +} + +/** Repaints a cached theme at startup, re-checked against this mode's built-ins. */ +export function applyCachedPluginAppTheme( + cached: CachedPluginAppTheme, + options: PluginThemePaintOptions = {} +): void { + paintPluginTokens(cached.tokens, options) +} diff --git a/src/renderer/src/lib/plugin-theme-tokens.ts b/src/renderer/src/lib/plugin-theme-tokens.ts new file mode 100644 index 00000000000..86c0a9d4ad7 --- /dev/null +++ b/src/renderer/src/lib/plugin-theme-tokens.ts @@ -0,0 +1,146 @@ +/** sRGB channels 0-255 and alpha 0-1, or null when the value is not a CSS color. */ +export type ColorProbe = (value: string) => readonly [number, number, number, number] | null +type Rgba = readonly [number, number, number, number] + +// Why: a theme must not be able to hide text, so every text/surface pair keeps WCAG AA contrast. +const CONTRAST_PAIRS: readonly (readonly [string, string])[] = [ + ['--background', '--foreground'], + ['--background', '--muted-foreground'], + // Notebook markdown renders directly on the editor surface. + ['--editor-surface', '--foreground'], + ['--editor-surface', '--muted-foreground'], + ['--card', '--card-foreground'], + ['--popover', '--popover-foreground'], + ['--primary', '--primary-foreground'], + ['--secondary', '--secondary-foreground'], + ['--muted', '--muted-foreground'], + ['--accent', '--accent-foreground'], + ['--sidebar', '--sidebar-foreground'], + ['--sidebar-primary', '--sidebar-primary-foreground'], + ['--sidebar-accent', '--sidebar-accent-foreground'], + ['--worktree-sidebar', '--worktree-sidebar-foreground'], + ['--worktree-sidebar-accent', '--worktree-sidebar-accent-foreground'] +] +const MIN_TEXT_CONTRAST = 4.5 +// Why 2:1, not WCAG's 3:1: Orca's own light ring is 2.6:1; the goal is a ring that never vanishes. +const RINGS: readonly (readonly [string, string])[] = [ + ['--ring', '--background'], + ['--sidebar-ring', '--sidebar'], + ['--worktree-sidebar-ring', '--worktree-sidebar'] +] +const MIN_RING_CONTRAST = 2 +// Why a floor, not opaque: Orca's own dark borders are 7% white; 0 or near-0 alpha hides them. +const BORDERS = ['--border', '--input', '--sidebar-border', '--worktree-sidebar-border'] +const MIN_BORDER_ALPHA = 0.05 + +function luminance([r, g, b]: Rgba): number { + const channel = (value: number): number => { + const c = value / 255 + return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4 + } + return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b) +} + +function contrast(a: Rgba, b: Rgba): number { + const first = luminance(a) + const second = luminance(b) + return (Math.max(first, second) + 0.05) / (Math.min(first, second) + 0.05) +} + +function over([r, g, b, a]: Rgba, [br, bg, bb]: Rgba): Rgba { + return [r * a + br * (1 - a), g * a + bg * (1 - a), b * a + bb * (1 - a), 1] +} + +/** + * The plugin tokens that are safe to paint over the built-in ones for the theme's base. + * Checks run on the effective set (plugin values, else built-in), and dropping a token can + * expose a built-in value that breaks another pair, so checks repeat until nothing changes. + * Dropped tokens keep their built-in values. + */ +export function safePluginThemeTokens( + tokens: Readonly>, + builtIns: Readonly>, + probe: ColorProbe +): Record { + const accepted = new Map() + for (const [token, value] of Object.entries(tokens)) { + const color = probe(value) + if (color && !(BORDERS.includes(token) && color[3] < MIN_BORDER_ALPHA)) { + accepted.set(token, color) + } + } + const builtInColors = new Map() + const effective = (token: string): Rgba | null => { + const own = accepted.get(token) + if (own) { + return own + } + if (!builtInColors.has(token)) { + builtInColors.set(token, builtIns[token] ? probe(builtIns[token]) : null) + } + return builtInColors.get(token) ?? null + } + const drop = (...pair: string[]): boolean => + pair.map((token) => accepted.delete(token)).some(Boolean) + + for (let changed = true; changed;) { + changed = false + for (const [surface, text] of CONTRAST_PAIRS) { + const surfaceColor = effective(surface) + const textColor = effective(text) + if ( + surfaceColor && + textColor && + (surfaceColor[3] < 1 || + textColor[3] < 1 || + contrast(surfaceColor, textColor) < MIN_TEXT_CONTRAST) + ) { + changed = drop(surface, text) || changed + } + } + for (const [ring, surface] of RINGS) { + const ringColor = accepted.get(ring) + const surfaceColor = effective(surface) + if ( + ringColor && + surfaceColor && + contrast(over(ringColor, surfaceColor), surfaceColor) < MIN_RING_CONTRAST + ) { + changed = drop(ring) || changed + } + } + } + return Object.fromEntries(Object.entries(tokens).filter(([token]) => accepted.has(token))) +} + +let probeContext: OffscreenCanvasRenderingContext2D | null | undefined + +/** Resolves a color through the browser's own parser by painting one pixel. */ +export const browserColorProbe: ColorProbe = (value) => { + if (typeof CSS === 'undefined' || !CSS.supports('color', value)) { + return null + } + probeContext ??= + typeof OffscreenCanvas === 'undefined' + ? null + : new OffscreenCanvas(1, 1).getContext('2d', { willReadFrequently: true }) + if (!probeContext) { + return null + } + const context = probeContext + // Why two seeds: fillStyle silently keeps its old value for values it rejects (e.g. `inherit`). + const paint = (seed: string): number[] => { + context.clearRect(0, 0, 1, 1) + context.fillStyle = seed + context.fillStyle = value + context.fillRect(0, 0, 1, 1) + return [...context.getImageData(0, 0, 1, 1).data] + } + const first = paint('#000000') + const second = paint('#ffffff') + if (first.some((channel, index) => channel !== second[index])) { + return null + } + const [r = 0, g = 0, b = 0, a = 0] = first + return [r, g, b, a / 255] +} diff --git a/src/renderer/src/lib/terminal-plugin-palettes.test.ts b/src/renderer/src/lib/terminal-plugin-palettes.test.ts new file mode 100644 index 00000000000..eb8eda446d1 --- /dev/null +++ b/src/renderer/src/lib/terminal-plugin-palettes.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import { + refreshedTerminalPluginPalettes, + withTerminalPluginPalettes +} from './terminal-plugin-palettes' + +const dusk = { + id: 'plugin:samples.dusk/dusk' as const, + pluginKey: 'samples.dusk', + pluginName: 'Dusk', + label: 'Dusk', + mode: 'dark' as const, + terminal: { background: '#1b1430', foreground: '#e8e0ff', red: '#ff6b8b' } +} +const base = { + terminalThemeDark: 'Builtin Dark', + terminalThemeLight: '', + terminalPluginPalettes: [] +} + +describe('terminal plugin palette snapshots', () => { + it('saves the palette of a newly selected plugin theme and drops unselected ones', () => { + const selected = withTerminalPluginPalettes(base, { terminalThemeDark: dusk.id }, [dusk]) + expect(selected.terminalPluginPalettes).toEqual([{ id: dusk.id, colors: dusk.terminal }]) + + const cleared = withTerminalPluginPalettes( + { + ...base, + terminalThemeDark: dusk.id, + terminalPluginPalettes: selected.terminalPluginPalettes + }, + { terminalThemeDark: 'Builtin Dark' }, + [dusk] + ) + expect(cleared.terminalPluginPalettes).toEqual([]) + }) + + it('refreshes after a plugin update and keeps the last palette while the plugin is off', () => { + const settings = { + ...base, + terminalThemeDark: dusk.id, + terminalPluginPalettes: [{ id: dusk.id, colors: dusk.terminal }] + } + expect(refreshedTerminalPluginPalettes(settings, [dusk])).toBeNull() + expect(refreshedTerminalPluginPalettes(settings, [])).toBeNull() + const updated = { ...dusk, terminal: { ...dusk.terminal, red: '#ff0000' } } + expect(refreshedTerminalPluginPalettes(settings, [updated])).toEqual({ + terminalPluginPalettes: [{ id: dusk.id, colors: updated.terminal }] + }) + }) +}) diff --git a/src/renderer/src/lib/terminal-plugin-palettes.ts b/src/renderer/src/lib/terminal-plugin-palettes.ts new file mode 100644 index 00000000000..a74b6ca0731 --- /dev/null +++ b/src/renderer/src/lib/terminal-plugin-palettes.ts @@ -0,0 +1,53 @@ +import type { GlobalSettings } from '../../../shared/global-settings-types' +import type { TerminalPluginPalette } from '../../../shared/terminal-color-overrides' +import type { PluginTerminalThemeRegistration } from '../../../shared/plugins/plugin-terminal-theme-artifact' + +type PaletteSettings = Pick< + GlobalSettings, + 'terminalThemeDark' | 'terminalThemeLight' | 'terminalPluginPalettes' +> + +function selectedPalettes( + dark: string, + light: string, + current: readonly TerminalPluginPalette[], + pluginThemes: readonly PluginTerminalThemeRegistration[] +): TerminalPluginPalette[] { + return [...new Set([dark, light])].flatMap((selection) => { + const theme = pluginThemes.find((candidate) => candidate.id === selection) + // Why keep the old snapshot: a disabled plugin's palette keeps painting the terminal it chose. + const palette = theme + ? { id: theme.id, colors: theme.terminal } + : current.find((entry) => entry.id === selection) + return palette ? [palette] : [] + }) +} + +/** Adds the palette snapshot a terminal theme selection needs, so every host resolves it. */ +export function withTerminalPluginPalettes( + settings: PaletteSettings, + update: Partial, + pluginThemes: readonly PluginTerminalThemeRegistration[] +): Partial { + const current = settings.terminalPluginPalettes ?? [] + const dark = update.terminalThemeDark ?? settings.terminalThemeDark + const light = update.terminalThemeLight ?? settings.terminalThemeLight + // Why: users who never pick a plugin theme keep their settings writes unchanged. + if (current.length === 0 && !dark.startsWith('plugin:') && !light.startsWith('plugin:')) { + return update + } + return { ...update, terminalPluginPalettes: selectedPalettes(dark, light, current, pluginThemes) } +} + +/** The snapshot refresh after a plugin updates a selected palette, or null when nothing changed. */ +export function refreshedTerminalPluginPalettes( + settings: PaletteSettings, + pluginThemes: readonly PluginTerminalThemeRegistration[] +): Partial | null { + const next = withTerminalPluginPalettes(settings, {}, pluginThemes) + return !next.terminalPluginPalettes || + JSON.stringify(next.terminalPluginPalettes) === + JSON.stringify(settings.terminalPluginPalettes ?? []) + ? null + : next +} diff --git a/src/renderer/src/lib/terminal-theme.test.ts b/src/renderer/src/lib/terminal-theme.test.ts index 94ffe0750a6..7257cb16bb7 100644 --- a/src/renderer/src/lib/terminal-theme.test.ts +++ b/src/renderer/src/lib/terminal-theme.test.ts @@ -337,3 +337,39 @@ describe('resolveOpaqueTerminalBackground', () => { expect(resolveOpaqueTerminalBackground('var(--background)')).toBe(null) }) }) + +describe('plugin terminal themes', () => { + const pluginTheme = { + id: 'plugin:samples.midnight/midnight' as const, + pluginKey: 'samples.midnight', + pluginName: 'Midnight', + label: 'Midnight', + mode: 'dark' as const, + terminal: { background: '#010203', foreground: '#fefefe', red: '#ff0000' } + } + const settings = { + theme: 'dark' as const, + terminalThemeDark: pluginTheme.id, + terminalDividerColorDark: '', + terminalUseSeparateLightTheme: false, + terminalThemeLight: '', + terminalCustomThemes: [], + terminalDividerColorLight: '', + terminalPluginPalettes: [{ id: pluginTheme.id, colors: pluginTheme.terminal }] + } + + it('paints a selected plugin theme from its saved palette and lists it by plugin name', () => { + expect(resolveEffectiveTerminalAppearance(settings, true).theme?.background).toBe('#010203') + expect(getAvailableTerminalThemeOptions(settings, [pluginTheme]).at(-1)).toMatchObject({ + value: pluginTheme.id, + group: 'plugin', + sourceLabel: 'Midnight' + }) + }) + + it('falls back to the default theme when no palette was saved', () => { + expect( + resolveEffectiveTerminalAppearance({ ...settings, terminalPluginPalettes: [] }, true).theme + ).toEqual(getBuiltinTheme(DEFAULT_TERMINAL_THEME_DARK)) + }) +}) diff --git a/src/renderer/src/lib/terminal-theme.ts b/src/renderer/src/lib/terminal-theme.ts index 9782ff16428..64d0a9c20f3 100644 --- a/src/renderer/src/lib/terminal-theme.ts +++ b/src/renderer/src/lib/terminal-theme.ts @@ -1,6 +1,7 @@ import type { ITheme } from '@xterm/xterm' import { getTheme, getThemeNames } from './terminal-themes-data' import type { GlobalSettings } from '../../../shared/global-settings-types' +import type { PluginTerminalThemeRegistration } from '../../../shared/plugins/plugin-terminal-theme-artifact' import { makeCustomTerminalThemeSelection, normalizeTerminalCustomThemes, @@ -32,7 +33,7 @@ export type EffectiveTerminalAppearance = { export type TerminalThemeOption = { value: string label: string - group: 'built-in' | 'imported' + group: 'built-in' | 'imported' | 'plugin' sourceLabel?: string mode?: TerminalCustomTheme['mode'] previewTheme: ITheme | null @@ -50,7 +51,7 @@ export function getBuiltinTheme(name: string): ITheme | null { } export function getTerminalTheme( - settings: Pick | undefined, + settings: Pick | undefined, selection: string ): ITheme | null { return lookupTerminalTheme(settings, selection) @@ -58,7 +59,7 @@ export function getTerminalTheme( function getTerminalThemePreview( name: string, - settings?: Pick, + settings?: Pick, fallbackMode: 'dark' | 'light' = 'dark' ): ITheme | null { const theme = getTerminalTheme(settings, name) @@ -71,7 +72,8 @@ function getTerminalThemePreview( } export function getAvailableTerminalThemeOptions( - settings: Pick + settings: Pick, + pluginThemes: readonly PluginTerminalThemeRegistration[] = [] ): TerminalThemeOption[] { const builtinOptions = BUILTIN_TERMINAL_THEME_NAMES.map((name) => ({ value: name, @@ -90,7 +92,15 @@ export function getAvailableTerminalThemeOptions( previewTheme: terminalCustomThemeToXtermTheme(theme) }) ) - return [...builtinOptions, ...customOptions] + const pluginOptions = pluginThemes.map((theme) => ({ + value: theme.id, + label: theme.label, + group: 'plugin' as const, + sourceLabel: theme.pluginName, + mode: theme.mode, + previewTheme: theme.terminal + })) + return [...builtinOptions, ...customOptions, ...pluginOptions] } export function resolveEffectiveTerminalAppearance( @@ -102,6 +112,7 @@ export function resolveEffectiveTerminalAppearance( | 'terminalUseSeparateLightTheme' | 'terminalThemeLight' | 'terminalCustomThemes' + | 'terminalPluginPalettes' | 'terminalDividerColorLight' >, systemPrefersDark = getSystemPrefersDark() diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 3829302a41b..7ad07daab9b 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -12,7 +12,11 @@ import { recordRendererCrashBreadcrumb } from './lib/crash-diagnostics' import { installAutomationHostDiagnostic } from './components/automations/automation-host-diagnostics' -import { applyDocumentTheme } from './lib/document-theme' +import { + applyCachedPluginAppTheme, + applyDocumentTheme, + readCachedPluginAppTheme +} from './lib/document-theme' import { installTypingLatencyDiagnostic } from './lib/typing-latency/diagnostic' import { installAppKeyboardRouter } from './lib/app-keyboard-router' import { shouldEnableReactGrab } from './lib/react-grab-dev-gate' @@ -48,7 +52,11 @@ if ( void import('react-grab/styles.css') } -applyDocumentTheme('system', { disableTransitions: false }) +const cachedPluginTheme = readCachedPluginAppTheme() +applyDocumentTheme(cachedPluginTheme?.base ?? 'system', { disableTransitions: false }) +if (cachedPluginTheme) { + applyCachedPluginAppTheme(cachedPluginTheme) +} registerBrowserClientPageGuestMessageHandler(handleGuestWindowCloseMessage) const browserClientPageRenderer = installBrowserClientPageRenderer() import.meta.hot?.dispose(() => browserClientPageRenderer?.dispose()) diff --git a/src/renderer/src/store/plugin-themes.ts b/src/renderer/src/store/plugin-themes.ts new file mode 100644 index 00000000000..b2344644465 --- /dev/null +++ b/src/renderer/src/store/plugin-themes.ts @@ -0,0 +1,79 @@ +import { + isPluginThemeRegistration, + type PluginThemeRegistration +} from '../../../shared/plugins/plugin-theme-artifact' +import { + isPluginTerminalThemeRegistration, + type PluginTerminalThemeRegistration +} from '../../../shared/plugins/plugin-terminal-theme-artifact' +import { useEffect } from 'react' +import { createPluginContentListStore } from './plugin-content-list-store' +import { refreshedTerminalPluginPalettes } from '../lib/terminal-plugin-palettes' +import { applyPluginAppTheme, readCachedPluginAppTheme } from '../lib/document-theme' +import { useAppStore } from './index' + +const appThemes = createPluginContentListStore({ + kind: 'themes', + list: (api) => api.listThemes, + isEntry: isPluginThemeRegistration +}) + +const terminalThemes = createPluginContentListStore({ + kind: 'terminal themes', + list: (api) => api.listTerminalThemes, + isEntry: isPluginTerminalThemeRegistration +}) + +export const usePluginThemeStore = appThemes.useStore +export const usePluginThemes = appThemes.useEntries +export const usePluginTerminalThemes = terminalThemes.useEntries + +/** Keeps the saved snapshot of selected plugin terminal palettes current as plugins update. */ +export function usePluginTerminalPaletteRefresh(): void { + const pluginTerminalThemes = usePluginTerminalThemes() + const settings = useAppStore((s) => s.settings) + const updateSettings = useAppStore((s) => s.updateSettings) + useEffect(() => { + const update = settings && refreshedTerminalPluginPalettes(settings, pluginTerminalThemes) + if (update) { + void updateSettings(update) + } + }, [settings, pluginTerminalThemes, updateSettings]) +} + +function pluginKeyOf(id: string): string { + return id.slice('plugin:'.length, id.lastIndexOf('/')) +} + +/** Keeps `theme` equal to the selected plugin theme's base while it is enabled, restores the + * user's earlier choice while it is not, and drops a startup paint the settings no longer back. */ +export function usePluginAppThemeSync(): void { + const pluginThemes = usePluginThemes() + const loaded = usePluginThemeStore((s) => s.loaded) + const settings = useAppStore((s) => s.settings) + const updateSettings = useAppStore((s) => s.updateSettings) + const selected = settings?.pluginAppTheme ?? null + const disabled = + !selected || + settings?.pluginSystemEnabled === false || + (settings?.disabledPlugins ?? []).includes(pluginKeyOf(selected)) + + useEffect(() => { + const cached = readCachedPluginAppTheme() + // Why before the list loads: the settings already say whether the cached paint still applies. + if (settings && !loaded && cached && (cached.id !== selected || disabled)) { + applyPluginAppTheme(null) + } + }, [settings, loaded, selected, disabled]) + + useEffect(() => { + if (!settings || !loaded || !selected) { + return + } + const active = pluginThemes.find((theme) => theme.id === selected) + const target = active ? active.base : settings.themeBeforePlugin + if (target && settings.theme !== target) { + void updateSettings({ theme: target }) + } + }, [settings, loaded, selected, pluginThemes, updateSettings]) +} diff --git a/src/shared/default-global-settings.ts b/src/shared/default-global-settings.ts index af37a09cb4b..3862366b694 100644 --- a/src/shared/default-global-settings.ts +++ b/src/shared/default-global-settings.ts @@ -41,6 +41,7 @@ export function buildDefaultSettings(args: { branchPrefixCustom: '', jiraIssueKeyCasing: 'lowercase', theme: 'system', + pluginAppTheme: null, leftSidebarAppearanceMode: 'default', leftSidebarTintColor: DEFAULT_LEFT_SIDEBAR_TINT_COLOR, leftSidebarTintOpacity: DEFAULT_LEFT_SIDEBAR_TINT_OPACITY, diff --git a/src/shared/global-settings-base.ts b/src/shared/global-settings-base.ts index 1d2f2b48508..e177b06ae8e 100644 --- a/src/shared/global-settings-base.ts +++ b/src/shared/global-settings-base.ts @@ -4,6 +4,7 @@ import type { BrowserLinkOpeningSettings } from './browser-link-opening-settings import type { ClaudeAccountSwitchSettings } from './claude-account-switch-settings' import type { ExternalConversationSettings } from './external-conversation-settings' import type { NativeChatGlobalSettings } from './native-chat/native-chat-appearance-settings' +import type { PluginAppearanceSettings } from './plugin-appearance-settings-types' import type { WorktreeDeletionPreferences } from './worktree-deletion-preferences' import type { AgentPermissionGlobalSettings } from './tui-agent-permission-choice' import type { OrchestrationHeartbeatSettings } from './orchestration-heartbeat' @@ -17,4 +18,5 @@ export type GlobalSettingsBase = ClaudeAgentViewSettings & ExternalConversationSettings & BrowserLinkOpeningSettings & OrchestrationHeartbeatSettings & - BrowserExtensionGlobalSettings + BrowserExtensionGlobalSettings & + PluginAppearanceSettings diff --git a/src/shared/plugin-appearance-settings-types.ts b/src/shared/plugin-appearance-settings-types.ts new file mode 100644 index 00000000000..58f2f7e1d99 --- /dev/null +++ b/src/shared/plugin-appearance-settings-types.ts @@ -0,0 +1,12 @@ +import type { TerminalPluginPalette } from './terminal-color-overrides' + +/** Appearance choices that come from plugins. */ +export type PluginAppearanceSettings = { + /** A plugin theme's id; its colors replace the built-in ones and its base picks light or dark. */ + pluginAppTheme?: `plugin:${string}` | null + /** The built-in choice to return to when the plugin theme is off or removed. */ + themeBeforePlugin?: 'system' | 'dark' | 'light' | null + /** Palettes of the selected plugin terminal themes, so main, headless serve and relays + * resolve them without the plugin loaded. Only selected themes are kept. */ + terminalPluginPalettes?: TerminalPluginPalette[] +} diff --git a/src/shared/plugin-theme-settings.test.ts b/src/shared/plugin-theme-settings.test.ts new file mode 100644 index 00000000000..13197e58a7c --- /dev/null +++ b/src/shared/plugin-theme-settings.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest' +import type { GlobalSettings } from './global-settings-types' +import { + normalizePluginThemeSettings, + pluginThemeSettingsAfterRemoval +} from './plugin-theme-settings' + +const colors = { background: '#1b1430', foreground: '#e8e0ff', red: '#ff6b8b' } + +describe('plugin theme settings', () => { + it('bounds what a client may write', () => { + expect( + normalizePluginThemeSettings({ + pluginAppTheme: 'plugin:samples.dusk/dusk', + themeBeforePlugin: 'system', + terminalPluginPalettes: [ + { id: 'plugin:samples.dusk/dusk', colors }, + { id: 'plugin:samples.dusk/dusk', colors }, + { id: 'not-a-plugin', colors }, + { id: 'plugin:samples.other/a', colors: { background: 'red;' } }, + { id: 'plugin:samples.b/b', colors }, + { id: 'plugin:samples.c/c', colors } + ] + }) + ).toEqual({ + pluginAppTheme: 'plugin:samples.dusk/dusk', + themeBeforePlugin: 'system', + terminalPluginPalettes: [ + { id: 'plugin:samples.dusk/dusk', colors }, + { id: 'plugin:samples.b/b', colors } + ] + }) + expect( + normalizePluginThemeSettings({ pluginAppTheme: 'plugin:../x', themeBeforePlugin: 'neon' }) + ).toEqual({ pluginAppTheme: null, themeBeforePlugin: null }) + const unrelated: Partial = { editorAutoSave: true } + expect(normalizePluginThemeSettings(unrelated)).toEqual({}) + }) + + it('forgets an uninstalled plugin and restores the earlier Theme choice', () => { + expect( + pluginThemeSettingsAfterRemoval( + { + theme: 'dark', + themeBeforePlugin: 'system', + pluginAppTheme: 'plugin:samples.dusk/dusk', + terminalThemeDark: 'plugin:samples.dusk/dusk', + terminalThemeLight: 'Builtin Tango Light', + terminalPluginPalettes: [{ id: 'plugin:samples.dusk/dusk', colors }] + }, + 'samples.dusk' + ) + ).toEqual({ + pluginAppTheme: null, + themeBeforePlugin: null, + theme: 'system', + terminalThemeDark: '', + terminalPluginPalettes: [] + }) + expect( + pluginThemeSettingsAfterRemoval( + { + theme: 'light', + pluginAppTheme: 'plugin:samples.dusk-two/dusk', + terminalThemeDark: '', + terminalThemeLight: '' + }, + 'samples.dusk' + ) + ).toEqual({}) + }) +}) diff --git a/src/shared/plugin-theme-settings.ts b/src/shared/plugin-theme-settings.ts new file mode 100644 index 00000000000..47bb8cb9050 --- /dev/null +++ b/src/shared/plugin-theme-settings.ts @@ -0,0 +1,98 @@ +import type { GlobalSettings } from './global-settings-types' +import type { TerminalPluginPalette } from './terminal-color-overrides' +import { + hasUsableTerminalThemeColors, + normalizeTerminalColorOverrides +} from './terminal-custom-themes' + +type PluginThemeSettings = Pick< + GlobalSettings, + 'pluginAppTheme' | 'themeBeforePlugin' | 'terminalPluginPalettes' +> + +// Two terminal slots (dark, light) can each hold one plugin palette. +const MAX_TERMINAL_PLUGIN_PALETTES = 2 +const PLUGIN_CONTENT_ID = + /^plugin:[a-z0-9][a-z0-9-]{0,63}\.[a-z0-9][a-z0-9-]{0,63}\/[a-z0-9][a-z0-9-]{0,63}$/ + +function contentId(value: unknown): `plugin:${string}` | null { + return typeof value === 'string' && PLUGIN_CONTENT_ID.test(value) + ? `plugin:${value.slice('plugin:'.length)}` + : null +} + +function palettes(value: unknown): TerminalPluginPalette[] { + if (!Array.isArray(value)) { + return [] + } + const result: TerminalPluginPalette[] = [] + for (const entry of value) { + const id = + typeof entry === 'object' && entry !== null && 'id' in entry ? contentId(entry.id) : null + const colors = normalizeTerminalColorOverrides( + typeof entry === 'object' && entry !== null && 'colors' in entry ? entry.colors : undefined + ) + if (id && hasUsableTerminalThemeColors(colors) && !result.some((p) => p.id === id)) { + result.push({ id, colors }) + } + } + return result.slice(0, MAX_TERMINAL_PLUGIN_PALETTES) +} + +/** Unvalidated plugin theme keys as a settings write or load carries them. */ +export type PluginThemeSettingsInput = { [Key in keyof PluginThemeSettings]?: unknown } + +/** Bounds plugin theme settings on every write and load; only keys present in `input` return. */ +export function normalizePluginThemeSettings( + input: PluginThemeSettingsInput +): Partial { + const result: Partial = {} + if ('pluginAppTheme' in input) { + result.pluginAppTheme = contentId(input.pluginAppTheme) + } + if ('themeBeforePlugin' in input) { + const theme = input.themeBeforePlugin + result.themeBeforePlugin = + theme === 'system' || theme === 'dark' || theme === 'light' ? theme : null + } + if ('terminalPluginPalettes' in input) { + result.terminalPluginPalettes = palettes(input.terminalPluginPalettes) + } + return result +} + +function ownedBy(id: string | null | undefined, pluginKey: string): boolean { + return typeof id === 'string' && id.startsWith(`plugin:${pluginKey}/`) +} + +/** Forgets an uninstalled plugin's theme choices so a reinstall does not quietly reapply them. */ +export function pluginThemeSettingsAfterRemoval( + settings: Pick< + GlobalSettings, + | 'theme' + | 'terminalThemeDark' + | 'terminalThemeLight' + | 'pluginAppTheme' + | 'themeBeforePlugin' + | 'terminalPluginPalettes' + >, + pluginKey: string +): Partial { + const update: Partial = {} + if (ownedBy(settings.pluginAppTheme, pluginKey)) { + update.pluginAppTheme = null + update.themeBeforePlugin = null + update.theme = settings.themeBeforePlugin ?? settings.theme + } + if (ownedBy(settings.terminalThemeDark, pluginKey)) { + update.terminalThemeDark = '' + } + if (ownedBy(settings.terminalThemeLight, pluginKey)) { + update.terminalThemeLight = '' + } + const kept = (settings.terminalPluginPalettes ?? []).filter((p) => !ownedBy(p.id, pluginKey)) + if (kept.length !== (settings.terminalPluginPalettes ?? []).length) { + update.terminalPluginPalettes = kept + } + return update +} diff --git a/src/shared/plugins/plugin-catalog-release.test.ts b/src/shared/plugins/plugin-catalog-release.test.ts index 3c5244a61e7..777b38d813c 100644 --- a/src/shared/plugins/plugin-catalog-release.test.ts +++ b/src/shared/plugins/plugin-catalog-release.test.ts @@ -74,7 +74,7 @@ describe('catalog releases', () => { it('rejects a release type its profile cannot run or this client cannot install', () => { expect(() => release({ types: ['worker'] })).toThrow('data cannot declare worker') expect( - pluginCatalogReleaseSchema.safeParse({ ...release(), types: ['app-theme'] }).success + pluginCatalogReleaseSchema.safeParse({ ...release(), types: ['icon-theme'] }).success ).toBe(false) }) diff --git a/src/shared/plugins/plugin-catalog-release.ts b/src/shared/plugins/plugin-catalog-release.ts index 23a474f8b8a..3e81621bf6d 100644 --- a/src/shared/plugins/plugin-catalog-release.ts +++ b/src/shared/plugins/plugin-catalog-release.ts @@ -10,6 +10,8 @@ import type { PluginManifest } from './plugin-manifest' // v2 catalog drops it instead of listing a plugin this client cannot install. export const PLUGIN_CATALOG_TYPES = [ 'interface-language', + 'app-theme', + 'terminal-theme', 'instructions', 'panel', 'worker' @@ -60,6 +62,12 @@ export function pluginCatalogTypes(manifest: PluginManifest): PluginCatalogType[ if (c.languagePacks.length > 0) { types.push('interface-language') } + if (c.themes.length > 0) { + types.push('app-theme') + } + if (c.terminalThemes.length > 0) { + types.push('terminal-theme') + } if (c.commands.length || c.keybindings.length || c.vmRecipes.length || c.agents.length) { types.push('instructions') } diff --git a/src/shared/plugins/plugin-content-pack-contributions.test.ts b/src/shared/plugins/plugin-content-pack-contributions.test.ts index 6e495899bbd..89a6a9e9a9b 100644 --- a/src/shared/plugins/plugin-content-pack-contributions.test.ts +++ b/src/shared/plugins/plugin-content-pack-contributions.test.ts @@ -79,7 +79,9 @@ describe('content-pack manifest contributions', () => { languagePacks: [], keybindings: [], vmRecipes: [], - agents: [] + agents: [], + themes: [], + terminalThemes: [] }) }) diff --git a/src/shared/plugins/plugin-content-pack-contributions.ts b/src/shared/plugins/plugin-content-pack-contributions.ts index 92f8cd1a98c..f57d09b8fb8 100644 --- a/src/shared/plugins/plugin-content-pack-contributions.ts +++ b/src/shared/plugins/plugin-content-pack-contributions.ts @@ -1,11 +1,17 @@ import { z } from 'zod' -import { pluginCommandIdSchema, pluginRelativePathSchema } from './plugin-manifest-fields' +import { + pluginCommandIdSchema, + pluginIdSchema, + pluginRelativePathSchema +} from './plugin-manifest-fields' import { normalizeKeybinding } from '../keybindings' export const PLUGIN_LANGUAGE_PACK_LIMIT = 16 export const PLUGIN_KEYBINDING_LIMIT = 256 export const PLUGIN_VM_RECIPE_LIMIT = 64 export const PLUGIN_AGENT_PROFILE_LIMIT = 64 +export const PLUGIN_THEME_LIMIT = 64 +export const PLUGIN_TERMINAL_THEME_LIMIT = 64 /** True when Intl can parse the tag's grammar, not just its shape. */ function isWellFormedLocaleId(value: string): boolean { @@ -62,7 +68,17 @@ export const pluginAgentProfileContributionSchema = z .object({ path: pluginRelativePathSchema }) .strict() +/** App and terminal themes share one shape: a labelled JSON artifact. */ +export const pluginThemeContributionSchema = z + .object({ + id: pluginIdSchema, + label: z.string().min(1).max(128), + path: pluginRelativePathSchema + }) + .strict() + export type PluginLanguagePackContribution = z.infer export type PluginKeybindingContribution = z.infer export type PluginVmRecipeContribution = z.infer export type PluginAgentProfileContribution = z.infer +export type PluginThemeContribution = z.infer diff --git a/src/shared/plugins/plugin-manifest-contribution-validation.ts b/src/shared/plugins/plugin-manifest-contribution-validation.ts index 3c06048269b..ffafab06537 100644 --- a/src/shared/plugins/plugin-manifest-contribution-validation.ts +++ b/src/shared/plugins/plugin-manifest-contribution-validation.ts @@ -15,6 +15,8 @@ type ContributionValidationManifest = { keybindings: { command: string; key: string; when?: 'global' | 'worktree' }[] vmRecipes: PathContribution[] agents: PathContribution[] + themes: IdentifiedContribution[] + terminalThemes: IdentifiedContribution[] } capabilities: { kind: string }[] } @@ -44,7 +46,7 @@ export function validatePluginManifestContributions( manifest: ContributionValidationManifest, ctx: RefinementCtx ): void { - for (const path of ['panels', 'commands'] as const) { + for (const path of ['panels', 'commands', 'themes', 'terminalThemes'] as const) { rejectDuplicateValues( manifest.contributes[path], (entry) => (entry as IdentifiedContribution).id, diff --git a/src/shared/plugins/plugin-manifest.ts b/src/shared/plugins/plugin-manifest.ts index c8139ad85f0..cd04b1a085e 100644 --- a/src/shared/plugins/plugin-manifest.ts +++ b/src/shared/plugins/plugin-manifest.ts @@ -4,10 +4,13 @@ import { PLUGIN_AGENT_PROFILE_LIMIT, PLUGIN_KEYBINDING_LIMIT, PLUGIN_LANGUAGE_PACK_LIMIT, + PLUGIN_TERMINAL_THEME_LIMIT, + PLUGIN_THEME_LIMIT, PLUGIN_VM_RECIPE_LIMIT, pluginAgentProfileContributionSchema, pluginKeybindingContributionSchema, pluginLanguagePackContributionSchema, + pluginThemeContributionSchema, pluginVmRecipeContributionSchema } from './plugin-content-pack-contributions' import { @@ -111,6 +114,11 @@ const pluginManifestV1Object = z.object({ agents: z .array(pluginAgentProfileContributionSchema) .max(PLUGIN_AGENT_PROFILE_LIMIT) + .default([]), + themes: z.array(pluginThemeContributionSchema).max(PLUGIN_THEME_LIMIT).default([]), + terminalThemes: z + .array(pluginThemeContributionSchema) + .max(PLUGIN_TERMINAL_THEME_LIMIT) .default([]) }) .strict() diff --git a/src/shared/plugins/plugin-marketplace.test.ts b/src/shared/plugins/plugin-marketplace.test.ts index 8effa173f89..798f0871bb3 100644 --- a/src/shared/plugins/plugin-marketplace.test.ts +++ b/src/shared/plugins/plugin-marketplace.test.ts @@ -181,8 +181,11 @@ describe('marketplace provenance contracts', () => { expect(isMarketplaceListingSupported(['vm-recipes', 'official'])).toBe(true) expect(isMarketplaceListingSupported(['keybindings'])).toBe(true) expect(isMarketplaceListingSupported([])).toBe(true) - for (const deferred of ['themes', 'icons', 'icon-themes', 'terminal-themes', 'skills']) { + for (const deferred of ['icons', 'icon-themes', 'skills']) { expect(isMarketplaceListingSupported([deferred, 'official'])).toBe(false) } + for (const supported of ['themes', 'terminal-themes']) { + expect(isMarketplaceListingSupported([supported, 'official'])).toBe(true) + } }) }) diff --git a/src/shared/plugins/plugin-marketplace.ts b/src/shared/plugins/plugin-marketplace.ts index 602770c8767..c2183823092 100644 --- a/src/shared/plugins/plugin-marketplace.ts +++ b/src/shared/plugins/plugin-marketplace.ts @@ -15,13 +15,12 @@ export const OFFICIAL_PLUGIN_ID_PREFIX = 'orca-' export const OFFICIAL_MARKETPLACE_OWNER = 'stablyai' export const OFFICIAL_MARKETPLACE_REPOSITORY = 'orca-plugins' -// Hide unsupported contribution categories still advertised by the externally maintained index; otherwise their manifests -// fail wholesale and present a dead install path until the marketplace catches up. +// Why: icon-theme and skill contributions are not supported, so a plugin declaring one fails +// to install. Shared catalogs this build does not control still list such packs; hide them so +// users never reach a dead install. export const UNSUPPORTED_MARKETPLACE_CATEGORIES: readonly string[] = [ - 'themes', 'icons', 'icon-themes', - 'terminal-themes', 'skills' ] diff --git a/src/shared/plugins/plugin-terminal-theme-artifact.ts b/src/shared/plugins/plugin-terminal-theme-artifact.ts new file mode 100644 index 00000000000..8d76a5c264b --- /dev/null +++ b/src/shared/plugins/plugin-terminal-theme-artifact.ts @@ -0,0 +1,53 @@ +import { z } from 'zod' +import { + TERMINAL_COLOR_KEYS, + hasUsableTerminalThemeColors, + normalizeTerminalColorOverrides +} from '../terminal-custom-themes' +import { pluginContentIdSchema } from './plugin-theme-artifact' + +const terminalColorKeySchema = z.enum(TERMINAL_COLOR_KEYS) +const terminalModeSchema = z.enum(['dark', 'light', 'unknown']) + +const pluginTerminalThemeArtifactSchema = z + .object({ + schemaVersion: z.literal(1), + mode: terminalModeSchema.default('unknown'), + terminal: z.partialRecord(terminalColorKeySchema, z.string()) + }) + .strict() + +const pluginTerminalThemeRegistrationSchema = z.object({ + id: pluginContentIdSchema, + pluginKey: z.string(), + pluginName: z.string(), + label: z.string(), + mode: terminalModeSchema, + terminal: z.partialRecord(terminalColorKeySchema, z.string()) +}) + +export type PluginTerminalThemeRegistration = z.infer + +export function parsePluginTerminalThemeArtifact( + raw: string +): Pick { + const parsed = pluginTerminalThemeArtifactSchema.parse(JSON.parse(raw)) + const terminal = normalizeTerminalColorOverrides(parsed.terminal) + if (Object.keys(terminal).length !== Object.keys(parsed.terminal).length) { + throw new Error('terminal theme contains an invalid color') + } + if (!hasUsableTerminalThemeColors(terminal)) { + throw new Error('terminal theme requires background, foreground, and at least one ANSI color') + } + return { mode: parsed.mode, terminal } +} + +export function isPluginTerminalThemeRegistration( + value: unknown +): value is PluginTerminalThemeRegistration { + const parsed = pluginTerminalThemeRegistrationSchema.safeParse(value) + return ( + parsed.success && + hasUsableTerminalThemeColors(normalizeTerminalColorOverrides(parsed.data.terminal)) + ) +} diff --git a/src/shared/plugins/plugin-theme-artifact.test.ts b/src/shared/plugins/plugin-theme-artifact.test.ts new file mode 100644 index 00000000000..5356c4b4f7a --- /dev/null +++ b/src/shared/plugins/plugin-theme-artifact.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'vitest' +import { isPluginThemeRegistration, parsePluginAppThemeArtifact } from './plugin-theme-artifact' +import { + isPluginTerminalThemeRegistration, + parsePluginTerminalThemeArtifact +} from './plugin-terminal-theme-artifact' + +const parseApp = (value: unknown) => () => parsePluginAppThemeArtifact(JSON.stringify(value)) + +describe('plugin app theme artifacts', () => { + it('accepts public tokens with bounded color values', () => { + expect( + parsePluginAppThemeArtifact( + JSON.stringify({ + base: 'light', + tokens: { '--background': '#ffffff', '--foreground': 'oklch(0.2 0.02 250)' } + }) + ).tokens + ).toMatchObject({ '--background': '#ffffff', '--foreground': 'oklch(0.2 0.02 250)' }) + }) + + it.each([ + ['a private token', { base: 'dark', tokens: { '--internal-gap': '#000' } }], + ['a URL', { base: 'dark', tokens: { '--background': 'url(x)' } }], + ['a var() chain', { base: 'dark', tokens: { '--background': 'var(--a)' } }], + ['declaration syntax', { base: 'dark', tokens: { '--background': '#000; color: red' } }], + ['no tokens', { base: 'dark', tokens: {} }], + [ + 'transparent', + { base: 'dark', tokens: { '--background': 'transparent', '--foreground': '#fff' } } + ], + ['an unknown key', { base: 'dark', tokens: { '--border': '#000' }, extra: 1 }] + ])('rejects %s', (_label, value) => { + expect(parseApp(value)).toThrow() + }) + + it('validates registrations crossing IPC', () => { + const registration = { + id: 'plugin:samples.midnight/midnight', + revision: 'r1', + pluginKey: 'samples.midnight', + pluginName: 'Midnight', + label: 'Midnight', + base: 'dark', + tokens: { '--background': '#000000', '--foreground': '#ffffff' } + } + expect(isPluginThemeRegistration(registration)).toBe(true) + expect(isPluginThemeRegistration({ ...registration, id: 'midnight' })).toBe(false) + expect(isPluginThemeRegistration({ ...registration, tokens: { '--x': '#000' } })).toBe(false) + }) +}) + +describe('plugin terminal theme artifacts', () => { + const terminal = { background: '#000000', foreground: '#ffffff', green: '#00ff00' } + + it('normalizes a usable palette', () => { + expect( + parsePluginTerminalThemeArtifact(JSON.stringify({ schemaVersion: 1, terminal })) + ).toEqual({ mode: 'unknown', terminal }) + }) + + it.each([ + ['an unknown slot', { ...terminal, glow: '#000000' }], + ['an invalid color', { ...terminal, red: 'red; x' }], + ['no ANSI color', { background: '#000000', foreground: '#ffffff' }] + ])('rejects %s', (_label, colors) => { + expect(() => + parsePluginTerminalThemeArtifact(JSON.stringify({ schemaVersion: 1, terminal: colors })) + ).toThrow() + }) + + it('validates registrations crossing IPC', () => { + const registration = { + id: 'plugin:samples.midnight/midnight', + pluginKey: 'samples.midnight', + pluginName: 'Midnight', + label: 'Midnight', + mode: 'dark', + terminal + } + expect(isPluginTerminalThemeRegistration(registration)).toBe(true) + expect(isPluginTerminalThemeRegistration({ ...registration, terminal: {} })).toBe(false) + }) +}) + +describe('plugin app theme completion', () => { + it('requires a background and foreground', () => { + expect(parseApp({ base: 'dark', tokens: { '--background': '#000' } })).toThrow('--foreground') + }) + + it.each(['transparent', 'inherit', 'currentColor', '100px', 'calc(1px + 2px)', 'env(x)'])( + 'rejects the non-color value %s', + (value) => { + expect( + parseApp({ base: 'dark', tokens: { '--background': value, '--foreground': '#fff' } }) + ).toThrow() + } + ) + + it('derives omitted surfaces and accents from the tokens a theme sets', () => { + const { tokens } = parsePluginAppThemeArtifact( + JSON.stringify({ + base: 'dark', + tokens: { '--background': '#101018', '--foreground': '#f0f0ff', '--accent': '#2a2050' } + }) + ) + expect(tokens).toMatchObject({ + '--card': '#101018', + '--sidebar': '#101018', + '--worktree-sidebar': '#101018', + '--sidebar-accent': '#2a2050', + '--worktree-sidebar-accent': '#2a2050', + '--primary': '#f0f0ff', + '--primary-foreground': '#101018' + }) + }) +}) diff --git a/src/shared/plugins/plugin-theme-artifact.ts b/src/shared/plugins/plugin-theme-artifact.ts new file mode 100644 index 00000000000..5e98641be7c --- /dev/null +++ b/src/shared/plugins/plugin-theme-artifact.ts @@ -0,0 +1,189 @@ +import { z } from 'zod' + +// Why: this is a public, additive-only theme API. Exposing every main.css +// variable would make internal renderer refactors plugin-breaking changes. +export const PLUGIN_APP_THEME_TOKENS = [ + '--background', + '--editor-surface', + '--foreground', + '--card', + '--card-foreground', + '--popover', + '--popover-foreground', + '--primary', + '--primary-foreground', + '--secondary', + '--secondary-foreground', + '--muted', + '--muted-foreground', + '--accent', + '--accent-foreground', + '--border', + '--input', + '--ring', + '--chart-1', + '--chart-2', + '--chart-3', + '--chart-4', + '--chart-5', + '--sidebar', + '--sidebar-foreground', + '--sidebar-primary', + '--sidebar-primary-foreground', + '--sidebar-accent', + '--sidebar-accent-foreground', + '--sidebar-border', + '--sidebar-ring', + '--worktree-sidebar', + '--worktree-sidebar-foreground', + '--worktree-sidebar-accent', + '--worktree-sidebar-accent-foreground', + '--worktree-sidebar-border', + '--worktree-sidebar-ring' +] as const + +const THEME_TOKEN_SET = new Set(PLUGIN_APP_THEME_TOKENS) + +/** Every theme sets these; the rest are optional and derived from them when omitted. */ +export const PLUGIN_APP_THEME_REQUIRED_TOKENS = ['--background', '--foreground'] as const + +// Why: an omitted token would otherwise keep the built-in value and clash with the theme's +// palette (e.g. a neutral grey selected row on a purple theme). A rule names source tokens +// (first present wins) or mixes the theme's foreground into its background; the renderer's +// contrast check still drops any derived pair that ends up unreadable. +type DerivationRule = readonly string[] | { mixForeground: number } + +const DERIVED_TOKENS: Record = { + '--editor-surface': ['--background'], + '--card': ['--background'], + '--card-foreground': ['--foreground'], + '--popover': ['--card', '--background'], + '--popover-foreground': ['--card-foreground', '--foreground'], + '--primary': ['--foreground'], + '--primary-foreground': ['--background'], + '--muted': ['--secondary'], + '--secondary': ['--muted'], + '--muted-foreground': { mixForeground: 70 }, + '--border': { mixForeground: 16 }, + '--accent': { mixForeground: 12 }, + '--accent-foreground': ['--foreground'], + '--secondary-foreground': ['--foreground'], + '--input': ['--border'], + '--ring': ['--primary'], + '--sidebar': ['--background'], + '--sidebar-foreground': ['--foreground'], + '--sidebar-primary': ['--primary'], + '--sidebar-primary-foreground': ['--primary-foreground'], + '--sidebar-accent': ['--accent'], + '--sidebar-accent-foreground': ['--accent-foreground'], + '--sidebar-border': ['--border'], + '--sidebar-ring': ['--ring'], + '--worktree-sidebar': ['--sidebar'], + '--worktree-sidebar-foreground': ['--sidebar-foreground'], + '--worktree-sidebar-accent': ['--sidebar-accent'], + '--worktree-sidebar-accent-foreground': ['--sidebar-accent-foreground'], + '--worktree-sidebar-border': ['--sidebar-border'], + '--worktree-sidebar-ring': ['--sidebar-ring'], + '--chart-1': ['--primary'] +} + +/** Fills omitted tokens from the ones the theme set, in dependency order. */ +export function completePluginAppThemeTokens( + tokens: Record +): Record { + const complete = { ...tokens } + for (const [token, rule] of Object.entries(DERIVED_TOKENS)) { + if (complete[token]) { + continue + } + const derived = + 'mixForeground' in rule + ? `color-mix(in srgb, ${complete['--foreground']} ${rule.mixForeground}%, ${complete['--background']})` + : rule.map((source) => complete[source]).find(Boolean) + if (derived) { + complete[token] = derived + } + } + return complete +} + +const NON_COLOR_KEYWORDS = new Set([ + 'transparent', + 'currentcolor', + 'inherit', + 'initial', + 'unset', + 'revert', + 'revert-layer', + 'none' +]) + +// Why: values land in CSS custom properties, so only color syntax is accepted: hex, a color +// function, or a bare color name. The renderer also requires CSS.supports('color', value). +function isThemeColorValue(value: string): boolean { + const normalized = value.trim().toLowerCase() + return ( + normalized.length <= 64 && + !NON_COLOR_KEYWORDS.has(normalized) && + (/^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/.test(normalized) || + /^(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch)\([0-9.,%/ +-]*(?:deg|turn|rad)?[0-9.,%/ +-]*\)$/.test( + normalized + ) || + /^[a-z]{3,20}$/.test(normalized)) + ) +} + +const themeFields = { + base: z.enum(['light', 'dark']), + tokens: z.record( + z.string().refine((token) => THEME_TOKEN_SET.has(token), 'is not a public theme token'), + z.string().refine(isThemeColorValue, 'must be a color value') + ) +} + +const hasRequiredTokens = (theme: { tokens: Record }): boolean => + PLUGIN_APP_THEME_REQUIRED_TOKENS.every((token) => theme.tokens[token]) + +const pluginAppThemeArtifactSchema = z + .object(themeFields) + .strict() + .refine(hasRequiredTokens, `must define ${PLUGIN_APP_THEME_REQUIRED_TOKENS.join(' and ')}`) + +/** Host-owned id; plugin keys keep ids from colliding across publishers. */ +export const pluginContentIdSchema = z.templateLiteral(['plugin:', z.string()]) + +// Why looser values here: the host adds derived color-mix() values; the renderer still accepts +// only what CSS parses as a color and checks contrast before painting. +const registrationTokenValueSchema = z + .string() + .max(256) + .refine((value) => !/url\(|var\(|[;{}\\]/i.test(value), 'must be a color value') + +const pluginThemeRegistrationSchema = z + .object({ + base: themeFields.base, + tokens: z.record( + z.string().refine((token) => THEME_TOKEN_SET.has(token)), + registrationTokenValueSchema + ), + id: pluginContentIdSchema, + /** Changes whenever the theme's content does, so a cached paint can be checked. */ + revision: z.string().max(128), + pluginKey: z.string(), + pluginName: z.string(), + label: z.string() + }) + .refine(hasRequiredTokens) + +export type PluginAppThemeArtifact = z.infer +export type PluginThemeRegistration = z.infer + +/** Parses a theme and fills its omitted tokens. */ +export function parsePluginAppThemeArtifact(raw: string): PluginAppThemeArtifact { + const theme = pluginAppThemeArtifactSchema.parse(JSON.parse(raw)) + return { base: theme.base, tokens: completePluginAppThemeTokens(theme.tokens) } +} + +export function isPluginThemeRegistration(value: unknown): value is PluginThemeRegistration { + return pluginThemeRegistrationSchema.safeParse(value).success +} diff --git a/src/shared/terminal-color-overrides.ts b/src/shared/terminal-color-overrides.ts index 1c75a44e217..e0e3a31d1cb 100644 --- a/src/shared/terminal-color-overrides.ts +++ b/src/shared/terminal-color-overrides.ts @@ -24,3 +24,9 @@ export type TerminalColorOverrides = { // Preserve Ghostty bold for future renderer support; xterm ITheme has no bold key. bold?: string } + +/** A selected plugin terminal theme's palette, saved so every host resolves it. */ +export type TerminalPluginPalette = { + id: `plugin:${string}` + colors: TerminalColorOverrides +} diff --git a/src/shared/terminal-theme-selection.test.ts b/src/shared/terminal-theme-selection.test.ts index f245cfe158b..dea5d59dec0 100644 --- a/src/shared/terminal-theme-selection.test.ts +++ b/src/shared/terminal-theme-selection.test.ts @@ -44,3 +44,29 @@ describe('the terminal colours a host configures', () => { ).toEqual({ foreground: '#ffffff', background: '#282c34' }) }) }) + +describe('plugin terminal palettes', () => { + const id = 'plugin:samples.dusk/dusk' as const + const colors = { background: '#1b1430', foreground: '#e8e0ff', red: '#ff6b8b' } + + it('answers a host colour query from the saved palette without the plugin loaded', () => { + expect( + resolveConfiguredTerminalColors( + { ...base, terminalThemeDark: id, terminalPluginPalettes: [{ id, colors }] }, + true + ) + ).toEqual({ foreground: '#e8e0ff', background: '#1b1430' }) + }) + + it('falls back to the default theme for a missing or malformed snapshot', () => { + const fallback = resolveConfiguredTerminalColors(base, true) + for (const terminalPluginPalettes of [[], [{ id, colors: { background: 'red;' } }]]) { + expect( + resolveConfiguredTerminalColors( + { ...base, terminalThemeDark: id, terminalPluginPalettes }, + true + ) + ).toEqual(fallback) + } + }) +}) diff --git a/src/shared/terminal-theme-selection.ts b/src/shared/terminal-theme-selection.ts index a29c35683c2..a6bcfa50209 100644 --- a/src/shared/terminal-theme-selection.ts +++ b/src/shared/terminal-theme-selection.ts @@ -1,5 +1,7 @@ import type { GlobalSettings } from './global-settings-types' import { + hasUsableTerminalThemeColors, + normalizeTerminalColorOverrides, normalizeTerminalCustomThemes, parseCustomTerminalThemeSelection, terminalCustomThemeToXtermTheme @@ -34,10 +36,14 @@ export function selectTerminalTheme( return { mode, useLightVariant, themeName } } +/** A selection names a plugin, user-imported or built-in theme; one lookup for all three. */ export function lookupTerminalTheme( - settings: Pick | undefined, + settings: Pick | undefined, selection: string ): TerminalThemeMap[string] | null { + if (selection.startsWith('plugin:')) { + return pluginPalette(settings?.terminalPluginPalettes, selection) + } const customId = parseCustomTerminalThemeSelection(selection) const custom = customId && settings @@ -50,9 +56,27 @@ export function lookupTerminalTheme( : (TERMINAL_THEME_CATALOG[selection] ?? null) } +// Why re-validate: settings are persisted and synced, so a snapshot is untrusted input here. +function pluginPalette(palettes: unknown, selection: string): TerminalThemeMap[string] | null { + if (!Array.isArray(palettes)) { + return null + } + const entry: unknown = palettes.find( + (candidate: unknown) => + typeof candidate === 'object' && + candidate !== null && + 'id' in candidate && + candidate.id === selection + ) + const colors = normalizeTerminalColorOverrides( + typeof entry === 'object' && entry !== null && 'colors' in entry ? entry.colors : undefined + ) + return hasUsableTerminalThemeColors(colors) ? colors : null +} + function themeColors( selection: string, - settings: Pick + settings: Pick ): TerminalOscColorQueryReplyColors | null { const theme = lookupTerminalTheme(settings, selection) return theme ? { foreground: theme.foreground, background: theme.background } : null @@ -61,7 +85,10 @@ function themeColors( /** Answer OSC 10/11 from saved settings without a renderer; queries report opaque theme colors, ignoring opacity. */ export function resolveConfiguredTerminalColors( settings: TerminalThemeSelectionSettings & - Pick, + Pick< + GlobalSettings, + 'terminalCustomThemes' | 'terminalPluginPalettes' | 'terminalColorOverrides' + >, systemPrefersDark: boolean ): TerminalOscColorQueryReplyColors { const { themeName, useLightVariant } = selectTerminalTheme(settings, systemPrefersDark) diff --git a/src/shared/wsl-move/wsl-move-storage-dispositions.ts b/src/shared/wsl-move/wsl-move-storage-dispositions.ts index 8f580b21b2b..2f75e484c81 100644 --- a/src/shared/wsl-move/wsl-move-storage-dispositions.ts +++ b/src/shared/wsl-move/wsl-move-storage-dispositions.ts @@ -80,6 +80,7 @@ export const LOCAL_STORAGE_MODULE_DISPOSITIONS: Record