feat(plugins): app and terminal themes in one Theme list, painted safely (#30719)

* plugins: app and terminal theme contributions

Manifests can contribute themes (public CSS tokens over a light or dark
base) and terminal themes (an ANSI palette), each at most 16 KiB. App
themes must set --background and --foreground; omitted tokens are derived
from them (surfaces copy the background, muted text, borders and accents
mix the foreground into it), and values must be colors (no keywords,
lengths, calc, var or url). Both load through the shared artifact-pack
registry with the plugin's display name and a content revision, reach the
renderer over IPC and are re-validated there. Catalog releases can declare
app-theme and terminal-theme, and marketplaces stop hiding theme listings.
docs/reference/plugin-themes.md documents the format.

* plugins: resolve a selected plugin terminal palette on every host

Selecting a plugin terminal theme saves a copy of its palette in settings
(terminalPluginPalettes). The shared lookup reads it, so main, headless
serve and relays answer terminal color queries and native chat matches the
terminal without the plugin loaded; a malformed snapshot falls back to the
default theme.

* plugins: one Theme list with plugin themes, painted safely

Appearance keeps the System/Dark/Light control until a plugin offers a
theme; then Theme becomes one list with plugin themes after a divider,
labelled with the publisher when names collide. Choosing one stores its
base in theme and remembers the earlier built-in choice, which comes back
while the plugin is off or after it is uninstalled; a plugin update that
changes the base re-syncs theme. It also offers the plugin's terminal
colors in one click.

The renderer paints only values CSS parses as colors, checks every
text/surface pair, ring and border against the effective set (plugin
values over this mode's built-ins) and repeats until nothing changes, so
dropping one pair cannot expose another. The applied theme is cached with
its id and revision, painted before first render, and dropped as soon as
settings show it no longer applies. Main bounds the new settings on every
write and load, and uninstalling a plugin clears its theme choices and
palette copies. Terminal themes appear under Plugins in the picker with the
plugin's name and are passed to lookups instead of read from a store.

* plugins: type the theme settings normalizer input; let the store own its normalization

* fix(plugins): check text contrast on the editor surface and clear terminal overrides when adopting a theme's terminal colors

* fix(plugins): reject theme colors the canvas probe silently ignores
This commit is contained in:
Neil
2026-10-28 15:13:10 -07:00
committed by GitHub
parent 04f015ce41
commit 5018241866
56 changed files with 2007 additions and 77 deletions
+36
View File
@@ -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.
+14 -1
View File
@@ -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()
@@ -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,
@@ -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,
@@ -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)
)
]
}
@@ -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<typeof createPluginThemeRegistry>
readonly terminalThemes: ReturnType<typeof createPluginTerminalThemeRegistry>
readonly vmRecipes: PluginVmRecipeRegistry
readonly commands: PluginCommandRegistry
private readonly activationErrors = new Map<string, string>()
@@ -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) =>
@@ -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 }
]
@@ -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' })
])
@@ -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()
})
})
@@ -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<T>(
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<PluginThemeContribution, PluginThemeRegistration>({
contentVerifier,
contributions: (plugin) => plugin.manifest.contributes.themes,
load: (plugin, contribution) =>
readTheme(plugin, contribution, 'theme', parsePluginAppThemeArtifact)
})
}
export function createPluginTerminalThemeRegistry(contentVerifier: PluginContentVerifier) {
return new PluginArtifactPackRegistry<PluginThemeContribution, PluginTerminalThemeRegistration>({
contentVerifier,
contributions: (plugin) => plugin.manifest.contributes.terminalThemes,
load: (plugin, contribution) =>
readTheme(plugin, contribution, 'terminal theme', parsePluginTerminalThemeArtifact)
})
}
+4
View File
@@ -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<PluginHostListEntry[]>
listLanguagePacks: () => Promise<PluginLanguagePackRegistration[]>
listThemes: () => Promise<PluginThemeRegistration[]>
listTerminalThemes: () => Promise<PluginTerminalThemeRegistration[]>
/** Approval is bound to the current capability and trusted-worker fingerprint. */
consent: (args: PluginConsentRequest) => Promise<PluginHostListEntry[]>
setEnabled: (args: { pluginKey: string; enabled: boolean }) => Promise<PluginHostListEntry[]>
+2
View File
@@ -16,6 +16,8 @@ import type {
export const pluginsApi = {
list: (): Promise<PluginHostListEntry[]> => ipcRenderer.invoke('plugins:list'),
listLanguagePacks: () => ipcRenderer.invoke('plugins:listLanguagePacks'),
listThemes: () => ipcRenderer.invoke('plugins:listThemes'),
listTerminalThemes: () => ipcRenderer.invoke('plugins:listTerminalThemes'),
consent: (args: PluginConsentRequest): Promise<PluginHostListEntry[]> =>
ipcRenderer.invoke('plugins:consent', args),
setEnabled: (args: { pluginKey: string; enabled: boolean }): Promise<PluginHostListEntry[]> =>
@@ -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 })
})
})
@@ -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',
@@ -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({
<SettingsRow
label={themeLabel}
control={
<SettingsSegmentedControl
ariaLabel={themeLabel}
value={settings.theme}
onChange={(option) => {
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')
}
]}
<AppearanceThemeControl
label={themeLabel}
settings={settings}
updateSettings={updateSettings}
applyTheme={applyTheme}
/>
}
/>
@@ -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(
<AppearanceThemeControl
label="Theme"
settings={getDefaultSettings('/tmp')}
updateSettings={vi.fn()}
applyTheme={vi.fn()}
/>
)
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(
<AppearanceThemeControl
label="Theme"
settings={{
...getDefaultSettings('/tmp'),
theme: 'dark',
pluginAppTheme: 'plugin:samples.dusk/dusk',
terminalColorOverrides: { red: '#123456' }
}}
updateSettings={updateSettings}
applyTheme={vi.fn()}
/>
)
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(
<AppearanceThemeControl
label="Theme"
settings={{ ...getDefaultSettings('/tmp'), theme: 'system' }}
updateSettings={updateSettings}
applyTheme={vi.fn()}
/>
)
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'
})
})
@@ -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<GlobalSettings>) => 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 (
<SettingsSegmentedControl
ariaLabel={label}
value={settings.theme}
onChange={selectBuiltIn}
options={builtInOptions()}
/>
)
}
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 (
<div className="flex flex-col items-end gap-1">
<Select
value={active?.id ?? settings.theme}
onValueChange={(value) => {
const pluginTheme = pluginThemes.find((theme) => theme.id === value)
if (pluginTheme) {
updateSettings({
theme: pluginTheme.base,
pluginAppTheme: pluginTheme.id,
// Why keep the first one: switching between plugin themes must not lose it.
themeBeforePlugin: settings.themeBeforePlugin ?? settings.theme
})
applyTheme(pluginTheme.base)
return
}
const builtIn = builtInOptions().find((option) => option.value === value)
if (builtIn) {
selectBuiltIn(builtIn.value)
}
}}
>
<SelectTrigger size="sm" className="min-w-[220px]" aria-label={label}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{builtInOptions().map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
<SelectSeparator />
{pluginThemes.map((theme) => (
<SelectItem key={theme.id} value={theme.id}>
{pluginThemes.filter((other) => other.label === theme.label).length > 1
? `${theme.label} · ${theme.pluginKey.split('.')[0]}`
: theme.label}
</SelectItem>
))}
</SelectContent>
</Select>
{matchingTerminal && settings[terminalSlot] !== matchingTerminal.id ? (
<Button
variant="link"
size="xs"
onClick={() =>
updateSettings(
withTerminalPluginPalettes(
settings,
{
[terminalSlot]: matchingTerminal.id,
// A selected theme must replace overrides or they mask its palette.
...(Object.keys(settings.terminalColorOverrides ?? {}).length > 0
? { terminalColorOverrides: undefined }
: {})
},
pluginTerminalThemes
)
)
}
>
{translate(
'auto.components.settings.AppearanceThemeControl.useTerminalTheme',
'Use its terminal colors'
)}
</Button>
) : null}
</div>
)
}
@@ -72,7 +72,9 @@ const preview: PluginMarketplaceHostInstallPreview = {
languagePacks: [],
keybindings: [],
vmRecipes: [],
agents: []
agents: [],
themes: [],
terminalThemes: []
},
capabilities: [{ kind: 'workspace:read' }]
}
@@ -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({
<span className="block truncate">{theme.label}</span>
{theme.sourceLabel ? (
<span className="block truncate text-[11px] font-normal text-muted-foreground">
{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}` : ''}
</span>
) : null}
</span>
{/* 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 ? (
<span className="flex shrink-0 overflow-hidden rounded-sm border border-border/60">
@@ -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<typeof ReactModule>('react')
return {
@@ -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}
+6 -1
View File
@@ -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": {
+6 -1
View File
@@ -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": {
+6 -1
View File
@@ -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": {
+6 -1
View File
@@ -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": {
+6 -1
View File
@@ -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": {
+6 -1
View File
@@ -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": {
+178 -1
View File
@@ -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<string>()
@@ -118,3 +125,173 @@ describe('document theme', () => {
expect(frames.pendingCount()).toBe(0)
})
})
describe('applyPluginAppTheme', () => {
function fakeRoot() {
const properties = new Map<string, string>()
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<string, string>()
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<string, string>) => ({
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<string, string>) => {
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')
})
})
+134
View File
@@ -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<CSSStyleDeclaration, 'setProperty' | 'removeProperty'>
}
export type CachedPluginAppTheme = {
id: string
revision: string
base: 'light' | 'dark'
tokens: Record<string, string>
}
type PluginThemePaintOptions = {
root?: PluginThemeRoot
probe?: ColorProbe
/** Built-in token values for the current light/dark mode; read from the document by default. */
builtIns?: () => Record<string, string>
}
const PUBLIC_TOKENS = new Set<string>(PLUGIN_APP_THEME_TOKENS)
const appliedPluginTokens = new WeakMap<object, readonly string[]>()
// 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<string, string>): 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<string, string> {
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<string, string>,
options: PluginThemePaintOptions
): Record<string, string> {
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)
}
+146
View File
@@ -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<Record<string, string>>,
builtIns: Readonly<Record<string, string>>,
probe: ColorProbe
): Record<string, string> {
const accepted = new Map<string, Rgba>()
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<string, Rgba | null>()
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]
}
@@ -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 }]
})
})
})
@@ -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<GlobalSettings>,
pluginThemes: readonly PluginTerminalThemeRegistration[]
): Partial<GlobalSettings> {
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<GlobalSettings> | null {
const next = withTerminalPluginPalettes(settings, {}, pluginThemes)
return !next.terminalPluginPalettes ||
JSON.stringify(next.terminalPluginPalettes) ===
JSON.stringify(settings.terminalPluginPalettes ?? [])
? null
: next
}
@@ -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))
})
})
+16 -5
View File
@@ -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<GlobalSettings, 'terminalCustomThemes'> | undefined,
settings: Pick<GlobalSettings, 'terminalCustomThemes' | 'terminalPluginPalettes'> | undefined,
selection: string
): ITheme | null {
return lookupTerminalTheme(settings, selection)
@@ -58,7 +59,7 @@ export function getTerminalTheme(
function getTerminalThemePreview(
name: string,
settings?: Pick<GlobalSettings, 'terminalCustomThemes'>,
settings?: Pick<GlobalSettings, 'terminalCustomThemes' | 'terminalPluginPalettes'>,
fallbackMode: 'dark' | 'light' = 'dark'
): ITheme | null {
const theme = getTerminalTheme(settings, name)
@@ -71,7 +72,8 @@ function getTerminalThemePreview(
}
export function getAvailableTerminalThemeOptions(
settings: Pick<GlobalSettings, 'terminalCustomThemes'>
settings: Pick<GlobalSettings, 'terminalCustomThemes' | 'terminalPluginPalettes'>,
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()
+10 -2
View File
@@ -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())
+79
View File
@@ -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<PluginThemeRegistration>({
kind: 'themes',
list: (api) => api.listThemes,
isEntry: isPluginThemeRegistration
})
const terminalThemes = createPluginContentListStore<PluginTerminalThemeRegistration>({
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])
}
+1
View File
@@ -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,
+3 -1
View File
@@ -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
@@ -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[]
}
+72
View File
@@ -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<GlobalSettings> = { 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({})
})
})
+98
View File
@@ -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<PluginThemeSettings> {
const result: Partial<PluginThemeSettings> = {}
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<GlobalSettings> {
const update: Partial<GlobalSettings> = {}
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
}
@@ -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)
})
@@ -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')
}
@@ -79,7 +79,9 @@ describe('content-pack manifest contributions', () => {
languagePacks: [],
keybindings: [],
vmRecipes: [],
agents: []
agents: [],
themes: [],
terminalThemes: []
})
})
@@ -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<typeof pluginLanguagePackContributionSchema>
export type PluginKeybindingContribution = z.infer<typeof pluginKeybindingContributionSchema>
export type PluginVmRecipeContribution = z.infer<typeof pluginVmRecipeContributionSchema>
export type PluginAgentProfileContribution = z.infer<typeof pluginAgentProfileContributionSchema>
export type PluginThemeContribution = z.infer<typeof pluginThemeContributionSchema>
@@ -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,
+8
View File
@@ -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()
@@ -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)
}
})
})
+3 -4
View File
@@ -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'
]
@@ -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<typeof pluginTerminalThemeRegistrationSchema>
export function parsePluginTerminalThemeArtifact(
raw: string
): Pick<PluginTerminalThemeRegistration, 'mode' | 'terminal'> {
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))
)
}
@@ -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'
})
})
})
+189
View File
@@ -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<string>(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<string, DerivationRule> = {
'--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<string, string>
): Record<string, string> {
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<string, string> }): 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<typeof pluginAppThemeArtifactSchema>
export type PluginThemeRegistration = z.infer<typeof pluginThemeRegistrationSchema>
/** 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
}
+6
View File
@@ -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
}
@@ -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)
}
})
})
+30 -3
View File
@@ -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<GlobalSettings, 'terminalCustomThemes'> | undefined,
settings: Pick<GlobalSettings, 'terminalCustomThemes' | 'terminalPluginPalettes'> | 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<GlobalSettings, 'terminalCustomThemes'>
settings: Pick<GlobalSettings, 'terminalCustomThemes' | 'terminalPluginPalettes'>
): 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<GlobalSettings, 'terminalCustomThemes' | 'terminalColorOverrides'>,
Pick<
GlobalSettings,
'terminalCustomThemes' | 'terminalPluginPalettes' | 'terminalColorOverrides'
>,
systemPrefersDark: boolean
): TerminalOscColorQueryReplyColors {
const { themeName, useLightVariant } = selectTerminalTheme(settings, systemPrefersDark)
@@ -80,6 +80,7 @@ export const LOCAL_STORAGE_MODULE_DISPOSITIONS: Record<string, WslMoveFieldDispo
'src/renderer/src/components/terminal-pane/terminal-render-desync-trigger.ts',
'src/renderer/src/components/terminal-pane/terminal-startup-timing.ts',
'src/renderer/src/components/UnexpectedSignoutCard.tsx',
'src/renderer/src/lib/document-theme.ts',
'src/renderer/src/lib/feature-education-telemetry.ts',
'src/renderer/src/lib/orchestration-setup-state.ts',
'src/renderer/src/lib/terminal-shortcut-capture-notification.tsx',