mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
feat(themes): cross-surface theme SDK — one skin themes CLI, TUI, and desktop
Make the Python skin engine the single source of truth for a canonical theme shape consumed by every surface, so a skin authored in $HERMES_HOME/skins/*.yaml (by a user or by Hermes from a prompt) themes the CLI, TUI, and desktop GUI at once — the theme analogue of the plugin SDK. - @hermes/shared: canonical `HermesSkin` token shape + `SKIN_COLOR_TOKENS` enum, consumed by both TS surfaces (TUI `GatewaySkin` and desktop dedup onto it). - Desktop: `skinToDesktopTheme` resolver (skin → CSS-var palette, VS Code-style derive-from-seed) + `backend-sync` that registers backend skins into the theme registry (Appearance/Cmd-K/`/skin`) and applies on a real change. Seeds on gateway.ready (never stomps a persisted pick), applies on skin.changed and the post-turn `config.get skin` poll (catch-all for agent-edited config.yaml). - TUI: `fromSkin` now maps the status bar + `background` keys it was dropping. - Gateway: `config.get skin` also returns the full resolved palette (additive). - Skill: `hermes-themes` teaches the agent to author + activate a skin. Each surface keeps its own normalizing resolver (ansi for the TUI, CSS vars for the desktop, prompt_toolkit/Rich for the CLI).
This commit is contained in:
parent
32a9f2acbc
commit
a8444fbcae
19 changed files with 710 additions and 36 deletions
|
|
@ -1,8 +1,10 @@
|
|||
import type { HermesSkin } from '@hermes/shared/skin'
|
||||
import { type MutableRefObject, useCallback, useRef, useState } from 'react'
|
||||
|
||||
import { getHermesConfig, getHermesConfigDefaults } from '@/hermes'
|
||||
import { BUILTIN_PERSONALITIES, normalizePersonalityValue, personalityNamesFromConfig } from '@/lib/chat-runtime'
|
||||
import { normalize } from '@/lib/text'
|
||||
import { $gateway } from '@/store/gateway'
|
||||
import {
|
||||
$currentCwd,
|
||||
getComposerSelectionGeneration,
|
||||
|
|
@ -16,6 +18,9 @@ import {
|
|||
setIntroPersonality
|
||||
} from '@/store/session'
|
||||
import { applyAutoSpeakFromConfig } from '@/store/voice-prefs'
|
||||
// Leaf import (not the `@/themes` barrel) so this hook doesn't drag in the
|
||||
// ThemeProvider/profile module graph — keeps it decoupled and test-mockable.
|
||||
import { ingestBackendSkin } from '@/themes/backend-sync'
|
||||
|
||||
const DEFAULT_VOICE_SECONDS = 120
|
||||
const FAST_TIERS = new Set(['fast', 'priority', 'on'])
|
||||
|
|
@ -111,6 +116,16 @@ export function useHermesConfig({ activeSessionIdRef, refreshProjectBranch }: He
|
|||
setVoiceMaxRecordingSeconds(recordingLimit(config.voice?.max_recording_seconds))
|
||||
setSttEnabled(config.stt?.enabled !== false)
|
||||
applyAutoSpeakFromConfig(config)
|
||||
|
||||
// Cross-surface skin sync: a skin Hermes authors/activates from a prompt
|
||||
// edits config.yaml directly, which never emits `skin.changed`. The
|
||||
// post-turn config refresh is our catch-all — fetch the resolved palette
|
||||
// and repaint if the active skin name actually changed (guarded upstream).
|
||||
void $gateway
|
||||
.get()
|
||||
?.request<{ skin?: HermesSkin }>('config.get', { key: 'skin' })
|
||||
.then(res => ingestBackendSkin(res?.skin, { apply: true }))
|
||||
.catch(() => undefined)
|
||||
} catch {
|
||||
// Config is nice-to-have; chat still works without it.
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import type { HermesSkin } from '@hermes/shared/skin'
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
|
|
@ -45,6 +46,9 @@ import { clearActiveSessionTodos } from '@/store/todos'
|
|||
import { recordToolDiff } from '@/store/tool-diffs'
|
||||
import { reportInstallMethodWarning } from '@/store/updates'
|
||||
import { notifyWorkspaceChanged, toolChangedPath, toolMayMutateFiles } from '@/store/workspace-events'
|
||||
// Leaf import (not the `@/themes` barrel) to avoid pulling the ThemeProvider
|
||||
// module graph into the gateway event hot path.
|
||||
import { ingestBackendSkin } from '@/themes/backend-sync'
|
||||
import type { RpcEvent } from '@/types/hermes'
|
||||
|
||||
import type { ClientSessionState } from '../../../types'
|
||||
|
|
@ -181,6 +185,21 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
|
|||
}
|
||||
|
||||
if (event.type === 'gateway.ready') {
|
||||
// Seed the active skin into the desktop theme registry without applying,
|
||||
// so a fresh connect never overrides the user's persisted desktop theme.
|
||||
ingestBackendSkin((payload as { skin?: HermesSkin } | undefined)?.skin, { apply: false })
|
||||
|
||||
return
|
||||
} else if (event.type === 'skin.changed') {
|
||||
// A runtime skin switch (Hermes activating an authored skin, or `/skin`
|
||||
// on another surface). Only the active profile's change repaints.
|
||||
const fromActiveProfile =
|
||||
!event.profile || normalizeProfileKey(event.profile) === normalizeProfileKey($activeGatewayProfile.get())
|
||||
|
||||
if (fromActiveProfile) {
|
||||
ingestBackendSkin(payload as HermesSkin | undefined, { apply: true })
|
||||
}
|
||||
|
||||
return
|
||||
} else if (event.type === 'session.info') {
|
||||
// Apply session-scoped fields when the event targets the active
|
||||
|
|
|
|||
68
apps/desktop/src/themes/backend-sync.test.ts
Normal file
68
apps/desktop/src/themes/backend-sync.test.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { $backendThemes, $pendingSkinApply, __resetBackendSkinSync, ingestBackendSkin } from './backend-sync'
|
||||
|
||||
const skin = (name: string) => ({ name, colors: { background: '#101020', ui_accent: '#ff33aa', banner_text: '#eeeeee' } })
|
||||
|
||||
describe('ingestBackendSkin', () => {
|
||||
beforeEach(() => __resetBackendSkinSync())
|
||||
|
||||
it('registers a converted skin without applying when apply=false', () => {
|
||||
ingestBackendSkin(skin('neon'), { apply: false })
|
||||
|
||||
expect($backendThemes.get().neon?.name).toBe('neon')
|
||||
expect($pendingSkinApply.get()).toBeNull()
|
||||
})
|
||||
|
||||
it('applies a new skin name once', () => {
|
||||
ingestBackendSkin(skin('neon'), { apply: true })
|
||||
|
||||
expect($pendingSkinApply.get()).toBe('neon')
|
||||
})
|
||||
|
||||
it('does not re-apply the same skin name', () => {
|
||||
ingestBackendSkin(skin('neon'), { apply: true })
|
||||
$pendingSkinApply.set(null)
|
||||
ingestBackendSkin(skin('neon'), { apply: true })
|
||||
|
||||
expect($pendingSkinApply.get()).toBeNull()
|
||||
})
|
||||
|
||||
it('applies again when the skin name changes', () => {
|
||||
ingestBackendSkin(skin('neon'), { apply: true })
|
||||
$pendingSkinApply.set(null)
|
||||
ingestBackendSkin(skin('forest'), { apply: true })
|
||||
|
||||
expect($pendingSkinApply.get()).toBe('forest')
|
||||
})
|
||||
|
||||
it('seeds on connect so the first matching poll is a no-op, but a change applies', () => {
|
||||
ingestBackendSkin(skin('neon'), { apply: false }) // gateway.ready seed
|
||||
ingestBackendSkin(skin('neon'), { apply: true }) // post-turn poll, unchanged
|
||||
expect($pendingSkinApply.get()).toBeNull()
|
||||
|
||||
ingestBackendSkin(skin('forest'), { apply: true }) // Hermes authored a new skin
|
||||
expect($pendingSkinApply.get()).toBe('forest')
|
||||
})
|
||||
|
||||
it('treats default as no-opinion: never registers or applies it', () => {
|
||||
ingestBackendSkin(skin('default'), { apply: true })
|
||||
|
||||
expect($pendingSkinApply.get()).toBeNull()
|
||||
expect($backendThemes.get().default).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not shadow a built-in name but can still apply it', () => {
|
||||
ingestBackendSkin(skin('mono'), { apply: true })
|
||||
|
||||
expect($backendThemes.get().mono).toBeUndefined()
|
||||
expect($pendingSkinApply.get()).toBe('mono')
|
||||
})
|
||||
|
||||
it('ignores empty payloads', () => {
|
||||
ingestBackendSkin(undefined, { apply: true })
|
||||
ingestBackendSkin({ name: '' }, { apply: true })
|
||||
|
||||
expect($pendingSkinApply.get()).toBeNull()
|
||||
})
|
||||
})
|
||||
91
apps/desktop/src/themes/backend-sync.ts
Normal file
91
apps/desktop/src/themes/backend-sync.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
/**
|
||||
* Live skin sync from the Hermes backend.
|
||||
*
|
||||
* The backend resolves the active skin (built-in or `$HERMES_HOME/skins/*.yaml`)
|
||||
* and announces it on `gateway.ready` / `skin.changed`, and answers `config.get
|
||||
* skin` with the same payload. `ingestBackendSkin` folds that into the desktop:
|
||||
*
|
||||
* 1. Registers the converted theme in `$backendThemes` so it appears wherever a
|
||||
* built-in does — Appearance, Cmd-K, `/skin` — with no per-surface wiring
|
||||
* (`listAllThemes` merges this store).
|
||||
* 2. When asked to apply (an explicit change), requests the switch via
|
||||
* `$pendingSkinApply`, which the ThemeProvider drains through `setTheme`.
|
||||
*
|
||||
* `gateway.ready` seeds the baseline WITHOUT applying, so a fresh connect never
|
||||
* stomps the user's persisted desktop theme; only a genuine name change (Hermes
|
||||
* authoring/activating a skin from a prompt, or `/skin` elsewhere) repaints.
|
||||
*/
|
||||
|
||||
import type { HermesSkin } from '@hermes/shared/skin'
|
||||
import { atom } from 'nanostores'
|
||||
|
||||
import { BUILTIN_THEMES } from './presets'
|
||||
import { skinToDesktopTheme } from './skin'
|
||||
import type { DesktopTheme } from './types'
|
||||
|
||||
/** Skins pushed by the backend, keyed by name. Merged by `listAllThemes`. */
|
||||
export const $backendThemes = atom<Record<string, DesktopTheme>>({})
|
||||
|
||||
/** One-shot skin name the ThemeProvider should switch to (it clears this). */
|
||||
export const $pendingSkinApply = atom<string | null>(null)
|
||||
|
||||
// The last skin name we drove onto the desktop. Guards two things: re-applying
|
||||
// the same skin every post-turn poll, and snapping back after a manual switch —
|
||||
// only a CHANGE from this value applies. `default` is the "no opinion" sentinel.
|
||||
let lastSynced: string | null = null
|
||||
|
||||
/** Test-only: reset the module's apply guard + registry between cases. */
|
||||
export function __resetBackendSkinSync(): void {
|
||||
lastSynced = null
|
||||
$backendThemes.set({})
|
||||
$pendingSkinApply.set(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold a resolved skin into the desktop. `apply: false` (connect-time seed) only
|
||||
* records the baseline; `apply: true` (runtime change / poll) repaints on a name
|
||||
* change. Built-in names keep the desktop's own palette but can still be applied.
|
||||
*/
|
||||
export function ingestBackendSkin(skin: HermesSkin | undefined | null, { apply }: { apply: boolean }): void {
|
||||
const name = (skin && typeof skin === 'object' ? skin.name ?? '' : '').trim()
|
||||
|
||||
if (!name) {
|
||||
return
|
||||
}
|
||||
|
||||
// `default` is "no opinion" — the desktop keeps its own default (nous). Record
|
||||
// it as the baseline so a real skin authored later reads as a change.
|
||||
if (name === 'default') {
|
||||
lastSynced = 'default'
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Built-in names (mono/slate/…) already have a hand-tuned desktop palette — we
|
||||
// never shadow it, but the name is still a valid apply target.
|
||||
if (!BUILTIN_THEMES[name]) {
|
||||
const theme = skinToDesktopTheme(skin as HermesSkin)
|
||||
|
||||
if (!theme) {
|
||||
return
|
||||
}
|
||||
|
||||
const current = $backendThemes.get()
|
||||
|
||||
if (JSON.stringify(current[name]) !== JSON.stringify(theme)) {
|
||||
$backendThemes.set({ ...current, [name]: theme })
|
||||
}
|
||||
}
|
||||
|
||||
if (!apply) {
|
||||
// Connect-time seed: record the baseline so a later poll is a no-op.
|
||||
lastSynced = name
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (name !== lastSynced) {
|
||||
lastSynced = name
|
||||
$pendingSkinApply.set(name)
|
||||
}
|
||||
}
|
||||
|
|
@ -17,8 +17,9 @@ import { matchesQuery, useMediaQuery } from '@/hooks/use-media-query'
|
|||
import { persistString, persistStringRecord, storedString, storedStringRecord } from '@/lib/storage'
|
||||
import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile'
|
||||
|
||||
import { $backendThemes, $pendingSkinApply } from './backend-sync'
|
||||
import { hexToRgb, mix, readableOn } from './color'
|
||||
import { BUILTIN_THEME_LIST, BUILTIN_THEMES, DEFAULT_SKIN_NAME, DEFAULT_TYPOGRAPHY, nousTheme } from './presets'
|
||||
import { BUILTIN_THEME_LIST, DEFAULT_SKIN_NAME, DEFAULT_TYPOGRAPHY, nousTheme } from './presets'
|
||||
import type { DesktopTheme, DesktopThemeColors } from './types'
|
||||
import { $userThemes, listAllThemes, resolveTheme } from './user-themes'
|
||||
|
||||
|
|
@ -319,6 +320,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
|||
// import or a plugin registration shows up live in the palette, settings
|
||||
// grid, and `/skin` without a reload.
|
||||
const userThemes = useStore($userThemes)
|
||||
const backendThemes = useStore($backendThemes)
|
||||
const registryVersion = useStore($registryVersion)
|
||||
|
||||
const availableThemes = useMemo(
|
||||
|
|
@ -328,9 +330,9 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
|||
label,
|
||||
description
|
||||
})),
|
||||
// userThemes + registryVersion ARE listAllThemes' reactivity.
|
||||
// userThemes + backendThemes + registryVersion ARE listAllThemes' reactivity.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[userThemes, registryVersion]
|
||||
[userThemes, backendThemes, registryVersion]
|
||||
)
|
||||
|
||||
const [themeName, setThemeNameState] = useState(() =>
|
||||
|
|
@ -377,6 +379,18 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
|||
modePref.assign(liveProfile(), next)
|
||||
}, [])
|
||||
|
||||
// Drain a backend-driven skin switch (Hermes authoring/activating a skin from a
|
||||
// prompt, or `/skin` on another surface). setTheme persists it per profile, so
|
||||
// the choice sticks like any manual pick.
|
||||
const pendingSkin = useStore($pendingSkinApply)
|
||||
|
||||
useEffect(() => {
|
||||
if (pendingSkin) {
|
||||
setTheme(pendingSkin)
|
||||
$pendingSkinApply.set(null)
|
||||
}
|
||||
}, [pendingSkin, setTheme])
|
||||
|
||||
// The light/dark toggle (Shift+X by default) is owned by the keybind runtime
|
||||
// (`appearance.toggleMode`) so it shows up in the hotkey map and is rebindable.
|
||||
|
||||
|
|
@ -389,12 +403,3 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
|||
}
|
||||
|
||||
export const useTheme = (): ThemeContextValue => useContext(ThemeContext)
|
||||
|
||||
/** Sync the desktop skin with the active Hermes backend theme on connect. */
|
||||
export function useSyncThemeFromBackend(backendThemeName: string | undefined, setTheme: (name: string) => void) {
|
||||
useEffect(() => {
|
||||
if (backendThemeName && BUILTIN_THEMES[backendThemeName]) {
|
||||
setTheme(backendThemeName)
|
||||
}
|
||||
}, [backendThemeName, setTheme])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
export { ThemeProvider, useSyncThemeFromBackend, useTheme } from './context'
|
||||
export { ingestBackendSkin } from './backend-sync'
|
||||
export { ThemeProvider, useTheme } from './context'
|
||||
export { BUILTIN_THEME_LIST, BUILTIN_THEMES, DEFAULT_SKIN_NAME } from './presets'
|
||||
export { skinToDesktopTheme } from './skin'
|
||||
export type { DesktopTheme, DesktopThemeColors, DesktopThemeTypography } from './types'
|
||||
export type { HermesSkin } from '@hermes/shared/skin'
|
||||
|
|
|
|||
50
apps/desktop/src/themes/skin.test.ts
Normal file
50
apps/desktop/src/themes/skin.test.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { luminance, normalizeHex } from './color'
|
||||
import { skinToDesktopTheme } from './skin'
|
||||
|
||||
const withColors = (name: string, colors: Record<string, string>) => skinToDesktopTheme({ name, colors })
|
||||
|
||||
describe('skinToDesktopTheme', () => {
|
||||
it('returns null without a name or colors', () => {
|
||||
expect(skinToDesktopTheme({ name: 'x' })).toBeNull()
|
||||
expect(skinToDesktopTheme({ name: '', colors: { background: '#101010' } })).toBeNull()
|
||||
})
|
||||
|
||||
it('maps the accent onto every brand token and keeps a single palette', () => {
|
||||
const theme = withColors('neon', { background: '#101020', ui_accent: '#ff33aa', banner_text: '#eeeeee' })!
|
||||
|
||||
expect(theme.name).toBe('neon')
|
||||
expect(theme.colors.ring).toBe(theme.colors.primary)
|
||||
expect(theme.colors.midground).toBe(theme.colors.primary)
|
||||
// A skin is single-mode: the light/dark toggle must not invert it.
|
||||
expect(theme.colors).toBe(theme.darkColors)
|
||||
})
|
||||
|
||||
it('seeds the background from status_bar_bg when none is explicit', () => {
|
||||
const theme = withColors('s', { status_bar_bg: '#0b0b0b', banner_text: '#ffffff' })!
|
||||
|
||||
expect(theme.colors.background).toBe('#0b0b0b')
|
||||
expect(theme.colors.foreground).toBe('#ffffff')
|
||||
})
|
||||
|
||||
it('buckets dark vs light from background luminance', () => {
|
||||
const dark = withColors('d', { background: '#111111', banner_text: '#eeeeee' })!
|
||||
const light = withColors('l', { background: '#fafafa', banner_text: '#111111' })!
|
||||
|
||||
expect(luminance(dark.colors.background)).toBeLessThan(0.4)
|
||||
expect(luminance(light.colors.background)).toBeGreaterThan(0.4)
|
||||
})
|
||||
|
||||
it('derives a dark base from light text when no background is given', () => {
|
||||
const theme = withColors('x', { banner_text: '#eeeeee', ui_accent: '#33ccff' })!
|
||||
|
||||
expect(luminance(theme.colors.background)).toBeLessThan(0.4)
|
||||
})
|
||||
|
||||
it('maps ui_error to destructive', () => {
|
||||
const theme = withColors('e', { background: '#101010', ui_error: '#ff5566' })!
|
||||
|
||||
expect(theme.colors.destructive).toBe(normalizeHex('#ff5566'))
|
||||
})
|
||||
})
|
||||
113
apps/desktop/src/themes/skin.ts
Normal file
113
apps/desktop/src/themes/skin.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
/**
|
||||
* Hermes skin → DesktopTheme converter.
|
||||
*
|
||||
* A "skin" is the CLI/TUI theme unit: a YAML file in `$HERMES_HOME/skins/` (or a
|
||||
* built-in) resolved by `hermes_cli/skin_engine.py` and pushed to every surface
|
||||
* over JSON-RPC (`gateway.ready`, `skin.changed`, `config.get skin`). This is the
|
||||
* one place the desktop turns that CLI-shaped palette into a `DesktopTheme`, so a
|
||||
* skin Hermes authors from a prompt lights up all three surfaces from one file.
|
||||
*
|
||||
* Skins carry terminal-oriented keys (banner/status/completion). We seed the
|
||||
* desktop model from the load-bearing few (background, foreground, accent, error)
|
||||
* and derive every glass/shadcn surface by mixing toward bg/fg — the same "naive
|
||||
* token converter" strategy as the VS Code importer. A skin is single-mode, so
|
||||
* both `colors` and `darkColors` get the converted palette; `renderedModeFor`
|
||||
* still picks `.dark` from the real background luminance.
|
||||
*/
|
||||
|
||||
import type { HermesSkin, SkinColors } from '@hermes/shared/skin'
|
||||
|
||||
import { ensureContrast, luminance, mix, normalizeHex, readableOn } from './color'
|
||||
import type { DesktopTheme, DesktopThemeColors } from './types'
|
||||
|
||||
// The accent labels the sidebar in small uppercase text, so it must clear WCAG AA
|
||||
// for normal text or section headers go invisible — mirrors the VS Code importer.
|
||||
const ACCENT_MIN_CONTRAST = 4.5
|
||||
|
||||
/** First normalizable hex among `keys`, alpha flattened over `backdrop`. */
|
||||
const pick = (colors: SkinColors, keys: string[], backdrop: string): string | null => {
|
||||
for (const key of keys) {
|
||||
const value = normalizeHex(colors[key], backdrop)
|
||||
|
||||
if (value) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const titleCase = (name: string): string => name.charAt(0).toUpperCase() + name.slice(1)
|
||||
|
||||
/**
|
||||
* Convert a resolved skin into a `DesktopTheme`, or null when it carries no
|
||||
* usable colors (so a broken/empty skin never registers junk).
|
||||
*/
|
||||
export function skinToDesktopTheme(skin: HermesSkin): DesktopTheme | null {
|
||||
const name = (skin.name ?? '').trim()
|
||||
const colors = skin.colors
|
||||
|
||||
if (!name || !colors || typeof colors !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
// Background is the backdrop every other token flattens alpha over. Skins are
|
||||
// terminal-first so most only tint chrome — `status_bar_bg` is the closest
|
||||
// thing to an app surface; `background` is the explicit opt-in for GUI authors.
|
||||
const seededBg = pick(colors, ['background', 'status_bar_bg'], '#000000')
|
||||
const foregroundSeed = pick(colors, ['ui_text', 'banner_text', 'status_bar_text'], seededBg ?? '#000000')
|
||||
|
||||
// No background given: bucket by foreground luminance (light text ⇒ dark app).
|
||||
const background = seededBg ?? (foregroundSeed && luminance(foregroundSeed) > 0.5 ? '#141414' : '#f7f7f8')
|
||||
const dark = luminance(background) < 0.4
|
||||
const foreground = foregroundSeed ?? (dark ? '#e6e6e6' : '#161616')
|
||||
|
||||
const accentSeed =
|
||||
pick(colors, ['ui_accent', 'banner_accent', 'banner_title'], background) ?? mix(foreground, background, 0.55)
|
||||
|
||||
const sidebar = mix(background, foreground, dark ? 0.02 : 0.012)
|
||||
const accent = ensureContrast(accentSeed, sidebar, ACCENT_MIN_CONTRAST)
|
||||
|
||||
const border = pick(colors, ['ui_border', 'banner_border'], background) ?? mix(background, foreground, dark ? 0.16 : 0.14)
|
||||
const mutedForeground = pick(colors, ['banner_dim', 'session_border'], background) ?? mix(foreground, background, 0.45)
|
||||
const destructive = pick(colors, ['ui_error'], background) ?? '#e25563'
|
||||
|
||||
const palette: DesktopThemeColors = {
|
||||
background,
|
||||
foreground,
|
||||
card: mix(background, foreground, dark ? 0.04 : 0.025),
|
||||
cardForeground: foreground,
|
||||
muted: mix(background, foreground, dark ? 0.06 : 0.04),
|
||||
mutedForeground,
|
||||
popover: mix(background, foreground, dark ? 0.08 : 0.05),
|
||||
popoverForeground: foreground,
|
||||
primary: accent,
|
||||
primaryForeground: readableOn(accent),
|
||||
secondary: mix(accent, background, dark ? 0.72 : 0.86),
|
||||
secondaryForeground: foreground,
|
||||
accent: mix(accent, background, dark ? 0.82 : 0.88),
|
||||
accentForeground: foreground,
|
||||
border,
|
||||
input: pick(colors, ['completion_menu_bg'], background) ?? mix(background, foreground, dark ? 0.1 : 0.06),
|
||||
ring: accent,
|
||||
midground: accent,
|
||||
midgroundForeground: readableOn(accent),
|
||||
composerRing: accent,
|
||||
destructive,
|
||||
destructiveForeground: readableOn(destructive),
|
||||
sidebarBackground: sidebar,
|
||||
sidebarBorder: border,
|
||||
userBubble: mix(background, accent, dark ? 0.18 : 0.12),
|
||||
userBubbleBorder: border
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
label: titleCase(name),
|
||||
description: 'Hermes skin',
|
||||
// Single palette in both slots: a skin is one-mode, so the light/dark toggle
|
||||
// shouldn't invert it. renderedModeFor still paints `.dark` from luminance.
|
||||
colors: palette,
|
||||
darkColors: palette
|
||||
}
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ import { atom, computed } from 'nanostores'
|
|||
|
||||
import { registry } from '@/contrib/registry'
|
||||
|
||||
import { $backendThemes } from './backend-sync'
|
||||
import { BUILTIN_THEMES } from './presets'
|
||||
import type { DesktopTheme, DesktopThemeColors } from './types'
|
||||
|
||||
|
|
@ -167,18 +168,26 @@ export function contributedThemes(): DesktopTheme[] {
|
|||
return out
|
||||
}
|
||||
|
||||
/** Resolve a theme by name across the merged set (built-in + user + contributed). */
|
||||
/** Resolve a theme by name across the merged set (built-in + user + backend + contributed). */
|
||||
export function resolveTheme(name: string): DesktopTheme | undefined {
|
||||
return BUILTIN_THEMES[name] ?? $userThemes.get()[name] ?? contributedThemes().find(theme => theme.name === name)
|
||||
return (
|
||||
BUILTIN_THEMES[name] ??
|
||||
$userThemes.get()[name] ??
|
||||
$backendThemes.get()[name] ??
|
||||
contributedThemes().find(theme => theme.name === name)
|
||||
)
|
||||
}
|
||||
|
||||
/** Built-ins first (stable order), then contributed, then user installs. */
|
||||
/** Built-ins first (stable order), then contributed, then backend skins, then user installs. */
|
||||
export function listAllThemes(): DesktopTheme[] {
|
||||
const user = $userThemes.get()
|
||||
const backend = $backendThemes.get()
|
||||
const shadows = (theme: DesktopTheme) => user[theme.name] || backend[theme.name]
|
||||
|
||||
return [
|
||||
...Object.values(BUILTIN_THEMES),
|
||||
...contributedThemes().filter(theme => !user[theme.name]),
|
||||
...contributedThemes().filter(theme => !shadows(theme)),
|
||||
...Object.values(backend).filter(theme => !user[theme.name]),
|
||||
...Object.values(user)
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@
|
|||
".": "./src/index.ts",
|
||||
"./billing": "./src/billing-types.ts",
|
||||
"./billing-policy": "./src/billing-policy.ts",
|
||||
"./charge-settlement": "./src/charge-settlement.ts"
|
||||
"./charge-settlement": "./src/charge-settlement.ts",
|
||||
"./skin": "./src/skin.ts"
|
||||
},
|
||||
"types": "./src/index.ts",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -42,6 +42,15 @@ export {
|
|||
JsonRpcGatewayClient,
|
||||
type WebSocketLike
|
||||
} from './json-rpc-gateway'
|
||||
export {
|
||||
type HermesSkin,
|
||||
SKIN_BRANDING_TOKENS,
|
||||
SKIN_COLOR_TOKENS,
|
||||
type SkinBranding,
|
||||
type SkinBrandingToken,
|
||||
type SkinColors,
|
||||
type SkinColorToken
|
||||
} from './skin'
|
||||
export {
|
||||
buildHermesWebSocketUrl,
|
||||
type GatewayAuthMode,
|
||||
|
|
|
|||
99
apps/shared/src/skin.ts
Normal file
99
apps/shared/src/skin.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
/**
|
||||
* Canonical Hermes skin — the theme SDK's cross-surface contract.
|
||||
*
|
||||
* A skin is authored once as YAML in `$HERMES_HOME/skins/<name>.yaml` (or a
|
||||
* built-in), resolved by the Python skin engine (`hermes_cli/skin_engine.py`),
|
||||
* and pushed to every surface over JSON-RPC (`gateway.ready`, `skin.changed`,
|
||||
* `config.get skin`). This is the ONE shape every TypeScript surface consumes;
|
||||
* each owns a resolver that normalizes it into its render model:
|
||||
*
|
||||
* • TUI → `fromSkin` → ansi-safe `Theme` (Ink)
|
||||
* • Desktop → `skinToDesktopTheme` → CSS custom properties (Tailwind/shadcn)
|
||||
* • CLI → `hermes_cli/skin_engine` → prompt_toolkit / Rich styles (Python)
|
||||
*
|
||||
* Tokens are terminal-first (the CLI is the oldest surface); GUIs derive their
|
||||
* fuller palettes from the load-bearing few. Every field is optional — a resolver
|
||||
* falls back to its own default for anything a skin omits.
|
||||
*/
|
||||
|
||||
/** Canonical semantic color tokens a skin may set (the "enum" of the shape). */
|
||||
export const SKIN_COLOR_TOKENS = [
|
||||
// Base surface — GUIs + the TUI status bar derive their palette from this.
|
||||
'background',
|
||||
// Brand accent + primary.
|
||||
'ui_accent',
|
||||
'ui_primary',
|
||||
'banner_accent',
|
||||
'banner_title',
|
||||
// Text.
|
||||
'ui_text',
|
||||
'banner_text',
|
||||
'banner_dim',
|
||||
// Structure.
|
||||
'ui_border',
|
||||
'banner_border',
|
||||
// Semantic status.
|
||||
'ui_ok',
|
||||
'ui_warn',
|
||||
'ui_error',
|
||||
'ui_label',
|
||||
// CLI / TUI chrome.
|
||||
'prompt',
|
||||
'input_rule',
|
||||
'response_border',
|
||||
'shell_dollar',
|
||||
'selection_bg',
|
||||
'session_label',
|
||||
'session_border',
|
||||
'status_bar_bg',
|
||||
'status_bar_text',
|
||||
'status_bar_strong',
|
||||
'status_bar_dim',
|
||||
'status_bar_good',
|
||||
'status_bar_warn',
|
||||
'status_bar_bad',
|
||||
'status_bar_critical',
|
||||
'voice_status_bg',
|
||||
'completion_menu_bg',
|
||||
'completion_menu_current_bg',
|
||||
'completion_menu_meta_bg',
|
||||
'completion_menu_meta_current_bg'
|
||||
] as const
|
||||
|
||||
export type SkinColorToken = (typeof SKIN_COLOR_TOKENS)[number]
|
||||
|
||||
/** Canonical branding/string tokens. */
|
||||
export const SKIN_BRANDING_TOKENS = [
|
||||
'agent_name',
|
||||
'welcome',
|
||||
'goodbye',
|
||||
'response_label',
|
||||
'prompt_symbol',
|
||||
'help_header'
|
||||
] as const
|
||||
|
||||
export type SkinBrandingToken = (typeof SKIN_BRANDING_TOKENS)[number]
|
||||
|
||||
/** Hex color per token. Open-ended so back-compat / niche keys still round-trip. */
|
||||
export type SkinColors = Partial<Record<SkinColorToken, string>> & { [key: string]: string | undefined }
|
||||
|
||||
/** Branding strings per token. Open-ended for the same reason. */
|
||||
export type SkinBranding = Partial<Record<SkinBrandingToken, string>> & { [key: string]: string | undefined }
|
||||
|
||||
/** The resolved skin payload (matches Python's `resolve_skin()`). */
|
||||
export interface HermesSkin {
|
||||
name?: string
|
||||
description?: string
|
||||
colors?: SkinColors
|
||||
/** Hand-tuned palette overlay for dark terminals (light-authored skins).
|
||||
* A resolver picks colors/light_colors/dark_colors by the terminal's
|
||||
* detected polarity — see the TUI's `themeForSkin`. */
|
||||
dark_colors?: SkinColors
|
||||
/** Hand-tuned palette overlay for light terminals (dark-authored skins). */
|
||||
light_colors?: SkinColors
|
||||
branding?: SkinBranding
|
||||
banner_logo?: string
|
||||
banner_hero?: string
|
||||
tool_prefix?: string
|
||||
help_header?: string
|
||||
}
|
||||
|
|
@ -1,9 +1,15 @@
|
|||
"""Hermes CLI skin/theme engine.
|
||||
"""Hermes skin/theme engine — the theme SDK for every surface.
|
||||
|
||||
A data-driven skin system that lets users customize the CLI's visual appearance.
|
||||
Skins are defined as YAML files in ~/.hermes/skins/ or as built-in presets.
|
||||
A data-driven skin system that lets users (and Hermes itself) customize the
|
||||
visual appearance across the CLI, the TUI, and the desktop GUI from a single
|
||||
file. Skins are defined as YAML files in ~/.hermes/skins/ or as built-in presets.
|
||||
No code changes are needed to add a new skin.
|
||||
|
||||
This module is the source of truth: it resolves the active skin, and the gateway
|
||||
pushes the resolved palette to the TUI and desktop (see tui_gateway's
|
||||
``resolve_skin`` / ``skin.changed``). A skin dropped in ~/.hermes/skins/ therefore
|
||||
themes all three surfaces at once — the theme analogue of the plugin SDK.
|
||||
|
||||
SKIN YAML SCHEMA
|
||||
================
|
||||
|
||||
|
|
@ -17,6 +23,9 @@ All fields are optional. Missing values inherit from the ``default`` skin.
|
|||
|
||||
# Colors: hex values for Rich markup (banner, UI, response box)
|
||||
colors:
|
||||
background: "#0e0e12" # App/base surface — the seed the TUI
|
||||
# status bar and the desktop GUI derive
|
||||
# their whole palette from (see below).
|
||||
banner_border: "#CD7F32" # Panel border color
|
||||
banner_title: "#FFD700" # Panel title text color
|
||||
banner_accent: "#FFBF00" # Section headers (Available Tools, etc.)
|
||||
|
|
|
|||
99
skills/hermes-themes/SKILL.md
Normal file
99
skills/hermes-themes/SKILL.md
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
---
|
||||
name: hermes-themes
|
||||
description: "Author a Hermes color theme that skins every surface."
|
||||
version: 1.0.0
|
||||
platforms: [linux, macos, windows]
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [theme, skin, appearance, cli, tui, desktop, self-config]
|
||||
related_skills: []
|
||||
---
|
||||
|
||||
# Hermes Themes Skill
|
||||
|
||||
Author a Hermes **skin** — one YAML file that themes the CLI, the TUI, and the
|
||||
desktop GUI at once. The skin engine (`hermes_cli/skin_engine.py`) resolves the
|
||||
active skin and the gateway pushes it to every surface, so a file dropped in
|
||||
`~/.hermes/skins/` is the theme analogue of a plugin: no code, all surfaces. This
|
||||
skill covers writing a good skin and activating it; it does not build GUI theme
|
||||
editors or ship built-in presets.
|
||||
|
||||
## When to Use
|
||||
|
||||
- The user asks for a custom look ("make me a synthwave theme", "dark forest
|
||||
vibes", "match my brand colors") for Hermes itself.
|
||||
- The user wants the CLI/TUI/desktop to share one coordinated palette.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Write access to the Hermes home dir — `~/.hermes` by default, or `$HERMES_HOME`
|
||||
/ the active profile's dir. Skins live in `<hermes-home>/skins/`.
|
||||
- Native tools: `write_file` (create the YAML), `read_file` / `search_files`
|
||||
(inspect existing skins), `patch` (set `display.skin`).
|
||||
|
||||
## How to Run
|
||||
|
||||
1. Pick a lowercase, hyphen-safe `name` (e.g. `synthwave`).
|
||||
2. Copy `templates/skin.yaml` and fill in the palette (keep every key — missing
|
||||
keys inherit the `default` skin).
|
||||
3. `write_file` it to `<hermes-home>/skins/<name>.yaml`.
|
||||
4. Activate it (see Procedure). Confirm the change landed.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
Load-bearing color keys (hex, `#rrggbb`). The desktop GUI derives its whole
|
||||
palette from these; the TUI and CLI read the terminal-oriented ones directly.
|
||||
|
||||
| Key | Drives |
|
||||
|---|---|
|
||||
| `background` | Base surface — GUI + TUI status bar seed. Set it. |
|
||||
| `ui_accent` / `banner_accent` | Brand accent: buttons, rings, primary. |
|
||||
| `banner_title` | Headings / primary text. |
|
||||
| `banner_text` / `ui_text` | Body foreground. |
|
||||
| `banner_border` / `ui_border` | Borders. |
|
||||
| `banner_dim` | Muted / secondary text. |
|
||||
| `ui_ok` / `ui_warn` / `ui_error` | Semantic status colors. |
|
||||
| `status_bar_bg` / `status_bar_text` | TUI status bar. |
|
||||
| `response_border` | CLI response box. |
|
||||
|
||||
`branding` (`agent_name`, `welcome`, `goodbye`, `prompt_symbol`, `help_header`),
|
||||
`spinner` (faces/verbs/wings), and `tool_prefix` are optional flavor. See the
|
||||
full schema in `hermes_cli/skin_engine.py`.
|
||||
|
||||
## Procedure
|
||||
|
||||
1. **Design the palette.** Choose a `background` first, then an `ui_accent` that
|
||||
clears WCAG AA against it (~4.5:1) so labels stay legible — the GUI enforces
|
||||
contrast but a low-contrast accent still looks washed out. Keep
|
||||
`ui_ok`/`ui_warn`/`ui_error` recognizably green/amber/red.
|
||||
2. **Write the file** to `<hermes-home>/skins/<name>.yaml`. Every top-level
|
||||
`colors` key from the template should be present.
|
||||
3. **Activate.** Set `display.skin: <name>` in `<hermes-home>/config.yaml` with
|
||||
`patch` (create the `display:` block if absent). This is the source of truth
|
||||
all surfaces read.
|
||||
- **Desktop**: repaints automatically after the current turn (and the skin
|
||||
appears in Appearance / `Cmd-K` / `/skin`).
|
||||
- **CLI / TUI**: run `/skin <name>` for an immediate switch, or it loads on
|
||||
next start.
|
||||
4. **Confirm** and tell the user how to switch back (`/skin default`).
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **Don't hardcode `~/.hermes`** when a profile is active — resolve the real home
|
||||
from `$HERMES_HOME` first, falling back to `~/.hermes`.
|
||||
- **Keep `#rrggbb` hex.** Shorthand `#rgb`, `rgb()`, and named colors are not
|
||||
guaranteed to parse on every surface.
|
||||
- **Set `background`.** Without it the GUI has to guess a base surface from text
|
||||
luminance — usable, but you lose control of the app background.
|
||||
- **Name collisions**: a skin named like a desktop built-in (`mono`, `slate`,
|
||||
`cyberpunk`, `nous`, `midnight`, `ember`) won't override that built-in on the
|
||||
GUI. Pick a fresh name.
|
||||
- **Don't rebuild config.yaml** — `patch` only the `display.skin` line so you
|
||||
don't clobber the user's other settings.
|
||||
|
||||
## Verification
|
||||
|
||||
- `read_file` the written `<hermes-home>/skins/<name>.yaml` and confirm valid
|
||||
YAML with the intended `name` and `colors`.
|
||||
- `read_file` `<hermes-home>/config.yaml` and confirm `display.skin: <name>`.
|
||||
- Ask the user to confirm the new look, or check the current surface repainted.
|
||||
49
skills/hermes-themes/templates/skin.yaml
Normal file
49
skills/hermes-themes/templates/skin.yaml
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
# Hermes skin template — themes the CLI, TUI, and desktop GUI from one file.
|
||||
# Copy to <hermes-home>/skins/<name>.yaml, edit the palette, then set
|
||||
# `display.skin: <name>` in config.yaml. Missing keys inherit the default skin.
|
||||
|
||||
name: synthwave
|
||||
description: Neon dusk — magenta and cyan on deep indigo
|
||||
|
||||
colors:
|
||||
# Base surface — set this. The GUI derives its palette from it; the TUI
|
||||
# status bar and CLI chrome seed from it too.
|
||||
background: "#1a1030"
|
||||
|
||||
# Brand accent — buttons, focus rings, GUI primary. Keep it legible (~4.5:1)
|
||||
# against `background`.
|
||||
ui_accent: "#ff5fd2"
|
||||
banner_accent: "#ff5fd2"
|
||||
|
||||
# Text hierarchy.
|
||||
banner_title: "#7ef9ff" # headings / primary
|
||||
banner_text: "#e6e0ff" # body foreground
|
||||
ui_text: "#e6e0ff"
|
||||
banner_dim: "#8a7fb5" # muted / secondary
|
||||
banner_border: "#3a2a63"
|
||||
ui_border: "#3a2a63"
|
||||
|
||||
# Semantic status.
|
||||
ui_ok: "#5af7b0"
|
||||
ui_warn: "#ffcf5f"
|
||||
ui_error: "#ff6b7d"
|
||||
|
||||
# CLI / TUI chrome.
|
||||
prompt: "#e6e0ff"
|
||||
input_rule: "#ff5fd2"
|
||||
response_border: "#7ef9ff"
|
||||
status_bar_bg: "#120a24"
|
||||
status_bar_text: "#e6e0ff"
|
||||
status_bar_good: "#5af7b0"
|
||||
status_bar_warn: "#ffcf5f"
|
||||
status_bar_critical: "#ff6b7d"
|
||||
session_label: "#7ef9ff"
|
||||
session_border: "#3a2a63"
|
||||
|
||||
# Optional flavor — safe to delete.
|
||||
branding:
|
||||
agent_name: Hermes Agent
|
||||
prompt_symbol: "❯"
|
||||
help_header: "(^_^)? Commands"
|
||||
|
||||
tool_prefix: "┊"
|
||||
|
|
@ -12556,8 +12556,15 @@ def _(rid, params: dict) -> dict:
|
|||
if key == "prompt":
|
||||
return _ok(rid, {"prompt": _load_cfg().get("custom_prompt", "")})
|
||||
if key == "skin":
|
||||
# `value` is the active skin name (back-compat, used by the TUI). `skin`
|
||||
# carries the full resolved palette so cross-surface consumers (the
|
||||
# desktop) can rebuild the theme without their own YAML loader.
|
||||
return _ok(
|
||||
rid, {"value": (_load_cfg().get("display") or {}).get("skin", "default")}
|
||||
rid,
|
||||
{
|
||||
"value": (_load_cfg().get("display") or {}).get("skin", "default"),
|
||||
"skin": resolve_skin(),
|
||||
},
|
||||
)
|
||||
if key == "indicator":
|
||||
# Normalize so a hand-edited config.yaml with stray casing or
|
||||
|
|
|
|||
|
|
@ -532,4 +532,32 @@ describe('background-aware adaptation (OSC-11 light terminals)', () => {
|
|||
// …then the OSC-11 answer lands and is cached into the env slot.
|
||||
expect(defaultThemeForCurrentBackground({ HERMES_TUI_BACKGROUND: '#ffffff' }).color).toEqual(LIGHT_THEME.color)
|
||||
})
|
||||
|
||||
it('maps the status bar from skin status_bar_* keys', async () => {
|
||||
const { fromSkin } = await importThemeWithCleanEnv()
|
||||
|
||||
const { color } = fromSkin(
|
||||
{
|
||||
status_bar_bg: '#101020',
|
||||
status_bar_text: '#e0e0e0',
|
||||
status_bar_bad: '#ff8800',
|
||||
status_bar_critical: '#ff0000'
|
||||
},
|
||||
{}
|
||||
)
|
||||
|
||||
expect(color.statusBg).toBe('#101020')
|
||||
expect(color.statusFg).toBe('#e0e0e0')
|
||||
expect(color.statusBad).toBe('#ff8800')
|
||||
expect(color.statusCritical).toBe('#ff0000')
|
||||
})
|
||||
|
||||
it('falls the status bar back to background + semantic colors', async () => {
|
||||
const { fromSkin } = await importThemeWithCleanEnv()
|
||||
const { color } = fromSkin({ background: '#0a0a0a', banner_text: '#fafafa', ui_error: '#dd2222' }, {})
|
||||
|
||||
expect(color.statusBg).toBe('#0a0a0a')
|
||||
expect(color.statusFg).toBe('#fafafa')
|
||||
expect(color.statusCritical).toBe('#dd2222')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,19 +1,11 @@
|
|||
import type { UsageModelData } from '@hermes/shared/billing'
|
||||
import type { HermesSkin } from '@hermes/shared/skin'
|
||||
|
||||
import type { SessionInfo, SlashCategory, SubagentStatus, Usage } from './types.js'
|
||||
|
||||
export interface GatewaySkin {
|
||||
banner_hero?: string
|
||||
banner_logo?: string
|
||||
branding?: Record<string, string>
|
||||
colors?: Record<string, string>
|
||||
/** Hand-tuned palette for dark terminals (light-authored skins). */
|
||||
dark_colors?: Record<string, string>
|
||||
help_header?: string
|
||||
/** Hand-tuned palette for light terminals (dark-authored skins). */
|
||||
light_colors?: Record<string, string>
|
||||
tool_prefix?: string
|
||||
}
|
||||
/** The cross-surface skin contract (canonical shape in `@hermes/shared`).
|
||||
* Includes the paired light_colors/dark_colors overlays from #20379. */
|
||||
export type GatewaySkin = HermesSkin
|
||||
|
||||
export interface GatewayCompletionItem {
|
||||
display: string
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import type { SkinBranding, SkinColors } from '@hermes/shared/skin'
|
||||
|
||||
import { desaturate, grayOf, liftForContrast, mix, parseColor, relativeLuminance, toHex } from './lib/color.js'
|
||||
|
||||
export interface ThemeColors {
|
||||
|
|
@ -763,8 +765,8 @@ export function defaultThemeForCurrentBackground(env: NodeJS.ProcessEnv = proces
|
|||
// ── Skin → Theme ─────────────────────────────────────────────────────
|
||||
|
||||
export function fromSkin(
|
||||
colors: Record<string, string>,
|
||||
branding: Record<string, string>,
|
||||
colors: SkinColors,
|
||||
branding: SkinBranding,
|
||||
bannerLogo = '',
|
||||
bannerHero = '',
|
||||
toolPrefix = '',
|
||||
|
|
@ -848,6 +850,12 @@ export function fromSkin(
|
|||
|
||||
return normalizeThemeForAnsiLightTerminal(
|
||||
{
|
||||
// The element tokens theme-sdk introduced (ui_primary, ui_text,
|
||||
// ui_border, ui_ok/warn/error, shell_dollar, status_bar_*) are read
|
||||
// above into `seeds` and flow through buildPalette → adaptColorsToBackground,
|
||||
// so `adapted` already honors them AND applies #20379's contrast/polarity
|
||||
// machinery. Emitting a hand-mapped color block here would bypass that
|
||||
// adaptation and regress theme quality.
|
||||
color: adapted,
|
||||
|
||||
brand: {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue