mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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[]>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
@@ -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({})
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user