Files
orca/src/shared/plugins/plugin-theme-artifact.ts
T
Neil 5018241866 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
2026-10-28 15:13:10 -07:00

190 lines
6.5 KiB
TypeScript

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
}