35022e02ed
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)
373 lines
12 KiB
TypeScript
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 } : {})
|
|
}
|
|
}
|
|
}
|