Files
hermes-agent/apps/desktop/src/themes/vscode.ts
T
teknium1 35022e02ed refactor(themes): one sRGB color-math module in @hermes/shared; measured readableOn + fine ensureContrast ladder on both surfaces
ui-tui/src/lib/color.ts called itself "the twin of the desktop app's
src/themes/color.ts" and the two had already drifted: the desktop measured
readableOn but used a coarse 0.2x5 ensureContrast ladder and returned 0 for
unparseable luminance; the TUI had the fine 0.05x20 ladder and null-for-garbage
but a luminance>0.5 threshold readableOn. Both now import the primitives from
apps/shared/src/color.ts (`@hermes/shared/color`, also exported from the root
index); each surface keeps only what is specific to it. No palette / preset /
skin VALUE changes anywhere — only math.

Sites (path::symbol → canonical):
  apps/desktop/src/themes/color.ts::hexToRgb           → @hermes/shared/color::parseColor (deleted)
  apps/desktop/src/themes/color.ts::rgbToHex           → @hermes/shared/color::toHex (deleted)
  apps/desktop/src/themes/color.ts::mix                → @hermes/shared/color::mix
  apps/desktop/src/themes/color.ts::relativeLuminance  → @hermes/shared/color::relativeLuminance
  apps/desktop/src/themes/color.ts::contrastRatio      → @hermes/shared/color::contrastRatio
  apps/desktop/src/themes/color.ts::readableOn         → @hermes/shared/color::readableOn (desktop wrapper readableInk pins ['#161616','#ffffff'])
  apps/desktop/src/themes/color.ts::ensureContrast     → @hermes/shared/color::ensureContrast
  ui-tui/src/lib/color.ts::{Rgb,parseColor,toHex,mix,relativeLuminance,contrastRatio,readableOn,ensureContrast,lighten,darken}
                                                       → @hermes/shared/color (same names)
  Stays desktop-only (apps/desktop/src/themes/color.ts): luminance, normalizeHex, readableInk, OKLCH set
    (hexToOklch, oklchToHex, oklchToSrgb255, maxChroma, hueDelta, harmonize, mixOklab, withHue, ensureContrastOklch).
  Stays TUI-only (ui-tui/src/lib/color.ts): liftForContrast, grayOf, desaturate, toHsl, fromHsl, retone,
    boostSaturation, color()/ColorChain.
  Importers repointed (17): apps/desktop/src/{sdk/index.ts, themes/context.tsx, themes/retint.ts,
    themes/retint.test.ts, themes/skin.ts, themes/vscode.ts, themes/vscode.test.ts};
    ui-tui/src/{theme.ts, sdk/index.ts, sdk/apps/weather.tsx, app/createGatewayEventHandler.ts,
    components/agentsPanel.tsx, components/branding.tsx, components/loaders.tsx,
    components/overlayPrimitives.tsx, lib/color.ts, lib/color.test.ts}.
  Wiring: apps/shared/package.json exports './color'; apps/shared/src/index.ts re-exports;
    apps/desktop/tsconfig.json paths + vite.config.ts alias for '@hermes/shared/color'
    (ui-tui resolves the subpath via the workspace package exports, like './billing').

Behavior change (1): relativeLuminance / contrastRatio return null for unparseable
  input on the desktop too (previously 0, which made garbage measure like pure
  black). Desktop SDK export `contrastRatio` therefore widens to `number | null`.
  Only ensureContrastOklch relied on the number: it now treats null as "already
  passing / can't measure" and returns the input unchanged. Every other desktop
  caller passes 6-digit hex.

