opentui(phase3): launcher integration — HERMES_TUI_ENGINE dual-engine

hermes --tui launches the native OpenTUI engine (Bun) when
HERMES_TUI_ENGINE=opentui (env) or display.tui_engine=opentui (config);
Ink stays the default and the shipping path is untouched.

- _resolve_tui_engine() (env > config > ink); refuses opentui on
  Windows/Termux (no Bun) -> falls back to ink with a notice.
- _make_opentui_argv() -> [bun, src/entry.real.tsx] (no build step).
- _bun_bin() with HERMES_BUN override.
- Branch at top of _make_tui_argv BEFORE _ensure_tui_node (Bun-only host
  must not bootstrap Node).
- Gate _launch_tui NODE_OPTIONS/--max-old-space-size on engine==ink (Bun
  is JSC; the V8 flag errors/ignores).

Verified end-to-end via tmux: real hermes --tui -> Bun -> OpenTUI ->
real Python gateway streamed a real reply. No-flag default still ink.
This commit is contained in:
alt-glitch
2026-06-08 11:11:54 +00:00
parent 24f74eb888
commit 2bd9c9b881
741 changed files with 17733 additions and 79889 deletions
-142
View File
@@ -1,142 +0,0 @@
/**
* Small color helpers shared by the theme context (synthesised light variants)
* and the VS Code theme converter (token → seed mapping).
*
* Everything works in 6-digit `#rrggbb`. `normalizeHex` is the front door for
* untrusted input (VS Code themes use `#rgb`, `#rgba`, `#rrggbbaa`, and named
* tokens), flattening alpha over a backdrop so downstream math stays simple.
*/
export function hexToRgb(hex: string): [number, number, number] | null {
const clean = hex.trim().replace(/^#/, '')
if (!/^[0-9a-f]{6}$/i.test(clean)) {
return null
}
return [0, 2, 4].map(i => parseInt(clean.slice(i, i + 2), 16)) as [number, number, number]
}
export const rgbToHex = ([r, g, b]: [number, number, number]): string =>
`#${[r, g, b].map(n => Math.round(Math.min(255, Math.max(0, n))).toString(16).padStart(2, '0')).join('')}`
export function mix(a: string, b: string, amount: number): string {
const ar = hexToRgb(a)
const br = hexToRgb(b)
return ar && br
? rgbToHex([ar[0] + (br[0] - ar[0]) * amount, ar[1] + (br[1] - ar[1]) * amount, ar[2] + (br[2] - ar[2]) * amount])
: a
}
const linearize = (channel: number): number =>
channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4
/** WCAG relative luminance (gamma-corrected), 0..1. */
export function relativeLuminance(hex: string): number {
const rgb = hexToRgb(hex)
if (!rgb) {
return 0
}
const [r, g, b] = rgb.map(v => linearize(v / 255))
return 0.2126 * r + 0.7152 * g + 0.0722 * b
}
/** WCAG contrast ratio (1..21) between two hex colors. */
export function contrastRatio(a: string, b: string): number {
const la = relativeLuminance(a)
const lb = relativeLuminance(b)
return la >= lb ? (la + 0.05) / (lb + 0.05) : (lb + 0.05) / (la + 0.05)
}
/** Returns a readable foreground (#161616 or #ffffff) for a background hex. */
export function readableOn(hex: string): string {
return relativeLuminance(hex) > 0.58 ? '#161616' : '#ffffff'
}
/**
* Guarantee `color` reads against `bg`: if it's below `min` contrast, mix it
* toward white (on a dark bg) or black (on a light bg) in steps until it clears,
* keeping the hue as much as possible. Used so imported accents never collapse
* into a near-background sidebar (the "invisible label" case).
*/
export function ensureContrast(color: string, bg: string, min: number): string {
if (contrastRatio(color, bg) >= min) {
return color
}
const towards = relativeLuminance(bg) < 0.5 ? '#ffffff' : '#000000'
let best = color
for (let amount = 0.2; amount <= 1.0001; amount += 0.2) {
best = mix(color, towards, Math.min(amount, 1))
if (contrastRatio(best, bg) >= min) {
return best
}
}
return best
}
/** Perceptual-ish luminance in 0..1 (naive, for light/dark bucketing). */
export function luminance(hex: string): number {
const rgb = hexToRgb(hex)
if (!rgb) {
return 0
}
const [r, g, b] = rgb.map(v => v / 255)
return 0.2126 * r + 0.7152 * g + 0.0722 * b
}
/**
* Coerce any CSS hex color VS Code themes throw at us into a flat 6-digit
* `#rrggbb`, compositing alpha over `backdrop`. Accepts `#rgb`, `#rgba`,
* `#rrggbb`, `#rrggbbaa` (with or without the leading `#`). Returns null for
* non-hex values (named colors, `rgb()`, etc.) so callers can fall back.
*/
export function normalizeHex(input: string | undefined | null, backdrop = '#000000'): string | null {
if (typeof input !== 'string') {
return null
}
let clean = input.trim().replace(/^#/, '')
// Expand shorthand (#rgb / #rgba) to full width.
if (clean.length === 3 || clean.length === 4) {
clean = clean
.split('')
.map(ch => ch + ch)
.join('')
}
if (!/^[0-9a-f]{6}([0-9a-f]{2})?$/i.test(clean)) {
return null
}
const rgb = hexToRgb(`#${clean.slice(0, 6)}`)
if (!rgb) {
return null
}
if (clean.length === 6) {
return rgbToHex(rgb)
}
const alpha = parseInt(clean.slice(6, 8), 16) / 255
const base = hexToRgb(backdrop) ?? [0, 0, 0]
return rgbToHex([
base[0] + (rgb[0] - base[0]) * alpha,
base[1] + (rgb[1] - base[1]) * alpha,
base[2] + (rgb[2] - base[2]) * alpha
])
}
+53 -106
View File
@@ -9,30 +9,15 @@
* The two are persisted independently. Shift+X toggles light/dark.
*/
import { useStore } from '@nanostores/react'
import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useState } from 'react'
import { matchesQuery, useMediaQuery } from '@/hooks/use-media-query'
import { persistString, persistStringRecord, storedString, storedStringRecord } from '@/lib/storage'
import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile'
import { hexToRgb, mix, readableOn } from './color'
import { BUILTIN_THEME_LIST, BUILTIN_THEMES, DEFAULT_SKIN_NAME, DEFAULT_TYPOGRAPHY, nousTheme } from './presets'
import type { DesktopTheme, DesktopThemeColors } from './types'
import { $userThemes, resolveTheme } from './user-themes'
// Legacy global skin (pre per-profile themes). Still the inheritance fallback
// for any profile without its own assignment, so single-profile users and old
// installs are unaffected.
const SKIN_KEY = 'hermes-desktop-theme-v2'
const MODE_KEY = 'hermes-desktop-mode-v1'
// Per-profile skin + light/dark mode assignments: { [profileKey]: value }. A
// profile inherits the global default until it's given its own appearance.
const PROFILE_SKINS_KEY = 'hermes-desktop-profile-themes-v1'
const PROFILE_MODES_KEY = 'hermes-desktop-profile-modes-v1'
// Last active profile, recorded so the boot-time paint can pick that profile's
// theme before the gateway reports which profile actually launched.
const LAST_PROFILE_KEY = 'hermes-desktop-active-profile-v1'
const RETIRED_SKINS = new Set(['nous-light', 'default', 'gold'])
export type ThemeMode = 'light' | 'dark' | 'system'
@@ -42,39 +27,48 @@ const INJECTED_FONT_URLS = new Set<string>()
const resolveMode = (mode: ThemeMode, systemDark = matchesQuery('(prefers-color-scheme: dark)')): 'light' | 'dark' =>
mode === 'system' ? (systemDark ? 'dark' : 'light') : mode
const normalizeSkin = (name: string | null): string =>
name && resolveTheme(name) && !RETIRED_SKINS.has(name) ? name : DEFAULT_SKIN_NAME
const normalizeMode = (value: string | null): ThemeMode =>
value === 'light' || value === 'dark' || value === 'system' ? value : 'light'
// ─── Per-profile appearance persistence ─────────────────────────────────────
// Skin and mode are each stored per profile. "default" isn't a real profile —
// it *is* the legacy global slot, so it reads/writes the global directly. Named
// profiles get their own entry and fall back to that global until assigned, so
// unassigned profiles and pre-per-profile installs stay on the global value.
const profilePref = <T extends string>(record: string, legacy: string, normalize: (v: string | null) => T) => ({
resolve: (profile: string): T => normalize(storedStringRecord(record)[profile] ?? storedString(legacy)),
assign: (profile: string, value: T): void => {
if (profile === 'default') {
persistString(legacy, value)
} else {
persistStringRecord(record, { ...storedStringRecord(record), [profile]: value })
}
}
})
export const skinPref = profilePref(PROFILE_SKINS_KEY, SKIN_KEY, normalizeSkin)
export const modePref = profilePref(PROFILE_MODES_KEY, MODE_KEY, normalizeMode)
// Last active profile — lets the boot paint pick its appearance before the
// gateway reports which profile actually launched.
const readBootProfileKey = () => normalizeProfileKey(storedString(LAST_PROFILE_KEY))
const rememberActiveProfileKey = (profile: string) => persistString(LAST_PROFILE_KEY, profile)
const normalizeSkin = (name: string | null | undefined): string =>
name && BUILTIN_THEMES[name] && !RETIRED_SKINS.has(name) ? name : DEFAULT_SKIN_NAME
// ─── Color math (for synthesised light variants of dark-only skins) ────────
// hexToRgb / mix / readableOn live in ./color so the VS Code converter shares
// the exact same math.
function hexToRgb(hex: string): [number, number, number] | null {
const clean = hex.trim().replace(/^#/, '')
if (!/^[0-9a-f]{6}$/i.test(clean)) {
return null
}
return [0, 2, 4].map(i => parseInt(clean.slice(i, i + 2), 16)) as [number, number, number]
}
const rgbToHex = ([r, g, b]: [number, number, number]) =>
`#${[r, g, b].map(n => Math.round(n).toString(16).padStart(2, '0')).join('')}`
function mix(a: string, b: string, amount: number): string {
const ar = hexToRgb(a)
const br = hexToRgb(b)
return ar && br
? rgbToHex([ar[0] + (br[0] - ar[0]) * amount, ar[1] + (br[1] - ar[1]) * amount, ar[2] + (br[2] - ar[2]) * amount])
: a
}
function readableOn(hex: string): string {
const rgb = hexToRgb(hex)
if (!rgb) {
return '#ffffff'
}
const [r, g, b] = rgb.map(v => {
const c = v / 255
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
})
return 0.2126 * r + 0.7152 * g + 0.0722 * b > 0.58 ? '#161616' : '#ffffff'
}
function synthLightColors(seed: DesktopTheme): DesktopThemeColors {
const accent = seed.colors.ring || seed.colors.primary
@@ -114,7 +108,7 @@ function synthLightColors(seed: DesktopTheme): DesktopThemeColors {
/** Returns the seed palette for a given skin + mode (no overrides applied). */
export function getBaseColors(skinName: string, mode: 'light' | 'dark'): DesktopThemeColors {
const seed = resolveTheme(skinName) ?? nousTheme
const seed = BUILTIN_THEMES[skinName] ?? nousTheme
if (mode === 'dark') {
return seed.darkColors ?? seed.colors
@@ -124,7 +118,7 @@ export function getBaseColors(skinName: string, mode: 'light' | 'dark'): Desktop
}
function deriveTheme(skinName: string, mode: 'light' | 'dark'): DesktopTheme {
const seed = resolveTheme(skinName) ?? nousTheme
const seed = BUILTIN_THEMES[skinName] ?? nousTheme
return {
...seed,
@@ -237,13 +231,12 @@ function applyTheme(theme: DesktopTheme, mode: 'light' | 'dark') {
}
}
// Boot-time paint to avoid a flash before <ThemeProvider> mounts. Use the last
// active profile's appearance so a non-default profile relaunch paints its own
// skin + light/dark mode.
// Boot-time paint to avoid a flash before <ThemeProvider> mounts.
if (typeof window !== 'undefined') {
const profile = readBootProfileKey()
const resolved = resolveMode(modePref.resolve(profile))
applyTheme(deriveTheme(skinPref.resolve(profile), resolved), resolved)
const skin = normalizeSkin(window.localStorage.getItem(SKIN_KEY))
const mode = (window.localStorage.getItem(MODE_KEY) as ThemeMode) ?? 'light'
const resolved = resolveMode(mode)
applyTheme(deriveTheme(skin, resolved), resolved)
}
// ─── Context ────────────────────────────────────────────────────────────────
@@ -252,15 +245,7 @@ interface ThemeContextValue {
theme: DesktopTheme
themeName: string
mode: ThemeMode
/** The light/dark switch the user picked. */
resolvedMode: 'light' | 'dark'
/**
* The mode actually painted, derived from the active background's luminance.
* Differs from `resolvedMode` for skins that keep a bright surface in "dark"
* (or vice-versa). Surface-bound UI (e.g. the terminal palette) should key off
* this so it matches what's on screen instead of inverting.
*/
renderedMode: 'light' | 'dark'
availableThemes: Array<{ name: string; label: string; description: string }>
setTheme: (name: string) => void
setMode: (mode: ThemeMode) => void
@@ -273,81 +258,43 @@ const ThemeContext = createContext<ThemeContextValue>({
themeName: DEFAULT_SKIN_NAME,
mode: 'light',
resolvedMode: 'light',
renderedMode: 'light',
availableThemes: SKIN_LIST,
setTheme: () => {},
setMode: () => {}
})
export function ThemeProvider({ children }: { children: ReactNode }) {
// Skin + mode are assigned per profile; the active profile drives which
// appearance shows. Single-profile users only ever see "default", so their
// behavior is unchanged.
const profileKey = normalizeProfileKey(useStore($activeGatewayProfile))
// Built-ins + user-installed themes. Reactive so an import shows up live in
// the palette, settings grid, and `/skin` without a reload.
const userThemes = useStore($userThemes)
const availableThemes = useMemo(
() =>
[...Object.values(BUILTIN_THEMES), ...Object.values(userThemes)].map(({ name, label, description }) => ({
name,
label,
description
})),
[userThemes]
)
const [themeName, setThemeNameState] = useState(() =>
typeof window === 'undefined' ? DEFAULT_SKIN_NAME : skinPref.resolve(readBootProfileKey())
typeof window === 'undefined' ? DEFAULT_SKIN_NAME : normalizeSkin(window.localStorage.getItem(SKIN_KEY))
)
const [mode, setModeState] = useState<ThemeMode>(() =>
typeof window === 'undefined' ? 'light' : modePref.resolve(readBootProfileKey())
typeof window === 'undefined' ? 'light' : ((window.localStorage.getItem(MODE_KEY) as ThemeMode) ?? 'light')
)
// Follow profile switches: paint the profile's assigned skin + mode and
// remember it for the next boot's first paint.
useEffect(() => {
rememberActiveProfileKey(profileKey)
setThemeNameState(skinPref.resolve(profileKey))
setModeState(modePref.resolve(profileKey))
}, [profileKey])
const systemDark = useMediaQuery('(prefers-color-scheme: dark)')
const resolvedMode = resolveMode(mode, systemDark)
const activeTheme = useMemo(() => deriveTheme(themeName, resolvedMode), [themeName, resolvedMode])
// What actually gets painted (matches the `.dark` class applyTheme toggles).
const renderedMode = useMemo(
() => renderedModeFor(activeTheme.colors, resolvedMode),
[activeTheme, resolvedMode]
)
useEffect(() => applyTheme(activeTheme, resolvedMode), [activeTheme, resolvedMode])
// Assign to whichever profile is live right now (read fresh so the callbacks
// stay stable across profile switches).
const liveProfile = () => normalizeProfileKey($activeGatewayProfile.get())
const setTheme = useCallback((name: string) => {
const next = normalizeSkin(name)
setThemeNameState(next)
skinPref.assign(liveProfile(), next)
window.localStorage.setItem(SKIN_KEY, next)
}, [])
const setMode = useCallback((next: ThemeMode) => {
setModeState(next)
modePref.assign(liveProfile(), next)
window.localStorage.setItem(MODE_KEY, next)
}, [])
// 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.
const value = useMemo<ThemeContextValue>(
() => ({ theme: activeTheme, themeName, mode, resolvedMode, renderedMode, availableThemes, setTheme, setMode }),
[activeTheme, themeName, mode, resolvedMode, renderedMode, availableThemes, setTheme, setMode]
() => ({ theme: activeTheme, themeName, mode, resolvedMode, availableThemes: SKIN_LIST, setTheme, setMode }),
[activeTheme, themeName, mode, resolvedMode, setTheme, setMode]
)
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
-119
View File
@@ -1,119 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { DesktopMarketplaceThemeResult } from '@/global'
import { luminance } from './color'
import { buildThemeFromMarketplace } from './install'
const themeJson = (type: 'light' | 'dark', background: string, foreground: string) =>
JSON.stringify({ type, colors: { 'editor.background': background, 'editor.foreground': foreground } })
// A full base-8 ANSI set keyed off `red` so each variant is distinguishable.
const ansiColors = (red: string) => ({
'terminal.ansiBlack': '#000000',
'terminal.ansiRed': red,
'terminal.ansiGreen': '#00aa00',
'terminal.ansiYellow': '#aaaa00',
'terminal.ansiBlue': '#0000aa',
'terminal.ansiMagenta': '#aa00aa',
'terminal.ansiCyan': '#00aaaa',
'terminal.ansiWhite': '#aaaaaa'
})
const themeJsonWithAnsi = (type: 'light' | 'dark', background: string, foreground: string, red: string) =>
JSON.stringify({ type, colors: { 'editor.background': background, 'editor.foreground': foreground, ...ansiColors(red) } })
describe('buildThemeFromMarketplace', () => {
it('folds a light + dark variant into one family with both slots', () => {
const result: DesktopMarketplaceThemeResult = {
extensionId: 'ryanolsonx.solarized',
displayName: 'Solarized',
themes: [
{ label: 'Solarized Light', uiTheme: 'vs', contents: themeJson('light', '#fdf6e3', '#586e75') },
{ label: 'Solarized Dark', uiTheme: 'vs-dark', contents: themeJson('dark', '#002b36', '#93a1a1') }
]
}
const theme = buildThemeFromMarketplace(result)
expect(theme.label).toBe('Solarized')
expect(theme.name).toBe('vsc-solarized')
// colors = the light variant, darkColors = the dark variant → the toggle works.
expect(theme.colors.background).toBe('#fdf6e3')
expect(theme.darkColors?.background).toBe('#002b36')
expect(luminance(theme.colors.background)).toBeGreaterThan(0.5)
expect(luminance(theme.darkColors!.background)).toBeLessThan(0.5)
})
it('orders variants by contribution regardless of light/dark sequence', () => {
const result: DesktopMarketplaceThemeResult = {
extensionId: 'github.github-vscode-theme',
displayName: 'GitHub Theme',
themes: [
{ label: 'GitHub Dark Default', uiTheme: 'vs-dark', contents: themeJson('dark', '#0d1117', '#e6edf3') },
{ label: 'GitHub Light Default', uiTheme: 'vs', contents: themeJson('light', '#ffffff', '#1f2328') }
]
}
const theme = buildThemeFromMarketplace(result)
expect(theme.colors.background).toBe('#ffffff')
expect(theme.darkColors?.background).toBe('#0d1117')
})
it('fills both slots with the sole palette for a single-variant extension', () => {
const result: DesktopMarketplaceThemeResult = {
extensionId: 'dracula-theme.theme-dracula',
displayName: 'Dracula',
themes: [{ label: 'Dracula', uiTheme: 'vs-dark', contents: themeJson('dark', '#282a36', '#f8f8f2') }]
}
const theme = buildThemeFromMarketplace(result)
expect(theme.colors.background).toBe('#282a36')
expect(theme.darkColors).toBe(theme.colors)
})
it('keys each variant terminal palette to its mode (terminal / darkTerminal)', () => {
const result: DesktopMarketplaceThemeResult = {
extensionId: 'ryanolsonx.solarized',
displayName: 'Solarized',
themes: [
{ label: 'Solarized Light', uiTheme: 'vs', contents: themeJsonWithAnsi('light', '#fdf6e3', '#586e75', '#dc322f') },
{ label: 'Solarized Dark', uiTheme: 'vs-dark', contents: themeJsonWithAnsi('dark', '#002b36', '#93a1a1', '#ff5f56') }
]
}
const theme = buildThemeFromMarketplace(result)
expect(theme.terminal?.red).toBe('#dc322f')
expect(theme.darkTerminal?.red).toBe('#ff5f56')
})
it('reuses the sole variant terminal palette for both modes', () => {
const result: DesktopMarketplaceThemeResult = {
extensionId: 'dracula-theme.theme-dracula',
displayName: 'Dracula',
themes: [{ label: 'Dracula', uiTheme: 'vs-dark', contents: themeJsonWithAnsi('dark', '#282a36', '#f8f8f2', '#ff5555') }]
}
const theme = buildThemeFromMarketplace(result)
expect(theme.terminal?.red).toBe('#ff5555')
expect(theme.darkTerminal?.red).toBe('#ff5555')
})
it('leaves terminal slots unset when no variant ships an ANSI palette', () => {
const result: DesktopMarketplaceThemeResult = {
extensionId: 'x.plain',
displayName: 'Plain',
themes: [{ label: 'Plain', uiTheme: 'vs-dark', contents: themeJson('dark', '#101010', '#fafafa') }]
}
const theme = buildThemeFromMarketplace(result)
expect(theme.terminal).toBeUndefined()
expect(theme.darkTerminal).toBeUndefined()
})
it('throws when the extension contributes no themes', () => {
expect(() =>
buildThemeFromMarketplace({ extensionId: 'x.y', displayName: 'X', themes: [] })
).toThrow(/does not contribute/i)
})
})
-95
View File
@@ -1,95 +0,0 @@
/**
* Install desktop themes from external sources.
*
* The heavy lifting (network + .vsix unzip) lives in the Electron main process
* (`electron/vscode-marketplace.cjs`), reached via `window.hermesDesktop.themes`.
* Main hands back the raw theme JSON; we parse + convert + persist here so the
* conversion stays in one unit-testable place.
*/
import type { DesktopMarketplaceThemeResult } from '@/global'
import type { DesktopTheme } from './types'
import { installUserTheme } from './user-themes'
import { convertVscodeColorTheme, parseVscodeTheme, vscodeThemeSlug } from './vscode'
/** A `publisher.extension` id, e.g. `dracula-theme.theme-dracula`. */
export const MARKETPLACE_ID_RE = /^[\w-]+\.[\w-]+$/
/** Parse + convert + persist a pasted VS Code theme JSON. */
export function installVscodeThemeFromText(
text: string,
opts?: { label?: string; source?: string }
): DesktopTheme {
const raw = parseVscodeTheme(text)
const { theme } = convertVscodeColorTheme(raw, opts)
return installUserTheme(theme)
}
/**
* Fold every color theme an extension contributes into ONE desktop theme family.
*
* Many extensions ship a light *and* a dark variant (GitHub, Solarized, Winter
* is Coming…). Rather than install them as separate flat entries — which made
* the light/dark toggle a no-op and let "install in dark mode" land on the light
* variant — we map the first light variant onto `colors` and the first dark
* variant onto `darkColors`. The result is a single picker entry whose light/dark
* toggle switches between the real variants. A single-variant extension fills
* both slots with its one palette (the toggle is a no-op, as it must be).
*/
export function buildThemeFromMarketplace(result: DesktopMarketplaceThemeResult): DesktopTheme {
if (!result.themes.length) {
throw new Error(`"${result.extensionId}" does not contribute any color themes.`)
}
const variants = result.themes.map(file => {
const raw = parseVscodeTheme(file.contents)
const label = file.label || raw.name || result.displayName
const { mode, theme } = convertVscodeColorTheme(raw, { label, source: result.extensionId })
return { mode, palette: theme.colors, terminal: theme.terminal }
})
const fallback = variants[0]
const light = variants.find(variant => variant.mode === 'light') ?? fallback
const dark = variants.find(variant => variant.mode === 'dark') ?? fallback
// The terminal ANSI palette tracks the painted variant the same way colors do
// (light → terminal, dark → darkTerminal); each falls back to the other so a
// single-variant import still themes the terminal in both modes.
const terminal = light.terminal ?? dark.terminal
const darkTerminal = dark.terminal ?? light.terminal
return {
name: vscodeThemeSlug(result.displayName),
label: result.displayName,
description: `VS Code · ${result.extensionId}`,
colors: light.palette,
darkColors: dark.palette,
...(terminal ? { terminal } : {}),
...(darkTerminal ? { darkTerminal } : {})
}
}
/**
* Download a Marketplace extension and install the theme family it contributes
* (see `buildThemeFromMarketplace`). Returns the single installed theme.
*/
export async function installVscodeThemeFromMarketplace(id: string): Promise<DesktopTheme> {
const trimmed = id.trim()
if (!MARKETPLACE_ID_RE.test(trimmed)) {
throw new Error('Expected a Marketplace id like "publisher.extension".')
}
const api = window.hermesDesktop?.themes
if (!api?.fetchMarketplace) {
throw new Error('Marketplace install is only available in the desktop app.')
}
const result = await api.fetchMarketplace(trimmed)
return installUserTheme(buildThemeFromMarketplace(result))
}
@@ -1,41 +0,0 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { modePref, skinPref } from './context'
import { DEFAULT_SKIN_NAME } from './presets'
// Skin and mode share one per-profile contract, so assert it once over both.
interface Pref {
resolve: (profile: string) => string
assign: (profile: string, value: string) => void
}
const cases = [
{ name: 'skin', pref: skinPref as unknown as Pref, fallback: DEFAULT_SKIN_NAME, a: 'ember', b: 'midnight', junk: 'nope' },
{ name: 'mode', pref: modePref as unknown as Pref, fallback: 'light', a: 'dark', b: 'system', junk: 'dusk' }
]
describe.each(cases)('per-profile $name', ({ pref, fallback, a, b, junk }) => {
beforeEach(() => window.localStorage.clear())
it('falls back to the default when unassigned', () => {
expect(pref.resolve('default')).toBe(fallback)
expect(pref.resolve('work')).toBe(fallback)
})
it('keeps each profile on its own value', () => {
pref.assign('work', a)
pref.assign('default', b)
expect(pref.resolve('work')).toBe(a)
expect(pref.resolve('default')).toBe(b)
})
it('lets unassigned profiles inherit the default profile as the global fallback', () => {
pref.assign('default', a)
expect(pref.resolve('never-themed')).toBe(a)
})
it('normalizes an unknown stored value back to the default', () => {
pref.assign('work', junk)
expect(pref.resolve('work')).toBe(fallback)
})
})
-35
View File
@@ -54,37 +54,6 @@ export interface DesktopThemeTypography {
fontUrl?: string
}
/**
* Integrated-terminal ANSI palette (xterm `ITheme`, minus `background`).
*
* Populated only when a converted VS Code theme ships a full `terminal.ansi*`
* set; otherwise the terminal keeps its built-in VS Code default palette.
* `background` is intentionally absent — the pane always paints the live skin
* surface so it stays translucent.
*/
export interface DesktopTerminalPalette {
foreground?: string
cursor?: string
/** Keeps its source alpha — xterm blends it over the surface. */
selectionBackground?: string
black?: string
red?: string
green?: string
yellow?: string
blue?: string
magenta?: string
cyan?: string
white?: string
brightBlack?: string
brightRed?: string
brightGreen?: string
brightYellow?: string
brightBlue?: string
brightMagenta?: string
brightCyan?: string
brightWhite?: string
}
export interface DesktopTheme {
name: string
label: string
@@ -94,8 +63,4 @@ export interface DesktopTheme {
/** Hand-tuned dark palette. Skins like `nous` ship one. */
darkColors?: DesktopThemeColors
typography?: Partial<DesktopThemeTypography>
/** Light-variant terminal ANSI palette (also the fallback for dark). */
terminal?: DesktopTerminalPalette
/** Dark-variant terminal ANSI palette. Falls back to `terminal`. */
darkTerminal?: DesktopTerminalPalette
}
@@ -1,63 +0,0 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { BUILTIN_THEMES, DEFAULT_SKIN_NAME } from './presets'
import { $userThemes, installUserTheme, isUserTheme, listAllThemes, removeUserTheme, resolveTheme } from './user-themes'
import { convertVscodeColorTheme } from './vscode'
const makeTheme = (label: string) =>
convertVscodeColorTheme({
name: label,
type: 'dark',
colors: { 'editor.background': '#101014', 'editor.foreground': '#fafafa', focusBorder: '#7aa2f7' }
}).theme
describe('user theme registry', () => {
beforeEach(() => {
window.localStorage.clear()
$userThemes.set({})
})
it('installs a theme into the merged registry and persists it', () => {
const theme = installUserTheme(makeTheme('Tokyo Night'))
expect(isUserTheme(theme.name)).toBe(true)
expect(resolveTheme(theme.name)).toEqual(theme)
expect(listAllThemes().map(t => t.name)).toContain(theme.name)
expect(window.localStorage.getItem('hermes-desktop-user-themes-v1')).toContain(theme.name)
})
it('lists built-ins before user themes', () => {
installUserTheme(makeTheme('Custom'))
const names = listAllThemes().map(t => t.name)
expect(names.slice(0, Object.keys(BUILTIN_THEMES).length)).toEqual(Object.keys(BUILTIN_THEMES))
expect(names.at(-1)).toBe('vsc-custom')
})
it('removes a theme', () => {
const theme = installUserTheme(makeTheme('Throwaway'))
removeUserTheme(theme.name)
expect(isUserTheme(theme.name)).toBe(false)
expect(resolveTheme(theme.name)).toBeUndefined()
})
it('resolves built-ins through the same lookup', () => {
expect(resolveTheme(DEFAULT_SKIN_NAME)).toBe(BUILTIN_THEMES[DEFAULT_SKIN_NAME])
})
it('refuses to shadow a built-in name', () => {
const builtinName = makeTheme('x')
builtinName.name = DEFAULT_SKIN_NAME
expect(() => installUserTheme(builtinName)).toThrow(/built-in/)
})
it('rejects a theme missing required colors', () => {
const broken = makeTheme('Broken')
// @ts-expect-error — intentionally corrupt the palette for the test.
broken.colors = { background: '#000000' }
expect(() => installUserTheme(broken)).toThrow(/colors/)
})
})
-122
View File
@@ -1,122 +0,0 @@
/**
* User-installed desktop themes (currently: converted VS Code themes).
*
* This is the extensibility seam. The theme context reads the *merged* registry
* (built-ins + user themes) for `availableThemes` and for every skin lookup, so
* an installed theme shows up everywhere a built-in does — the Cmd-K palette,
* the Appearance settings grid, and `/skin` — with no per-surface wiring.
*
* Stored as a localStorage record so the boot-time paint (which runs before
* React mounts) can resolve a user theme synchronously, same as built-ins.
*/
import { atom } from 'nanostores'
import { BUILTIN_THEMES } from './presets'
import type { DesktopTheme, DesktopThemeColors } from './types'
const USER_THEMES_KEY = 'hermes-desktop-user-themes-v1'
// The minimal set of color keys a stored theme must carry to be usable. We keep
// this loose — `applyTheme` tolerates missing optionals via fallbacks — but a
// theme with no background/foreground/primary is junk and gets dropped.
const REQUIRED_COLOR_KEYS: ReadonlyArray<keyof DesktopThemeColors> = ['background', 'foreground', 'primary']
function isValidTheme(value: unknown): value is DesktopTheme {
if (!value || typeof value !== 'object') {
return false
}
const theme = value as Partial<DesktopTheme>
if (typeof theme.name !== 'string' || typeof theme.label !== 'string' || !theme.colors) {
return false
}
const colors = theme.colors as unknown as Record<string, unknown>
return REQUIRED_COLOR_KEYS.every(key => typeof colors[key] === 'string')
}
function readStored(): Record<string, DesktopTheme> {
try {
const raw = window.localStorage.getItem(USER_THEMES_KEY)
if (!raw) {
return {}
}
const parsed: unknown = JSON.parse(raw)
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return {}
}
const out: Record<string, DesktopTheme> = {}
for (const [key, value] of Object.entries(parsed)) {
// Never let a stored theme shadow a built-in name.
if (!BUILTIN_THEMES[key] && isValidTheme(value)) {
out[key] = value
}
}
return out
} catch {
return {}
}
}
function persist(record: Record<string, DesktopTheme>) {
try {
window.localStorage.setItem(USER_THEMES_KEY, JSON.stringify(record))
} catch {
// Best-effort: a restricted storage context shouldn't break theming.
}
}
/** Reactive map of installed user themes, keyed by slug. */
export const $userThemes = atom<Record<string, DesktopTheme>>(typeof window === 'undefined' ? {} : readStored())
/** Install (or replace) a user theme. Returns the stored theme. */
export function installUserTheme(theme: DesktopTheme): DesktopTheme {
if (BUILTIN_THEMES[theme.name]) {
throw new Error(`"${theme.name}" collides with a built-in theme.`)
}
if (!isValidTheme(theme)) {
throw new Error('Theme is missing required colors.')
}
const next = { ...$userThemes.get(), [theme.name]: theme }
$userThemes.set(next)
persist(next)
return theme
}
/** Remove a user theme by slug. No-op for unknown / built-in names. */
export function removeUserTheme(name: string): void {
const current = $userThemes.get()
if (!current[name]) {
return
}
const next = { ...current }
delete next[name]
$userThemes.set(next)
persist(next)
}
export const isUserTheme = (name: string): boolean => Boolean($userThemes.get()[name])
/** Resolve a theme by name across the merged registry (built-in + user). */
export function resolveTheme(name: string): DesktopTheme | undefined {
return BUILTIN_THEMES[name] ?? $userThemes.get()[name]
}
/** Built-ins first (stable order), then user themes by install order. */
export function listAllThemes(): DesktopTheme[] {
return [...Object.values(BUILTIN_THEMES), ...Object.values($userThemes.get())]
}
-171
View File
@@ -1,171 +0,0 @@
import { describe, expect, it } from 'vitest'
import { contrastRatio } from './color'
import { convertVscodeColorTheme, parseVscodeTheme, vscodeThemeSlug } from './vscode'
describe('vscodeThemeSlug', () => {
it('namespaces, lowercases, and dashes', () => {
expect(vscodeThemeSlug('Dracula Soft')).toBe('vsc-dracula-soft')
expect(vscodeThemeSlug(' One Dark Pro!! ')).toBe('vsc-one-dark-pro')
})
it('falls back when the name has no usable characters', () => {
expect(vscodeThemeSlug('—')).toBe('vsc-theme')
})
})
describe('parseVscodeTheme (JSONC tolerance)', () => {
it('strips comments and trailing commas', () => {
const text = `{
// a line comment
"name": "Demo",
/* block comment */
"type": "dark",
"colors": {
"editor.background": "#1e1e2e", // inline
},
}`
const parsed = parseVscodeTheme(text)
expect(parsed.name).toBe('Demo')
expect(parsed.colors?.['editor.background']).toBe('#1e1e2e')
})
it('throws on a non-object', () => {
expect(() => parseVscodeTheme('42')).toThrow()
})
})
describe('convertVscodeColorTheme', () => {
const dracula = {
name: 'Dracula',
type: 'dark',
colors: {
'editor.background': '#282a36',
'editor.foreground': '#f8f8f2',
focusBorder: '#6272a4',
'editorWidget.background': '#21222c',
'sideBar.background': '#21222c',
errorForeground: '#ff5555',
// 8-digit hex (alpha) — must flatten over the background.
'panel.border': '#bd93f900'
}
}
it('maps the load-bearing tokens onto the palette', () => {
const { theme } = convertVscodeColorTheme(dracula, { source: 'dracula-theme.theme-dracula' })
expect(theme.name).toBe('vsc-dracula')
expect(theme.label).toBe('Dracula')
expect(theme.description).toContain('dracula-theme.theme-dracula')
expect(theme.colors.background).toBe('#282a36')
expect(theme.colors.foreground).toBe('#f8f8f2')
// One accent drives primary + ring + midground together...
expect(theme.colors.ring).toBe(theme.colors.primary)
expect(theme.colors.midground).toBe(theme.colors.primary)
// ...and it's nudged until it reads on the sidebar it labels (the dim
// focusBorder #6272a4 sits below AA, so it's lifted).
expect(contrastRatio(theme.colors.primary, theme.colors.sidebarBackground!)).toBeGreaterThanOrEqual(4.5)
expect(theme.colors.popover).toBe('#21222c')
expect(theme.colors.sidebarBackground).toBe('#21222c')
expect(theme.colors.destructive).toBe('#ff5555')
})
it('flattens alpha hex over the background (no #rrggbbaa leaks)', () => {
const { theme } = convertVscodeColorTheme(dracula)
expect(theme.colors.border).toMatch(/^#[0-9a-f]{6}$/)
// 00 alpha over the bg means the border collapses to the background.
expect(theme.colors.border).toBe('#282a36')
})
it('renders identically in both modes (single palette in both slots)', () => {
const { theme } = convertVscodeColorTheme(dracula)
expect(theme.darkColors).toBe(theme.colors)
})
it('records derived fallbacks for omitted tokens', () => {
const { derived } = convertVscodeColorTheme({
name: 'Sparse',
type: 'dark',
colors: { 'editor.background': '#101010', 'editor.foreground': '#fafafa' }
})
// No accent/elevated/sidebar/error tokens → all derived. The accent records
// its first candidate (button.background) when none of the family is present.
expect(derived).toContain('button.background')
expect(derived).toContain('editorWidget.background')
expect(derived).toContain('editorError.foreground')
})
it('buckets light vs dark from background luminance when type is absent', () => {
const light = convertVscodeColorTheme({
name: 'Bright',
colors: { 'editor.background': '#ffffff', 'editor.foreground': '#1a1a1a' }
}).theme
// A light background should keep a near-white background, not synth dark.
expect(light.colors.background).toBe('#ffffff')
})
it('throws when there is no colors map', () => {
expect(() => convertVscodeColorTheme({ name: 'Empty' })).toThrow(/colors/)
})
const fullAnsi = {
'terminal.ansiBlack': '#073642',
'terminal.ansiRed': '#dc322f',
'terminal.ansiGreen': '#859900',
'terminal.ansiYellow': '#b58900',
'terminal.ansiBlue': '#268bd2',
'terminal.ansiMagenta': '#d33682',
'terminal.ansiCyan': '#2aa198',
'terminal.ansiWhite': '#eee8d5',
'terminal.ansiBrightBlack': '#002b36',
'terminal.ansiBrightRed': '#cb4b16',
'terminal.ansiBrightGreen': '#586e75',
'terminal.ansiBrightYellow': '#657b83',
'terminal.ansiBrightBlue': '#839496',
'terminal.ansiBrightMagenta': '#6c71c4',
'terminal.ansiBrightCyan': '#93a1a1',
'terminal.ansiBrightWhite': '#fdf6e3'
}
it('lifts the ANSI palette when the full base-8 set is present', () => {
const { theme } = convertVscodeColorTheme({
name: 'Solarized Dark',
type: 'dark',
colors: {
'editor.background': '#002b36',
'editor.foreground': '#93a1a1',
'terminal.foreground': '#839496',
'terminalCursor.foreground': '#93a1a1',
// Alpha selection must survive un-flattened — xterm blends it.
'terminal.selectionBackground': '#073642aa',
...fullAnsi
}
})
expect(theme.terminal?.red).toBe('#dc322f')
expect(theme.terminal?.brightWhite).toBe('#fdf6e3')
expect(theme.terminal?.foreground).toBe('#839496')
expect(theme.terminal?.cursor).toBe('#93a1a1')
expect(theme.terminal?.selectionBackground).toBe('#073642aa')
// No background slot — the pane keeps the live surface (transparency).
expect('background' in (theme.terminal ?? {})).toBe(false)
})
it('keeps the default palette (no terminal slot) when the ANSI set is partial', () => {
const { theme } = convertVscodeColorTheme({
name: 'Half',
type: 'dark',
colors: {
'editor.background': '#101010',
'editor.foreground': '#fafafa',
'terminal.ansiRed': '#ff0000',
'terminal.ansiGreen': '#00ff00'
}
})
expect(theme.terminal).toBeUndefined()
})
})
-343
View File
@@ -1,343 +0,0 @@
/**
* 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, luminance, mix, normalizeHex, readableOn } 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: readableOn(accent),
secondary,
secondaryForeground: foreground,
accent: accentSoft,
accentForeground: foreground,
border,
input,
ring: accent,
midground: accent,
midgroundForeground: readableOn(accent),
composerRing: accent,
destructive,
destructiveForeground: readableOn(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 } : {})
}
}
}