Behavior change (2): readableOn MEASURES both candidate inks and returns the one
  with the higher contrast ratio (desktop semantics; the threshold version got
  mid-lightness accents wrong: white on #4f9e5e is 3.29:1 vs near-black 5.50:1).
  Signature is readableOn(bg, inks = ['#000000', '#ffffff']); the desktop passes
  its own pair via `readableInk` so desktop output is byte-identical. The TUI
  switches from the luminance>0.5 threshold to measurement: over the 185 distinct
  hexes in ui-tui/src/theme.ts (DARK/LIGHT seeds + built palettes) and
  hermes_cli/skin_engine.py, 56 flip from '#ffffff' to '#000000' — all
  mid-lightness accents (L 0.18–0.49, e.g. #cd7f32, #4caf50, #ef5350, #ffa726,
  #4dabf7) where black measures 4.6–10.8:1 against white's 1.9–4.5:1. Note the
  TUI never called readableOn directly; it only reaches ensureContrast's pole
  choice (below), and ensureContrast is only reachable via the color() chain and
  the theme.ts re-export (no production caller today).

Behavior change (3): ensureContrast steps 0.05 x 20 from the ORIGINAL color toward
  the measured readableOn pole (TUI semantics). The desktop previously stepped
  0.2 x 5 toward a threshold-chosen pole, so desktop-derived accents that needed a
  lift (skin/VS Code imports whose accent fails 4.5:1 on the sidebar, and
  --dt-primary-solid) may now land up to 0.15 closer to their original hue —
  they stop at the first passing rung. Palette VALUES are unchanged; only
  synthesized colors move.

Also: parseColor accepts #rgb shorthand where desktop hexToRgb rejected it —
  strictly more permissive; the only desktop path fed raw user hex is
  normalizeHex, which already expands shorthand itself.

Tests: apps/shared/src/color.test.ts (moved TUI parse/mix/contrast cases +
  two invariants):
  - "readableOn(%s) returns the ink with the higher measured contrast" — computes
    contrastRatio for each candidate in the test and asserts the returned ink is
    the max (a contract, not a hardcoded hex) over #4f9e5e (both ink pairs),
    #cba6f7, #ffffff, #101014.
    Sabotage: reverted readableOn to the luminance threshold → 3 red
    (#4f9e5e x2, #cba6f7); restored → green.
  - "ensureContrast(%s on %s) clears %s" — 5 failing pairs end ≥ min; plus
    "leaves passing and unparseable colors byte-identical".
    Sabotage: truncated the ladder to 3 rungs → 5 red; restored → green.
  ui-tui/src/lib/color.test.ts keeps only the color() chain case.

Validation:
  apps/shared: npx tsc -p . --noEmit (0) && npx vitest run → 3 files, 30 tests passed; npm run lint clean
  apps/desktop: npx tsc -p . --noEmit (0); npx vitest run --project ui → 798/800 files, 7563/7572 tests;
    the 9 failures (src/app/messaging/index.test.tsx x8 12s-timeouts, src/lib/markdown-blocks.test.ts
    property fuzz 36s) are load-induced flakes under the full parallel run: both files pass in
    isolation on this branch (16/16) and on origin/main; neither imports color math. npm run lint 0 errors
  ui-tui: npm run build:ink; npx tsc -p . --noEmit (0) && npx vitest run → 168 files, 1764 tests passed; npm run lint 0 errors
  git diff --check clean; no new gitignored .d.ts.

Handoff: desktop vs web preset palettes diverge for the four shared ids
  (web presets carry a 3-slot palette {background, midground, foreground(alpha 0)}
  + warmGlow, not the desktop's 24-slot set, so only the comparable slots are
  listed; web `foreground` is #ffffff alpha 0 on all four — a glow/overlay
  slot, not text ink). Design call for Teknium; nothing changed here.

    preset     slot        desktop                     web
    cyberpunk  background  #000a00                     #040608
    cyberpunk  accent      #00ff41 (primary/ring/mid)  #9bffcf (midground)
    cyberpunk  foreground  #00ff41                     #ffffff (alpha 0)
    ember      background  #160800                     #1a0a06
    ember      accent      #d97316 (ring/midground)    #ffd8b0 (midground = desktop fg/primary)
    ember      foreground  #ffd8b0                     #ffffff (alpha 0)
    midnight   background  #08081c                     #0a0a1f
    midnight   accent      #8b80e8 (ring/midground)    #d4c8ff (midground)
    midnight   foreground  #ddd6ff                     #ffffff (alpha 0)
    mono       background  #0e0e0e                     #0e0e0e  (match)
    mono       accent      #9a9a9a (ring/midground)    #eaeaea (midground = desktop fg/primary)
    mono       foreground  #eaeaea                     #ffffff (alpha 0)
2026-09-13 06:50:57 -07:00

373 lines
12 KiB
TypeScript

/**
* VS Code color-theme → DesktopTheme converter.
*
* VS Code themes carry ~hundreds of `workbench.colorCustomization` keys, but the
* desktop theme model only needs a `DesktopThemeColors` struct — `applyTheme`
* derives every glass/shadcn token from a small seed chain via `color-mix()`.
* In practice ~6 workbench keys carry the whole look (background, foreground,
* accent, elevated surface, sidebar, error); everything else we derive by mixing
* those toward the background/foreground. That's the "naive token converter".
*
* A VS Code theme is single-mode (light OR dark). Rather than synthesise the
* opposite mode, we set both `colors` and `darkColors` to the converted palette
* so the imported theme renders faithfully no matter where the light/dark toggle
* sits — `renderedModeFor` still picks the `.dark` class from the real
* background luminance, so surface-bound UI matches what's on screen.
*/
import { ensureContrast, mix } from '@hermes/shared/color'
import { luminance, normalizeHex, readableInk } from './color'
import type { DesktopTerminalPalette, DesktopTheme, DesktopThemeColors } from './types'
// Section headers / sidebar labels render in --theme-primary directly on the
// sidebar surface as small (~10px) uppercase text, so the accent has to clear
// WCAG AA for normal text (4.5:1) or it's unreadable — the "invisible purple
// label" case. Imported accents below this get nudged lighter/darker.
const ACCENT_MIN_CONTRAST = 4.5
/** The shape of a VS Code `*-color-theme.json` (only the fields we read). */
export interface VscodeColorTheme {
name?: string
type?: string
/** Relative path to a base theme this one extends. We don't follow it. */
include?: string
colors?: Record<string, unknown>
tokenColors?: unknown
}
export interface ConvertOptions {
/** Stable id (slug). Defaults to a slug of `raw.name`. */
slug?: string
/** Display label. Defaults to `raw.name`. */
label?: string
/** Shown under the label in the picker (e.g. the marketplace extension id). */
source?: string
}
export interface ConvertResult {
theme: DesktopTheme
/** The source theme's own light/dark (from `type`, else background luminance). */
mode: 'light' | 'dark'
/** Workbench keys we wanted but the theme omitted (we derived fallbacks). */
derived: string[]
}
/** Tolerant slug: lowercase, alnum + dashes, deduped, `vsc-` namespaced. */
export function vscodeThemeSlug(name: string): string {
const base = name
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 48)
return `vsc-${base || 'theme'}`
}
/**
* Parse a VS Code theme file. These ship as JSONC (line/block comments and
* trailing commas), so a plain `JSON.parse` rejects most real-world files.
* Strips comments + trailing commas, then parses. Throws on hard syntax errors.
*/
export function parseVscodeTheme(text: string): VscodeColorTheme {
const stripped = text
// Block comments.
.replace(/\/\*[\s\S]*?\*\//g, '')
// Line comments (not inside strings — naive but fine for theme files).
.replace(/(^|[^:"'\\])\/\/[^\n\r]*/g, '$1')
// Trailing commas before } or ].
.replace(/,(\s*[}\]])/g, '$1')
const parsed: unknown = JSON.parse(stripped)
if (!parsed || typeof parsed !== 'object') {
throw new Error('Theme file is not a JSON object.')
}
return parsed as VscodeColorTheme
}
const isDarkType = (raw: VscodeColorTheme, background: string): boolean => {
const type = (raw.type ?? '').toLowerCase()
if (type.includes('light')) {
return false
}
if (type === 'dark' || type === 'hc' || type === 'hc-black' || type.includes('dark')) {
return true
}
// No usable `type` — bucket by background luminance.
return luminance(background) < 0.4
}
// xterm ITheme ANSI slots ← VS Code `terminal.ansi*` tokens. Background is
// deliberately excluded — the pane keeps the live skin surface (transparency).
const ANSI_TOKENS: ReadonlyArray<readonly [keyof DesktopTerminalPalette, string]> = [
['black', 'terminal.ansiBlack'],
['red', 'terminal.ansiRed'],
['green', 'terminal.ansiGreen'],
['yellow', 'terminal.ansiYellow'],
['blue', 'terminal.ansiBlue'],
['magenta', 'terminal.ansiMagenta'],
['cyan', 'terminal.ansiCyan'],
['white', 'terminal.ansiWhite'],
['brightBlack', 'terminal.ansiBrightBlack'],
['brightRed', 'terminal.ansiBrightRed'],
['brightGreen', 'terminal.ansiBrightGreen'],
['brightYellow', 'terminal.ansiBrightYellow'],
['brightBlue', 'terminal.ansiBrightBlue'],
['brightMagenta', 'terminal.ansiBrightMagenta'],
['brightCyan', 'terminal.ansiBrightCyan'],
['brightWhite', 'terminal.ansiBrightWhite']
]
const BASE_ANSI: ReadonlyArray<keyof DesktopTerminalPalette> = [
'black',
'red',
'green',
'yellow',
'blue',
'magenta',
'cyan',
'white'
]
const HEX_RE = /^#[0-9a-f]{3,8}$/i
/**
* Lift a theme's integrated-terminal ANSI palette, if it ships one.
*
* All-or-nothing on the base-8 colors: a half-filled palette mixed with our
* defaults reads worse than just keeping the defaults, so we adopt the theme's
* palette only when the full base set is present. ANSI slots flatten alpha over
* the editor background; selection keeps its alpha so xterm can blend it.
*/
function extractTerminalPalette(
colors: Record<string, unknown>,
background: string
): DesktopTerminalPalette | undefined {
const hex = (key: string): string | undefined =>
normalizeHex(typeof colors[key] === 'string' ? (colors[key] as string) : null, background) ?? undefined
const palette: DesktopTerminalPalette = {}
for (const [slot, token] of ANSI_TOKENS) {
const value = hex(token)
if (value) {
palette[slot] = value
}
}
if (!BASE_ANSI.every(slot => palette[slot])) {
return undefined
}
const foreground = hex('terminal.foreground')
const cursor = hex('terminalCursor.foreground') ?? hex('terminalCursor.background')
const selection =
typeof colors['terminal.selectionBackground'] === 'string' ? colors['terminal.selectionBackground'].trim() : ''
if (foreground) {
palette.foreground = foreground
}
if (cursor) {
palette.cursor = cursor
}
if (HEX_RE.test(selection)) {
palette.selectionBackground = selection
}
return palette
}
/** First normalizable hex among `keys`, composited over `backdrop`. */
const pick = (
colors: Record<string, unknown>,
keys: string[],
backdrop: string
): { key: string; value: string } | null => {
for (const key of keys) {
const value = normalizeHex(typeof colors[key] === 'string' ? (colors[key] as string) : null, backdrop)
if (value) {
return { key, value }
}
}
return null
}
export function convertVscodeColorTheme(raw: VscodeColorTheme, opts: ConvertOptions = {}): ConvertResult {
const colors = raw.colors && typeof raw.colors === 'object' ? (raw.colors as Record<string, unknown>) : null
if (!colors) {
throw new Error('Theme has no "colors" map — not a VS Code color theme.')
}
const derived: string[] = []
// Background first: it's the backdrop every other token flattens alpha over.
const backgroundHit = pick(
colors,
['editor.background', 'editorPane.background', 'editorGroup.background'],
'#000000'
)
const dark = isDarkType(raw, backgroundHit?.value ?? '#1e1e1e')
const background = backgroundHit?.value ?? (dark ? '#1e1e1e' : '#ffffff')
if (!backgroundHit) {
derived.push('editor.background')
}
// `take` records a derived fallback when the theme omits the key.
const take = (keys: string[], fallback: string): string => {
const hit = pick(colors, keys, background)
if (hit) {
return hit.value
}
derived.push(keys[0])
return fallback
}
const foreground = take(['editor.foreground', 'foreground'], dark ? '#d4d4d4' : '#1f1f1f')
// Brand accent — the single most load-bearing token. Drives primary buttons,
// focus rings, the streaming cursor, active-session pills, and sidebar labels.
// Prefer the saturated "brand" tokens (button / link / badge) over focusBorder,
// which many themes set to a muted gray — picking it first made imported
// accents look like the desktop defaults. We enforce contrast below regardless.
const accentSource = take(
[
'button.background',
'textLink.activeForeground',
'textLink.foreground',
'activityBarBadge.background',
'badge.background',
'progressBar.background',
'pickerGroup.foreground',
'list.highlightForeground',
'editorLink.activeForeground',
'focusBorder',
'tab.activeBorder',
'statusBarItem.remoteBackground'
],
mix(foreground, background, 0.55)
)
const elevated = take(
[
'editorWidget.background',
'dropdown.background',
'menu.background',
'quickInput.background',
'editorSuggestWidget.background'
],
mix(background, foreground, dark ? 0.08 : 0.05)
)
const card = take(
['sideBarSectionHeader.background', 'tab.inactiveBackground', 'editorGroupHeader.tabsBackground'],
mix(background, foreground, dark ? 0.04 : 0.025)
)
const sidebar = take(
['sideBar.background', 'activityBar.background'],
mix(background, foreground, dark ? 0.02 : 0.012)
)
// The accent labels the sidebar (--theme-primary), so guarantee it reads
// there — otherwise low-contrast brand colors leave invisible section headers.
const accent = ensureContrast(accentSource, sidebar, ACCENT_MIN_CONTRAST)
const border = take(
['panel.border', 'editorGroup.border', 'sideBar.border', 'contrastBorder', 'widget.border', 'input.border'],
mix(background, foreground, dark ? 0.16 : 0.14)
)
const input = take(
['input.background', 'dropdown.background', 'quickInput.background'],
mix(background, foreground, dark ? 0.1 : 0.06)
)
const mutedForeground = take(
['descriptionForeground', 'editorLineNumber.foreground', 'tab.inactiveForeground', 'disabledForeground'],
mix(foreground, background, 0.45)
)
const destructive = take(
[
'editorError.foreground',
'errorForeground',
'editorOverviewRuler.errorForeground',
'notificationsErrorIcon.foreground'
],
'#e25563'
)
const muted = mix(background, foreground, dark ? 0.06 : 0.04)
const accentSoft = mix(accent, background, dark ? 0.82 : 0.88)
const secondary = mix(accent, background, dark ? 0.72 : 0.86)
const palette: DesktopThemeColors = {
background,
foreground,
card,
cardForeground: foreground,
muted,
mutedForeground,
popover: elevated,
popoverForeground: foreground,
primary: accent,
primaryForeground: readableInk(accent),
secondary,
secondaryForeground: foreground,
accent: accentSoft,
accentForeground: foreground,
border,
input,
ring: accent,
midground: accent,
midgroundForeground: readableInk(accent),
composerRing: accent,
destructive,
destructiveForeground: readableInk(destructive),
sidebarBackground: sidebar,
sidebarBorder: border,
userBubble: mix(card, accent, dark ? 0.18 : 0.12),
userBubbleBorder: border
}
const label = (opts.label ?? raw.name ?? 'VS Code Theme').trim()
const slug = opts.slug ?? vscodeThemeSlug(label)
const terminal = extractTerminalPalette(colors, background)
return {
derived,
mode: dark ? 'dark' : 'light',
theme: {
name: slug,
label,
description: opts.source ? `VS Code · ${opts.source}` : 'Imported from VS Code',
// Single palette in both slots. A lone VS Code theme is one-mode; callers
// that have both a light and dark variant (a Marketplace extension family)
// recombine them into proper colors/darkColors via buildThemeFromMarketplace.
colors: palette,
darkColors: palette,
// Only set when the theme ships a full ANSI palette — the terminal keeps
// its built-in VS Code defaults otherwise.
...(terminal ? { terminal } : {})
}
}
}