Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license

This commit is contained in:
yilsem committed 2026-09-05 13:26:46 +03:00
commit 03634b1ca3
11340 files changed
+3442369

No files matched your search

@@ -0,0 +1,24 @@
/**
* Dev-only accent override.
*
* Live-authoring knob for `retintTheme`: while this holds a hex, the theme
* context paints the active skin re-seeded from it. `null` means "off" — the
* theme paints exactly as authored, which is the only state a production build
* can ever be in (the picker that sets this is installed under
* `import.meta.env.DEV`).
*
* Deliberately NOT persisted. It's a scratch control for finding a color you
* like, and the outcome is meant to be written into `presets.ts` as a real
* palette — not carried around as user state that would silently override a
* theme the user picked later.
*/
import { atom } from 'nanostores'
import { normalizeHex } from './color'
export const $accentOverride = atom<null | string>(null)
export function setAccentOverride(color: null | string): void {
$accentOverride.set(color === null ? null : (normalizeHex(color) ?? $accentOverride.get()))
}
@@ -0,0 +1,109 @@
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('seed does not paint, but a later same-name skin.changed applies (missed-activation recovery)', () => {
// Connect while display.skin is already neon: seed records the baseline
// without painting (never stomp the persisted desktop theme on connect).
ingestBackendSkin(skin('neon'), { apply: false }) // gateway.ready seed
expect($pendingSkinApply.get()).toBeNull()
// The activation event was missed (skin set while disconnected / backend
// restarted). Hermes re-affirms it — `hermes config set display.skin neon`
// or a `hermes skin set` recolor. That explicit event must repaint even
// though the name matches the seed.
ingestBackendSkin(skin('neon'), { apply: true })
expect($pendingSkinApply.get()).toBe('neon')
// Once applied, a repeat same-name event is a no-op again...
$pendingSkinApply.set(null)
ingestBackendSkin(skin('neon'), { apply: true })
expect($pendingSkinApply.get()).toBeNull()
// ...and a genuine switch still applies.
ingestBackendSkin(skin('forest'), { apply: true }) // Hermes authored a new skin
expect($pendingSkinApply.get()).toBe('forest')
})
it('a reconnect re-seed after a real apply does not downgrade the applied baseline', () => {
ingestBackendSkin(skin('neon'), { apply: true }) // applied for real
$pendingSkinApply.set(null)
ingestBackendSkin(skin('neon'), { apply: false }) // reconnect: gateway.ready re-seed
ingestBackendSkin(skin('neon'), { apply: true }) // repeat event (e.g. in-place recolor)
// Already painted once — the repeat must not re-apply (protects a manual
// desktop-side theme switch from being snapped back after a reconnect).
expect($pendingSkinApply.get()).toBeNull()
})
it('never registers default in the backend store (desktop keeps its own palette)', () => {
ingestBackendSkin(skin('default'), { apply: true })
expect($backendThemes.get().default).toBeUndefined()
})
it('does not apply default on the connect-time seed', () => {
ingestBackendSkin(skin('default'), { apply: false })
expect($pendingSkinApply.get()).toBeNull()
})
it('applies a runtime switch back to default (repaints the desktop to its own default)', () => {
ingestBackendSkin(skin('neon'), { apply: false }) // gateway.ready seed on some skin
ingestBackendSkin(skin('default'), { apply: true }) // Hermes switched back to default
expect($pendingSkinApply.get()).toBe('default')
})
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()
})
})
+94
View File
@@ -0,0 +1,94 @@
/**
* 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)
// Last skin name synced from the backend + whether it was ever APPLIED (vs
// merely seeded at connect). Once applied, only a name change applies again —
// no re-apply on repeat events, no snap-back after a manual desktop switch.
// A `skin.changed` matching a seed-only baseline still applies: the seed
// records without painting, so if the activation event was missed (backend
// restart / disconnected), an explicit re-affirm must repaint, not no-op.
let lastSynced: { applied: boolean; name: 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" on the PALETTE — the desktop keeps its own default
// (nous), so we never register a converted theme under `default`. It is still a
// valid apply TARGET though: a runtime switch back to `default` must repaint the
// desktop to its own default (setTheme normalizes `default` → nous). So we only
// skip the registry step here and let it flow through the apply logic below.
// 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 (name !== 'default' && !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 without painting. A reconnect re-seed keeps an
// earlier real apply's flag so repeat events can't override a manual switch.
if (lastSynced?.name !== name || !lastSynced.applied) {
lastSynced = { applied: false, name }
}
return
}
if (name !== lastSynced?.name || !lastSynced.applied) {
lastSynced = { applied: true, name }
$pendingSkinApply.set(name)
}
}
+411
View File
@@ -0,0 +1,411 @@
/**
* 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)
}
/**
* A readable foreground (`#161616` or `#ffffff`) for a background hex.
*
* Picks whichever of the two actually measures better, rather than splitting on
* a luminance threshold. The threshold version got mid-lightness accents wrong
* in the direction that matters — white on GitHub's dark green `#4f9e5e` is
* 3.29:1 (fails AA) where near-black is 5.50:1, and Catppuccin's mauve was a
* 2.03:1 white-on-lilac. Two candidates is a cheap enough search to just
* measure.
*/
export function readableOn(hex: string): string {
return contrastRatio(hex, '#ffffff') >= contrastRatio(hex, '#161616') ? '#ffffff' : '#161616'
}
/**
* 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
])
}
// ─── OKLCH ──────────────────────────────────────────────────────────────────
// Hue rotation has to happen in a perceptual space or it lies. Holding HSL's
// saturation+lightness across a hue sweep gives you mud at 60° (`#6d6d19`) and
// a washed teal at 200°, because HSL "lightness" is not lightness. OKLCH holds
// perceived lightness and colorfulness steady, so every hue lands with the same
// visual weight — which is what makes a single hue knob safe to expose.
const linearize01 = (c: number): number => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)
const delinearize01 = (c: number): number => (c <= 0.0031308 ? c * 12.92 : 1.055 * c ** (1 / 2.4) - 0.055)
/** Perceptual lightness 0..1, chroma (~0..0.37), hue 0..360. */
export interface Oklch {
l: number
c: number
h: number
}
export function hexToOklch(hex: string): Oklch | null {
const rgb = hexToRgb(hex)
if (!rgb) {
return null
}
const [okL, okA, okB] = rgbToOklab(rgb)
return {
l: okL,
c: Math.hypot(okA, okB),
h: ((Math.atan2(okB, okA) * 180) / Math.PI + 360) % 360
}
}
/** 0–255 sRGB triple → OKLab [L, a, b]. */
function rgbToOklab([r255, g255, b255]: readonly number[]): [number, number, number] {
const [r, g, b] = [r255, g255, b255].map(v => linearize01(v / 255))
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b)
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b)
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b)
return [
0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s
]
}
/** OKLab [L, a, b] → `#rrggbb`, via the chroma-preserving gamut fit. */
function oklabToHex([okL, okA, okB]: readonly number[]): string {
return oklchToHex({
l: okL,
c: Math.hypot(okA, okB),
h: ((Math.atan2(okB, okA) * 180) / Math.PI + 360) % 360
})
}
/** Raw (possibly out-of-gamut) linear-sRGB triple for an OKLCH color. */
function oklchToRgbRaw({ l: okL, c, h }: Oklch): [number, number, number] {
const rad = (h * Math.PI) / 180
const okA = c * Math.cos(rad)
const okB = c * Math.sin(rad)
const l = (okL + 0.3963377774 * okA + 0.2158037573 * okB) ** 3
const m = (okL - 0.1055613458 * okA - 0.0638541728 * okB) ** 3
const s = (okL - 0.0894841775 * okA - 1.291485548 * okB) ** 3
return [
4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s
]
}
const inSrgbGamut = (rgb: readonly number[]): boolean => rgb.every(c => c >= -0.001 && c <= 1.001)
/**
* OKLCH → `#rrggbb`, reducing chroma until the color fits sRGB.
*
* Clipping the channels instead would shift hue and lightness — a saturated
* blue clips to a different blue. Binary-searching chroma keeps the hue and the
* perceived lightness exactly, and only gives up the colorfulness the display
* cannot show. Deep blues and yellows need this; mid-chroma greens never do.
*/
export function oklchToHex(color: Oklch): string {
if (inSrgbGamut(oklchToRgbRaw(color))) {
return rgbToHex(oklchToRgbRaw(color).map(c => delinearize01(c) * 255) as [number, number, number])
}
let lo = 0
let hi = color.c
for (let i = 0; i < 24; i += 1) {
const mid = (lo + hi) / 2
if (inSrgbGamut(oklchToRgbRaw({ ...color, c: mid }))) {
lo = mid
} else {
hi = mid
}
}
return rgbToHex(oklchToRgbRaw({ ...color, c: lo }).map(c => delinearize01(c) * 255) as [number, number, number])
}
/**
* OKLCH → 0–255 sRGB, or `null` when the color is outside the display's gamut.
*
* The picker draws its field with this: a hue slice of OKLCH is a curved wedge
* in sRGB, not a rectangle, so plotting lightness against chroma needs to know
* where the wedge ENDS. Rendering the boundary honestly is the difference
* between a picker where every pixel is a real color and one where a third of
* the area silently clamps to the same few hexes.
*/
export function oklchToSrgb255(color: Oklch): [number, number, number] | null {
const raw = oklchToRgbRaw(color)
if (!inSrgbGamut(raw)) {
return null
}
return raw.map(c => Math.round(Math.min(1, Math.max(0, delinearize01(c))) * 255)) as [number, number, number]
}
/** Largest in-gamut chroma for a lightness+hue, for scaling the picker's axis. */
export function maxChroma(l: number, h: number): number {
let lo = 0
let hi = 0.4
for (let i = 0; i < 20; i += 1) {
const mid = (lo + hi) / 2
if (inSrgbGamut(oklchToRgbRaw({ l, c: mid, h }))) {
lo = mid
} else {
hi = mid
}
}
return lo
}
/**
* Signed shortest angular distance from `from` to `to`, in degrees (-180..180).
* Hue is a circle: 350° and 10° are 20° apart, not 340°.
*/
export function hueDelta(from: number, to: number): number {
return ((to - from + 540) % 360) - 180
}
/**
* Bend a semantic color toward the accent along the shortest hue arc.
*
* A success green next to a blue accent reads as a clash, but recoloring it to
* the accent throws away the meaning — "done" and "running" become one color.
* Rotating it PART of the way keeps the semantics and settles the palette: at
* `strength` 0 nothing moves, at 1 it lands on the accent hue.
*
* The default costs nothing by construction: when the accent already is a green
* (GitHub's 148° vs emerald's 162°) the arc is 14° and a 0.25 rotation moves the
* dot ~3° — invisible. The work only happens when the accent is genuinely far
* away, which is exactly the case that was clashing.
*/
export function harmonize(hex: string, accent: string, strength: number): string {
const base = hexToOklch(hex)
const target = hexToOklch(accent)
if (!base || !target) {
return hex
}
// Signed shortest arc, so a green never takes the long way round the wheel.
const h = (base.h + hueDelta(base.h, target.h) * Math.min(1, Math.max(0, strength)) + 360) % 360
return oklchToHex({ ...base, c: Math.min(Math.max(base.c, target.c * 0.85), maxChroma(base.l, h)), h })
}
/**
* Blend two colors in OKLab — the hue-stable counterpart to `mix`.
*
* `mix` lerps gamma-encoded sRGB channels, which bends hue on the way: a
* saturated blue mixed 88% toward white lands 7.6° violet of where it started,
* which is why a clean blue accent produced a lavender selection row. OKLab is
* (near) perceptually uniform, so the same blend keeps the hue and just walks
* chroma and lightness — the soft surface reads as a pale version of the accent
* instead of a different color.
*
* Use this for anything derived from the brand seed. `mix` is still correct for
* blending neutrals, where there is no hue to preserve.
*/
export function mixOklab(a: string, b: string, amount: number): string {
const ra = hexToRgb(a)
const rb = hexToRgb(b)
if (!ra || !rb) {
return a
}
const la = rgbToOklab(ra)
const lb = rgbToOklab(rb)
const t = Math.min(1, Math.max(0, amount))
return oklabToHex([la[0] + (lb[0] - la[0]) * t, la[1] + (lb[1] - la[1]) * t, la[2] + (lb[2] - la[2]) * t])
}
/** Re-hue a color, holding its perceived lightness and colorfulness. */
export function withHue(hex: string, hue: number): string {
const lch = hexToOklch(hex)
return lch ? oklchToHex({ ...lch, h: ((hue % 360) + 360) % 360 }) : hex
}
/**
* Make `hex` clear `min` contrast against `bg` by moving its LIGHTNESS only.
*
* The sRGB-mixing version of this (`ensureContrast`) blends toward white or
* black, which drags chroma down with it — Nous blue `#0053FD` mixed 30% white
* to pass AA on a dark surface loses a third of its colorfulness and reads
* washed. Walking OKLCH lightness instead keeps hue and chroma intact, so a
* brand color stays recognizably itself at whatever lightness the surface
* demands. Returns the original when it already passes, and gives up at the
* ends of the lightness range rather than looping.
*/
export function ensureContrastOklch(hex: string, bg: string, min: number): string {
if (contrastRatio(hex, bg) >= min) {
return hex
}
const lch = hexToOklch(hex)
if (!lch) {
return hex
}
// Lighten on a dark backdrop, darken on a light one.
const up = relativeLuminance(bg) < 0.5
let best = hex
for (let step = 1; step <= 40; step += 1) {
const l = lch.l + (up ? step : -step) * 0.02
if (l <= 0 || l >= 1) {
break
}
best = oklchToHex({ ...lch, l })
if (contrastRatio(best, bg) >= min) {
return best
}
}
return best
}
+140
View File
@@ -0,0 +1,140 @@
import { act, cleanup, render } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { __resetBackendSkinSync, ingestBackendSkin } from './backend-sync'
import { skinPref, ThemeProvider, useTheme } from './context'
import { everforestTheme } from './presets'
// The live-authoring loop: Hermes writes/edits one skin file and every surface
// repaints. An in-place edit keeps the NAME — only the palette moves.
const bloomberg = (foreground: string) => ({
name: 'bloomberg',
colors: { background: '#000000', ui_text: foreground, ui_accent: '#ff8000' }
})
const cssVar = (name: string) => window.document.documentElement.style.getPropertyValue(name)
describe('ThemeProvider ← backend skin sync', () => {
beforeEach(() => {
window.localStorage.clear()
__resetBackendSkinSync()
})
afterEach(cleanup)
it('applies an activated backend skin', () => {
render(
<ThemeProvider>
<div />
</ThemeProvider>
)
act(() => ingestBackendSkin(bloomberg('#ff9f0a'), { apply: true }))
expect(cssVar('--theme-foreground')).toBe('#ff9f0a')
expect(cssVar('--theme-background-seed')).toBe('#000000')
})
it('repaints an in-place edit of the ACTIVE skin (same name, new palette)', () => {
render(
<ThemeProvider>
<div />
</ThemeProvider>
)
act(() => ingestBackendSkin(bloomberg('#ff9f0a'), { apply: true }))
expect(cssVar('--theme-foreground')).toBe('#ff9f0a')
// Recolor the same skin file. The same-name apply guard correctly no-ops
// (protects manual desktop picks), so the repaint must come from the
// registry update reaching the active theme derivation.
act(() => ingestBackendSkin(bloomberg('#ff2d95'), { apply: true }))
expect(cssVar('--theme-foreground')).toBe('#ff2d95')
})
it('does not repaint an edit to an INACTIVE skin', () => {
render(
<ThemeProvider>
<div />
</ThemeProvider>
)
act(() => ingestBackendSkin(bloomberg('#ff9f0a'), { apply: true }))
// A different skin registered without apply (e.g. seeded on reconnect)
// must not touch the painted theme.
act(() =>
ingestBackendSkin({ name: 'forest', colors: { background: '#001100', ui_text: '#66ff66' } }, { apply: false })
)
expect(cssVar('--theme-foreground')).toBe('#ff9f0a')
})
})
describe('ThemeProvider highlight preview', () => {
beforeEach(() => {
window.localStorage.clear()
__resetBackendSkinSync()
})
afterEach(cleanup)
// Read the live context so the tests drive the real provider, not a mock.
let ctx: ReturnType<typeof useTheme>
function Probe() {
ctx = useTheme()
return null
}
const renderProbe = () =>
render(
<ThemeProvider>
<Probe />
</ThemeProvider>
)
it('paints the previewed theme without persisting it', () => {
renderProbe()
const committed = ctx.themeName
act(() => ctx.previewTheme('everforest', 'dark'))
expect(cssVar('--theme-foreground')).toBe(everforestTheme.darkColors!.foreground)
// The commit surface does not change. The context name and the stored
// preference keep their values.
expect(ctx.themeName).toBe(committed)
expect(skinPref.resolve('default')).toBe(committed)
})
it('clearThemePreview repaints the committed appearance', () => {
renderProbe()
act(() => ctx.previewTheme('everforest', 'dark'))
expect(cssVar('--theme-foreground')).toBe(everforestTheme.darkColors!.foreground)
act(() => ctx.clearThemePreview())
expect(cssVar('--theme-foreground')).not.toBe(everforestTheme.darkColors!.foreground)
})
it('a commit replaces the preview and persists', () => {
renderProbe()
act(() => ctx.previewTheme('everforest', 'dark'))
act(() => ctx.setTheme('mono'))
expect(ctx.themeName).toBe('mono')
expect(skinPref.resolve('default')).toBe('mono')
expect(cssVar('--theme-foreground')).not.toBe(everforestTheme.darkColors!.foreground)
})
it('ignores a preview of an unknown theme', () => {
renderProbe()
const painted = cssVar('--theme-foreground')
act(() => ctx.previewTheme('does-not-exist', 'dark'))
expect(cssVar('--theme-foreground')).toBe(painted)
})
})
+533
View File
@@ -0,0 +1,533 @@
/**
* Desktop theme context.
*
* Applies the active theme as CSS custom properties on :root so every
* Tailwind utility that references a color or font-family token picks up
* the change automatically.
*
* Mode (light/dark/system) controls brightness; skin controls accent.
* 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 { $registryVersion } from '@/contrib/registry'
import { matchesQuery, useMediaQuery } from '@/hooks/use-media-query'
import { persistString, persistStringRecord, storedString, storedStringRecord } from '@/lib/storage'
import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile'
import { setAppearance } from '@/store/translucency'
import { $accentOverride } from './accent-override'
import { $backendThemes, $pendingSkinApply } from './backend-sync'
import { ensureContrast, harmonize, hexToRgb, mix, readableOn } from './color'
import { BUILTIN_THEME_LIST, DEFAULT_SKIN_NAME, DEFAULT_TYPOGRAPHY, nousTheme } from './presets'
import { retintTheme } from './retint'
import type { DesktopTheme, DesktopThemeColors } from './types'
import { $userThemes, listAllThemes, 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'
// Skins that no longer exist. A profile still pointing at one falls back to
// DEFAULT_SKIN_NAME rather than painting a name nothing resolves.
const RETIRED_SKINS = new Set(['nous-light', 'default', 'gold'])
export type ThemeMode = 'light' | 'dark' | 'system'
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
/**
* A stored mode, or `system` when there isn't one.
*
* A fresh profile follows the OS. Defaulting to `light` meant someone whose
* desktop is dark got a white window on first launch and had to go find the
* setting — and with per-appearance translucency it also handed them light's
* much heavier tint, tuned for a bright desktop they don't have.
*/
const normalizeMode = (value: string | null): ThemeMode =>
value === 'light' || value === 'dark' || value === 'system' ? value : 'system'
// ─── 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)
/** Everything a peer window could change that this one has to repaint for. */
const APPEARANCE_KEYS = new Set([SKIN_KEY, PROFILE_SKINS_KEY, MODE_KEY, PROFILE_MODES_KEY])
// 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)
// ─── 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 synthLightColors(seed: DesktopTheme): DesktopThemeColors {
const accent = seed.colors.ring || seed.colors.primary
const soft = mix('#ffffff', accent, 0.1)
const softer = mix('#ffffff', accent, 0.06)
const border = mix('#ececef', accent, 0.14)
const midground = seed.colors.midground ?? accent
return {
background: '#ffffff',
foreground: '#161616',
card: '#ffffff',
cardForeground: '#161616',
muted: softer,
mutedForeground: mix('#6b6b70', accent, 0.16),
popover: '#ffffff',
popoverForeground: '#161616',
primary: accent,
primaryForeground: readableOn(accent),
secondary: soft,
secondaryForeground: mix('#2a2a2a', accent, 0.34),
accent: soft,
accentForeground: mix('#2a2a2a', accent, 0.34),
border,
input: mix('#e2e2e6', accent, 0.18),
ring: accent,
midground,
midgroundForeground: readableOn(midground),
destructive: '#b94a3a',
destructiveForeground: '#ffffff',
sidebarBackground: mix('#fafafa', accent, 0.05),
sidebarBorder: border,
userBubble: soft,
userBubbleBorder: border
}
}
/** 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
if (mode === 'dark') {
return seed.darkColors ?? seed.colors
}
return seed.darkColors ? seed.colors : synthLightColors(seed)
}
function deriveTheme(skinName: string, mode: 'light' | 'dark'): DesktopTheme {
const seed = resolveTheme(skinName) ?? nousTheme
return {
...seed,
name: `${skinName}-${mode}`,
label: `${seed.label} ${mode === 'light' ? 'Light' : 'Dark'}`,
description: `${seed.label} ${mode} palette`,
colors: getBaseColors(skinName, mode)
}
}
/**
* Some palettes intentionally keep a bright background even when
* `mode === 'dark'`, so we shouldn't apply the `.dark` class. Decide from
* the actual background luminance.
*/
function renderedModeFor(colors: DesktopThemeColors, mode: 'light' | 'dark'): 'light' | 'dark' {
const rgb = hexToRgb(colors.background)
if (!rgb) {
return mode
}
const [r, g, b] = rgb.map(v => v / 255)
return 0.2126 * r + 0.7152 * g + 0.0722 * b > 0.5 ? 'light' : 'dark'
}
// ─── CSS application ────────────────────────────────────────────────────────
// Per-mode mix knobs. Light/dark fallbacks live in styles.css `:root` /
// `:root.dark`; setting them inline keeps active-skin overrides surviving
// the boot-time paint.
// styles.css --theme-neutral-chrome — keep in sync.
const NEUTRAL_CHROME = { light: '#f3f3f3', dark: '#0d0d0e' } as const
// The one foreground --dt-primary-solid is built to carry. Fixed rather than
// measured: the surface is derived to suit IT, not the other way round.
// styles.css --dt-primary-solid-foreground fallback — keep in sync.
const PRIMARY_SOLID_FOREGROUND = '#fcfcfc'
const chromeBackground = (background: string, isDark: boolean) =>
mix(background, NEUTRAL_CHROME[isDark ? 'dark' : 'light'], isDark ? 0.26 : 0.08)
const mixesFor = (isDark: boolean): Record<string, string> => ({
'--theme-mix-chrome': isDark ? '74%' : '92%',
'--theme-mix-sidebar': '100%',
'--theme-mix-card': isDark ? '38%' : '22%',
'--theme-mix-elevated': isDark ? '46%' : '28%',
'--theme-mix-bubble': isDark ? '46%' : '0%'
})
function applyTheme(theme: DesktopTheme, mode: 'light' | 'dark') {
if (typeof document === 'undefined') {
return
}
const root = document.documentElement
const c = theme.colors
const typo = { ...DEFAULT_TYPOGRAPHY, ...nousTheme.typography, ...theme.typography }
const rendered = renderedModeFor(c, mode)
const isDark = rendered === 'dark'
const midground = c.midground ?? c.ring
const skinName = theme.name.endsWith(`-${mode}`) ? theme.name.slice(0, -mode.length - 1) : theme.name
root.style.setProperty('color-scheme', rendered)
root.dataset.hermesTheme = skinName
root.dataset.hermesMode = rendered
root.classList.toggle('dark', isDark)
// Translucency is tuned per appearance, and "appearance" means the palette
// actually painted — a skin that keeps a bright surface in "dark" wants
// light's tint. Publishing from here covers the boot paint too, so the very
// first resolved state main is told about is already the right one.
setAppearance(rendered)
// Brand seeds feed every glass + shadcn token via `color-mix()` in styles.css.
const seeds: Record<string, string> = {
'--theme-foreground': c.foreground,
'--theme-primary': c.primary,
'--theme-secondary': c.secondary,
'--theme-accent-soft': c.accent,
'--theme-midground': midground,
'--theme-warm': c.primary,
'--theme-background-seed': c.background,
'--theme-sidebar-seed': c.sidebarBackground ?? c.background,
'--theme-card-seed': c.card,
'--theme-elevated-seed': c.popover,
'--theme-bubble-seed': c.userBubble ?? c.popover
}
// shadcn/Tailwind tokens that aren't derived from the seed chain.
const palette: Record<string, string> = {
'--dt-primary-foreground': c.primaryForeground,
'--dt-secondary-foreground': c.secondaryForeground,
'--dt-accent-foreground': c.accentForeground,
'--dt-border': c.border,
'--dt-input': c.input,
'--dt-ring': c.ring,
'--dt-muted': c.muted,
'--dt-midground-foreground': c.midgroundForeground ?? readableOn(midground),
// A LOUD fill of the brand colour, for the rare surface that has to read as
// the app speaking rather than as chrome. `primary` alone can't do that job:
// a pale accent (imported VS Code themes love a pastel pink) is a perfectly
// valid primary, and the honest `primaryForeground` for it is near-black —
// so the "loud" surface comes out a pastel card with dark text on it,
// whispering. Deepening the hue until the LIGHT foreground clears AA keeps
// one look across every theme: no-ops on an accent that is already deep,
// and only ever darkens, so the hue survives.
'--dt-primary-solid': ensureContrast(c.primary, PRIMARY_SOLID_FOREGROUND, 4.5),
'--dt-primary-solid-foreground': PRIMARY_SOLID_FOREGROUND,
'--dt-composer-ring': c.composerRing ?? midground,
'--dt-destructive': c.destructive,
'--dt-destructive-foreground': c.destructiveForeground,
'--dt-sidebar-border': c.sidebarBorder ?? c.border,
'--dt-user-bubble-border': c.userBubbleBorder ?? c.border,
// Semantic success, bent toward the accent so it settles into the palette
// instead of clashing with it. A green accent barely moves it (see
// `harmonize`); a blue one turns the sidebar's finished dots teal rather
// than leaving eight emerald spots fighting the theme.
'--ui-success': harmonize('#10b981', midground, 0.25),
'--dt-font-sans': typo.fontSans,
'--dt-font-mono': typo.fontMono,
'--noise-opacity-mul': isDark ? 'calc(0.04 / 0.21)' : 'calc(0.34 / 0.21)'
}
for (const [k, v] of Object.entries({ ...seeds, ...mixesFor(isDark), ...palette })) {
root.style.setProperty(k, v)
}
const chromeBg = chromeBackground(c.background, isDark)
window.hermesDesktop?.setTitleBarTheme?.({
background: chromeBg,
foreground: c.foreground
})
// Raw (non-JSON) keys read by the inline pre-paint script in index.html —
// they let a brand-new window paint the themed background on its very first
// frame, before this module has even loaded.
try {
window.localStorage.setItem('hermes-boot-background', chromeBg)
window.localStorage.setItem('hermes-boot-color-scheme', rendered)
} catch {
// Storage may be unavailable (private mode / quota); the inline script
// falls back to prefers-color-scheme.
}
if (typo.fontUrl && !INJECTED_FONT_URLS.has(typo.fontUrl)) {
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = typo.fontUrl
link.dataset.hermesThemeFont = 'true'
document.head.appendChild(link)
INJECTED_FONT_URLS.add(typo.fontUrl)
}
}
// Pin Electron's nativeTheme to the app's mode so the NATIVE window chrome
// (macOS vibrancy material, titlebar, pre-paint background) matches the app
// theme instead of the OS appearance. An explicit light/dark pick is forced;
// 'system' stays 'system' so prefers-color-scheme keeps tracking the OS.
const syncNativeTheme = (pref: ThemeMode, rendered: 'light' | 'dark') =>
window.hermesDesktop?.setNativeTheme?.(pref === 'system' ? 'system' : rendered)
// 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.
if (typeof window !== 'undefined') {
const profile = readBootProfileKey()
const pref = modePref.resolve(profile)
const resolved = resolveMode(pref)
const theme = deriveTheme(skinPref.resolve(profile), resolved)
applyTheme(theme, resolved)
syncNativeTheme(pref, renderedModeFor(theme.colors, resolved))
}
// ─── Context ────────────────────────────────────────────────────────────────
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
/**
* Paint a theme with an explicit light/dark, without persistence. This is
* the highlight preview for the palette. A commit (`setTheme`) or
* `clearThemePreview` repaints the committed appearance.
*/
previewTheme: (name: string, mode: 'light' | 'dark') => void
clearThemePreview: () => void
}
const SKIN_LIST = BUILTIN_THEME_LIST.map(({ name, label, description }) => ({ name, label, description }))
const ThemeContext = createContext<ThemeContextValue>({
theme: nousTheme,
themeName: DEFAULT_SKIN_NAME,
mode: 'light',
resolvedMode: 'light',
renderedMode: 'light',
availableThemes: SKIN_LIST,
setTheme: () => {},
setMode: () => {},
previewTheme: () => {},
clearThemePreview: () => {}
})
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 + registry-contributed themes. Reactive so an
// 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(
() =>
listAllThemes().map(({ name, label, description }) => ({
name,
label,
description
})),
// userThemes + backendThemes + registryVersion ARE listAllThemes' reactivity.
// eslint-disable-next-line react-hooks/exhaustive-deps
[userThemes, backendThemes, registryVersion]
)
const [themeName, setThemeNameState] = useState(() =>
typeof window === 'undefined' ? DEFAULT_SKIN_NAME : skinPref.resolve(readBootProfileKey())
)
const [mode, setModeState] = useState<ThemeMode>(() =>
typeof window === 'undefined' ? 'system' : modePref.resolve(readBootProfileKey())
)
// 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])
// Appearance is per-profile localStorage, and every desktop window is another
// renderer on the same origin — so a switch made in the HUD (or any peer
// window) only ever repainted the window it was made in. `storage` fires in
// the OTHER windows, which is exactly the set that needs to catch up.
useEffect(() => {
const onStorage = (event: StorageEvent) => {
if (event.key && !APPEARANCE_KEYS.has(event.key)) {
return
}
const live = normalizeProfileKey($activeGatewayProfile.get())
setThemeNameState(skinPref.resolve(live))
setModeState(modePref.resolve(live))
}
window.addEventListener('storage', onStorage)
return () => window.removeEventListener('storage', onStorage)
}, [])
const systemDark = useMediaQuery('(prefers-color-scheme: dark)')
const resolvedMode = resolveMode(mode, systemDark)
// Transient highlight preview (palette theme picker). It is never
// persisted. A commit or an explicit clear returns the paint to the
// committed appearance.
const [preview, setPreview] = useState<{ name: string; mode: 'light' | 'dark' } | null>(null)
const paintedName = preview ? preview.name : themeName
const paintedMode = preview ? preview.mode : resolvedMode
const activeTheme = useMemo(
() => deriveTheme(paintedName, paintedMode),
// deriveTheme resolves its seed through the merged registry, so the theme
// stores are its reactivity too — an in-place palette edit of the ACTIVE
// skin (live theme authoring) must repaint, not just a name switch.
// eslint-disable-next-line react-hooks/exhaustive-deps
[paintedName, paintedMode, userThemes, backendThemes, registryVersion]
)
// Dev-only accent retint. `null` (always, in production) returns the theme
// untouched, and retintTheme is an identity when the seed already matches —
// so the picker costs nothing until it's actually moved off the default.
const accentOverride = useStore($accentOverride)
const paintedTheme = useMemo(
() => (accentOverride === null ? activeTheme : retintTheme(activeTheme, accentOverride)),
[activeTheme, accentOverride]
)
// What actually gets painted (matches the `.dark` class applyTheme toggles).
const renderedMode = useMemo(() => renderedModeFor(paintedTheme.colors, paintedMode), [paintedTheme, paintedMode])
useEffect(() => applyTheme(paintedTheme, paintedMode), [paintedTheme, paintedMode])
// Keep the native window appearance pinned to the app theme (vibrancy
// material, titlebar, new-window pre-paint background).
useEffect(() => syncNativeTheme(mode, renderedMode), [mode, renderedMode])
// 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)
setPreview(null)
setThemeNameState(next)
skinPref.assign(liveProfile(), next)
}, [])
const setMode = useCallback((next: ThemeMode) => {
setPreview(null)
setModeState(next)
modePref.assign(liveProfile(), next)
}, [])
const previewTheme = useCallback((name: string, previewMode: 'light' | 'dark') => {
setPreview(resolveTheme(name) ? { name, mode: previewMode } : null)
}, [])
const clearThemePreview = useCallback(() => setPreview(null), [])
// 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.
const value = useMemo<ThemeContextValue>(
() => ({
theme: paintedTheme,
themeName,
mode,
resolvedMode,
renderedMode,
availableThemes,
setTheme,
setMode,
previewTheme,
clearThemePreview
}),
[
paintedTheme,
themeName,
mode,
resolvedMode,
renderedMode,
availableThemes,
setTheme,
setMode,
previewTheme,
clearThemePreview
]
)
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
}
export const useTheme = (): ThemeContextValue => useContext(ThemeContext)
+7
View File
@@ -0,0 +1,7 @@
export { ingestBackendSkin } from './backend-sync'
export { ThemeProvider, useTheme } from './context'
export { BUILTIN_THEME_LIST, BUILTIN_THEMES, DEFAULT_SKIN_NAME } from './presets'
export { requestTheme } from './request'
export { skinToDesktopTheme } from './skin'
export type { DesktopTheme, DesktopThemeColors, DesktopThemeTypography } from './types'
export type { HermesSkin } from '@hermes/shared/skin'
+132
View File
@@ -0,0 +1,132 @@
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
)
})
})
+92
View File
@@ -0,0 +1,92 @@
/**
* Install desktop themes from external sources.
*
* The heavy lifting (network + .vsix unzip) lives in the Electron main process
* (`electron/vscode-marketplace.ts`), 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))
}
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest'
import {
BUILTIN_THEME_LIST,
BUILTIN_THEMES,
DEFAULT_SKIN_NAME,
DEFAULT_TYPOGRAPHY,
EMOJI_FALLBACK,
nousAltTheme
} from './presets'
// #40364: none of the UI text/mono fonts carry emoji glyphs, so every font
// stack must end with a color-emoji fallback or emoji render as tofu on
// platforms whose default font lacks them (e.g. Linux).
describe('theme typography emoji fallback (#40364)', () => {
const stacks: Array<[string, string]> = [
['DEFAULT_TYPOGRAPHY.fontSans', DEFAULT_TYPOGRAPHY.fontSans],
['DEFAULT_TYPOGRAPHY.fontMono', DEFAULT_TYPOGRAPHY.fontMono],
// A theme may override only fontMono (fontSans then falls back to the
// default, which already carries the emoji stack), so skip undefined.
...BUILTIN_THEME_LIST.flatMap(theme =>
(
[
[`${theme.name}.fontSans`, theme.typography?.fontSans],
[`${theme.name}.fontMono`, theme.typography?.fontMono]
] as Array<[string, string | undefined]>
).filter((entry): entry is [string, string] => typeof entry[1] === 'string')
)
]
it.each(stacks)('%s includes a color-emoji font', (_label, stack) => {
expect(stack).toMatch(/Apple Color Emoji|Segoe UI Emoji|Noto Color Emoji|(^|,\s*)emoji\b/)
})
it('EMOJI_FALLBACK lists the major platform emoji fonts', () => {
expect(EMOJI_FALLBACK).toContain('Apple Color Emoji')
expect(EMOJI_FALLBACK).toContain('Segoe UI Emoji')
expect(EMOJI_FALLBACK).toContain('Noto Color Emoji')
})
})
// The pre-GitHub Nous palette stays available alongside AITURK's default.
describe('nous-alt is the retired Nous, not the default', () => {
it('is registered under its own name and leaves AITURK as the default', () => {
expect(DEFAULT_SKIN_NAME).toBe('aiturk')
expect(BUILTIN_THEMES['nous-alt']).toBe(nousAltTheme)
expect(BUILTIN_THEMES.nous).not.toBe(nousAltTheme)
expect(nousAltTheme.darkColors?.background).toBe('#0D2F86')
expect(BUILTIN_THEMES.nous.darkColors?.background).not.toBe(nousAltTheme.darkColors?.background)
})
})
+887
View File
@@ -0,0 +1,887 @@
/**
* Built-in desktop themes. Names match the CLI skins / dashboard presets.
* Add new themes here — no code changes needed elsewhere.
*
* The palette-bearing skins (nous, catppuccin, everforest, solarized) are forks
* of their VS Code originals, converted by `buildThemeFromMarketplace` (see
* ./install.ts) from the extensions below — the same path a Marketplace import
* takes, so each is identical to installing the extension by hand and costs the
* user neither the download nor the install step.
*
* nous ← github.github-vscode-theme (Light Default / Dark Default)
* catppuccin ← Catppuccin.catppuccin-vsc (Latte / Mocha)
* everforest ← sainnhe.everforest
* solarized ← ryanolsonx.solarized
*
* Re-convert marketplace forks from the upstream extension rather than
* hand-editing hexes; hand edits drift from upstream silently and can't be
* re-derived. `nous-alt` is first-party — do not re-derive it from GitHub.
*/
import type { DesktopTheme, DesktopThemeTypography } from './types'
// Color-emoji fonts to append to every stack as a last resort. None of the UI
// text/mono fonts carry emoji glyphs, so without this emoji render as tofu
// boxes on platforms whose default text font lacks them (e.g. Linux/#40364).
// Covers macOS, Windows, Linux, plus the `emoji` generic for anything else.
export const EMOJI_FALLBACK = '"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji", emoji'
const SYSTEM_SANS =
'"Segoe WPC", "Segoe UI", -apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display", system-ui, sans-serif, ' +
EMOJI_FALLBACK
const SYSTEM_MONO = 'Menlo, Monaco, "SF Mono", "Courier Prime", monospace, ' + EMOJI_FALLBACK
export const DEFAULT_TYPOGRAPHY: DesktopThemeTypography = { fontSans: SYSTEM_SANS, fontMono: SYSTEM_MONO }
/**
* Nous — the canonical Hermes desktop identity, forked from the GitHub VS Code
* theme (github.github-vscode-theme). Light is GitHub Light Default, dark is
* GitHub Dark Default, both converted through the same path a Marketplace
* install takes, so the palette here is byte-identical to importing the
* extension yourself.
*
* Typography stays Hermes's own: a VS Code theme carries no font opinion, and
* these are the stacks every skin has been rendering with.
*/
/**
* GitHub — the upstream palette, unmodified.
*
* `nous` is a fork of this with its own accent, so shipping both keeps the
* original available on its own terms instead of only existing as the thing
* nous diverged from. Everything but the accent family is identical between
* them; separate presets are what let nous's accent move without silently
* redefining what "GitHub" means.
*/
export const githubTheme: DesktopTheme = {
name: 'github',
label: 'GitHub',
description: 'GitHub Light Default and Dark Default',
colors: {
background: '#ffffff',
foreground: '#1f2328',
card: '#f6f8fa',
cardForeground: '#1f2328',
muted: '#f6f6f6',
mutedForeground: '#656d76',
popover: '#ffffff',
popoverForeground: '#1f2328',
primary: '#196d31',
primaryForeground: '#ffffff',
secondary: '#dfebe2',
secondaryForeground: '#1f2328',
accent: '#e3ede6',
accentForeground: '#1f2328',
border: '#d0d7de',
input: '#ffffff',
ring: '#196d31',
midground: '#196d31',
midgroundForeground: '#ffffff',
composerRing: '#196d31',
destructive: '#cf222e',
destructiveForeground: '#ffffff',
sidebarBackground: '#f6f8fa',
sidebarBorder: '#d0d7de',
userBubble: '#dbe7e2',
userBubbleBorder: '#d0d7de'
},
darkColors: {
background: '#0d1117',
foreground: '#e6edf3',
card: '#010409',
cardForeground: '#e6edf3',
muted: '#1a1e24',
mutedForeground: '#7d8590',
popover: '#161b22',
popoverForeground: '#e6edf3',
primary: '#4f9e5e',
primaryForeground: '#ffffff',
secondary: '#1f382b',
secondaryForeground: '#e6edf3',
accent: '#192a24',
accentForeground: '#e6edf3',
border: '#30363d',
input: '#0d1117',
ring: '#4f9e5e',
midground: '#4f9e5e',
midgroundForeground: '#ffffff',
composerRing: '#4f9e5e',
destructive: '#f85149',
destructiveForeground: '#ffffff',
sidebarBackground: '#010409',
sidebarBorder: '#30363d',
userBubble: '#0f2018',
userBubbleBorder: '#30363d'
},
typography: {
fontSans: SYSTEM_SANS,
fontMono: SYSTEM_MONO,
fontUrl: 'https://fonts.googleapis.com/css2?family=Courier+Prime:wght@400;700&display=swap'
},
terminal: {
foreground: '#1f2328',
black: '#24292f',
red: '#cf222e',
green: '#116329',
yellow: '#4d2d00',
blue: '#0969da',
magenta: '#8250df',
cyan: '#1b7c83',
white: '#6e7781',
brightBlack: '#57606a',
brightRed: '#a40e26',
brightGreen: '#1a7f37',
brightYellow: '#633c01',
brightBlue: '#218bff',
brightMagenta: '#a475f9',
brightCyan: '#3192aa',
brightWhite: '#8c959f'
},
darkTerminal: {
foreground: '#e6edf3',
black: '#484f58',
red: '#ff7b72',
green: '#3fb950',
yellow: '#d29922',
blue: '#58a6ff',
magenta: '#bc8cff',
cyan: '#39c5cf',
white: '#b1bac4',
brightBlack: '#6e7681',
brightRed: '#ffa198',
brightGreen: '#56d364',
brightYellow: '#e3b341',
brightBlue: '#79c0ff',
brightMagenta: '#d2a8ff',
brightCyan: '#56d4dd',
brightWhite: '#ffffff'
}
}
/** Catppuccin — Latte in light, Mocha in dark (Catppuccin.catppuccin-vsc). */
/**
* Nous — the canonical Hermes desktop identity: GitHub's chrome carrying Nous
* blue. Forked from github.github-vscode-theme (Light Default / Dark Default),
* with only the accent family re-seeded; every neutral is upstream's.
*
* Two seeds, one blue. `#0053FD` is the brand color and reads at 5.4:1 on the
* light sidebar, but only 3.6:1 on the near-black dark one — so dark carries
* `#4a84fe`, the same hue (263°) lifted to clear AA at 5.9:1. The soft
* surfaces below are mixed from those seeds in OKLab, which is what keeps a
* saturated blue from drifting violet on its way to white.
*/
export const nousTheme: DesktopTheme = {
name: 'nous',
label: 'Nous',
description: 'GitHub chrome, Nous blue accent',
colors: {
background: '#ffffff',
foreground: '#1f2328',
card: '#f6f8fa',
cardForeground: '#1f2328',
muted: '#f6f6f6',
mutedForeground: '#656d76',
popover: '#ffffff',
popoverForeground: '#1f2328',
primary: '#0053fd',
primaryForeground: '#ffffff',
secondary: '#deeaff',
secondaryForeground: '#1f2328',
accent: '#e3edff',
accentForeground: '#1f2328',
border: '#d0d7de',
input: '#ffffff',
ring: '#0053fd',
midground: '#0053fd',
midgroundForeground: '#ffffff',
composerRing: '#0053fd',
destructive: '#cf222e',
destructiveForeground: '#ffffff',
sidebarBackground: '#f6f8fa',
sidebarBorder: '#d0d7de',
userBubble: '#dae7fd',
userBubbleBorder: '#d0d7de'
},
darkColors: {
background: '#0d1117',
foreground: '#e6edf3',
card: '#010409',
cardForeground: '#e6edf3',
muted: '#1a1e24',
mutedForeground: '#7d8590',
popover: '#161b22',
popoverForeground: '#e6edf3',
primary: '#4a84fe',
primaryForeground: '#161616',
secondary: '#1d2e4f',
secondaryForeground: '#e6edf3',
accent: '#17243a',
accentForeground: '#e6edf3',
border: '#30363d',
input: '#0d1117',
ring: '#4a84fe',
midground: '#4a84fe',
midgroundForeground: '#161616',
composerRing: '#4a84fe',
destructive: '#f85149',
destructiveForeground: '#ffffff',
sidebarBackground: '#010409',
sidebarBorder: '#30363d',
userBubble: '#07162c',
userBubbleBorder: '#30363d'
},
typography: {
fontSans: SYSTEM_SANS,
fontMono: SYSTEM_MONO,
fontUrl: 'https://fonts.googleapis.com/css2?family=Courier+Prime:wght@400;700&display=swap'
},
terminal: {
foreground: '#1f2328',
black: '#24292f',
red: '#cf222e',
green: '#116329',
yellow: '#4d2d00',
blue: '#0969da',
magenta: '#8250df',
cyan: '#1b7c83',
white: '#6e7781',
brightBlack: '#57606a',
brightRed: '#a40e26',
brightGreen: '#1a7f37',
brightYellow: '#633c01',
brightBlue: '#218bff',
brightMagenta: '#a475f9',
brightCyan: '#3192aa',
brightWhite: '#8c959f'
},
darkTerminal: {
foreground: '#e6edf3',
black: '#484f58',
red: '#ff7b72',
green: '#3fb950',
yellow: '#d29922',
blue: '#58a6ff',
magenta: '#bc8cff',
cyan: '#39c5cf',
white: '#b1bac4',
brightBlack: '#6e7681',
brightRed: '#ffa198',
brightGreen: '#56d364',
brightYellow: '#e3b341',
brightBlue: '#79c0ff',
brightMagenta: '#d2a8ff',
brightCyan: '#56d4dd',
brightWhite: '#ffffff'
}
}
/** Catppuccin — Latte in light, Mocha in dark (Catppuccin.catppuccin-vsc). */
export const catppuccinTheme: DesktopTheme = {
name: 'catppuccin',
label: 'Catppuccin',
description: 'Soothing pastels — Latte and Mocha',
colors: {
background: '#eff1f5',
foreground: '#4c4f69',
card: '#e6e9ef',
cardForeground: '#4c4f69',
muted: '#e8ebef',
mutedForeground: '#4c4f69',
popover: '#e6e9ef',
popoverForeground: '#4c4f69',
primary: '#6d2ebf',
primaryForeground: '#ffffff',
secondary: '#ddd6ed',
secondaryForeground: '#4c4f69',
accent: '#dfdaef',
accentForeground: '#4c4f69',
border: '#acb0be',
input: '#ccd0da',
ring: '#6d2ebf',
midground: '#6d2ebf',
midgroundForeground: '#ffffff',
composerRing: '#6d2ebf',
destructive: '#d20f39',
destructiveForeground: '#ffffff',
sidebarBackground: '#e6e9ef',
sidebarBorder: '#acb0be',
userBubble: '#d7d3e9',
userBubbleBorder: '#acb0be'
},
darkColors: {
background: '#1e1e2e',
foreground: '#cdd6f4',
card: '#181825',
cardForeground: '#cdd6f4',
muted: '#29293a',
mutedForeground: '#cdd6f4',
popover: '#181825',
popoverForeground: '#cdd6f4',
primary: '#cba6f7',
primaryForeground: '#ffffff',
secondary: '#4e4466',
secondaryForeground: '#cdd6f4',
accent: '#3d3652',
accentForeground: '#cdd6f4',
border: '#585b70',
input: '#313244',
ring: '#cba6f7',
midground: '#cba6f7',
midgroundForeground: '#ffffff',
composerRing: '#cba6f7',
destructive: '#f38ba8',
destructiveForeground: '#ffffff',
sidebarBackground: '#181825',
sidebarBorder: '#585b70',
userBubble: '#38324b',
userBubbleBorder: '#585b70'
},
terminal: {
foreground: '#4c4f69',
cursor: '#dc8a78',
selectionBackground: '#acb0be',
black: '#5c5f77',
red: '#d20f39',
green: '#40a02b',
yellow: '#df8e1d',
blue: '#1e66f5',
magenta: '#ea76cb',
cyan: '#179299',
white: '#acb0be',
brightBlack: '#6c6f85',
brightRed: '#de293e',
brightGreen: '#49af3d',
brightYellow: '#eea02d',
brightBlue: '#456eff',
brightMagenta: '#fe85d8',
brightCyan: '#2d9fa8',
brightWhite: '#bcc0cc'
},
darkTerminal: {
foreground: '#cdd6f4',
cursor: '#f5e0dc',
selectionBackground: '#585b70',
black: '#45475a',
red: '#f38ba8',
green: '#a6e3a1',
yellow: '#f9e2af',
blue: '#89b4fa',
magenta: '#f5c2e7',
cyan: '#94e2d5',
white: '#a6adc8',
brightBlack: '#585b70',
brightRed: '#f37799',
brightGreen: '#89d88b',
brightYellow: '#ebd391',
brightBlue: '#74a8fc',
brightMagenta: '#f2aede',
brightCyan: '#6bd7ca',
brightWhite: '#bac2de'
}
}
/** Everforest — warm, low-contrast forest greens (sainnhe.everforest). */
export const everforestTheme: DesktopTheme = {
name: 'everforest',
label: 'Everforest',
description: 'Warm, low-contrast forest greens',
colors: {
background: '#fdf6e3',
foreground: '#5c6a72',
card: '#fdf6e3',
cardForeground: '#5c6a72',
muted: '#f7f0de',
mutedForeground: '#939f91',
popover: '#fdf6e3',
popoverForeground: '#5c6a72',
primary: '#586b35',
primaryForeground: '#ffffff',
secondary: '#e6e3cb',
secondaryForeground: '#5c6a72',
accent: '#e9e5ce',
accentForeground: '#5c6a72',
border: '#fdf6e3',
input: '#fdf6e3',
ring: '#586b35',
midground: '#586b35',
midgroundForeground: '#ffffff',
composerRing: '#586b35',
destructive: '#f1706f',
destructiveForeground: '#ffffff',
sidebarBackground: '#fdf6e3',
sidebarBorder: '#fdf6e3',
userBubble: '#e9e5ce',
userBubbleBorder: '#fdf6e3'
},
darkColors: {
background: '#2d353b',
foreground: '#d3c6aa',
card: '#2d353b',
cardForeground: '#d3c6aa',
muted: '#373e42',
mutedForeground: '#859289',
popover: '#2d353b',
popoverForeground: '#d3c6aa',
primary: '#a7c080',
primaryForeground: '#ffffff',
secondary: '#4f5c4e',
secondaryForeground: '#d3c6aa',
accent: '#434e47',
accentForeground: '#d3c6aa',
border: '#2d353b',
input: '#2d353b',
ring: '#a7c080',
midground: '#a7c080',
midgroundForeground: '#ffffff',
composerRing: '#a7c080',
destructive: '#da6362',
destructiveForeground: '#ffffff',
sidebarBackground: '#2d353b',
sidebarBorder: '#2d353b',
userBubble: '#434e47',
userBubbleBorder: '#2d353b'
},
terminal: {
foreground: '#5c6a72',
cursor: '#5c6a72',
black: '#5c6a72',
red: '#f85552',
green: '#8da101',
yellow: '#dfa000',
blue: '#3a94c5',
magenta: '#df69ba',
cyan: '#35a77c',
white: '#939f91',
brightBlack: '#5c6a72',
brightRed: '#f85552',
brightGreen: '#8da101',
brightYellow: '#dfa000',
brightBlue: '#3a94c5',
brightMagenta: '#df69ba',
brightCyan: '#35a77c',
brightWhite: '#f4f0d9'
},
darkTerminal: {
foreground: '#d3c6aa',
cursor: '#d3c6aa',
black: '#343f44',
red: '#e67e80',
green: '#a7c080',
yellow: '#dbbc7f',
blue: '#7fbbb3',
magenta: '#d699b6',
cyan: '#83c092',
white: '#d3c6aa',
brightBlack: '#859289',
brightRed: '#e67e80',
brightGreen: '#a7c080',
brightYellow: '#dbbc7f',
brightBlue: '#7fbbb3',
brightMagenta: '#d699b6',
brightCyan: '#83c092',
brightWhite: '#d3c6aa'
}
}
/** Solarized — Ethan Schoonover's fixed-contrast pair (ryanolsonx.solarized). */
export const solarizedTheme: DesktopTheme = {
name: 'solarized',
label: 'Solarized',
description: 'Fixed-contrast light and dark',
colors: {
background: '#fdf6e3',
foreground: '#1f1f1f',
card: '#d3cbb7',
cardForeground: '#1f1f1f',
muted: '#f4eddb',
mutedForeground: '#9ca8a6',
popover: '#eee8d5',
popoverForeground: '#1f1f1f',
primary: '#675e34',
primaryForeground: '#ffffff',
secondary: '#e8e1cb',
secondaryForeground: '#1f1f1f',
accent: '#ebe4ce',
accentForeground: '#1f1f1f',
border: '#ddd6c1',
input: '#ddd6c1',
ring: '#675e34',
midground: '#675e34',
midgroundForeground: '#ffffff',
composerRing: '#675e34',
destructive: '#e25563',
destructiveForeground: '#ffffff',
sidebarBackground: '#eee8d5',
sidebarBorder: '#ddd6c1',
userBubble: '#c6bea7',
userBubbleBorder: '#ddd6c1'
},
darkColors: {
background: '#002b36',
foreground: '#839496',
card: '#002b36',
cardForeground: '#839496',
muted: '#08313c',
mutedForeground: '#586e75',
popover: '#001f26',
popoverForeground: '#839496',
primary: '#6ea1c4',
primaryForeground: '#ffffff',
secondary: '#1f4c5e',
secondaryForeground: '#839496',
accent: '#144050',
accentForeground: '#839496',
border: '#234751',
input: '#073642',
ring: '#6ea1c4',
midground: '#6ea1c4',
midgroundForeground: '#ffffff',
composerRing: '#6ea1c4',
destructive: '#e35957',
destructiveForeground: '#ffffff',
sidebarBackground: '#001f26',
sidebarBorder: '#234751',
userBubble: '#144050',
userBubbleBorder: '#234751'
},
terminal: {
foreground: '#657b83',
black: '#657b83',
red: '#dc322f',
green: '#859900',
yellow: '#b58900',
blue: '#268bd2',
magenta: '#d33682',
cyan: '#2aa198',
white: '#eee8d5',
brightBlack: '#657b83',
brightRed: '#cb4b16',
brightGreen: '#859900',
brightYellow: '#657b83',
brightBlue: '#839496',
brightMagenta: '#6c71c4',
brightCyan: '#93a1a1',
brightWhite: '#eee8d5'
},
darkTerminal: {
foreground: '#839496',
cursor: '#ffffff',
selectionBackground: '#ffffff40',
black: '#14181d',
red: '#dc322f',
green: '#859900',
yellow: '#b58900',
blue: '#268bd2',
magenta: '#d33682',
cyan: '#2aa198',
white: '#e5e5e5',
brightBlack: '#676767',
brightRed: '#dc322f',
brightGreen: '#859900',
brightYellow: '#b58900',
brightBlue: '#268bd2',
brightMagenta: '#d33682',
brightCyan: '#2aa198',
brightWhite: '#e5e5e5'
}
}
const NOUS_ALT_BLUE = '#0053FD'
const NOUS_ALT_NAVY = '#1540B1'
const NOUS_ALT_CREAM = '#FFE6CB'
const nousAltTint = (pct: number) => `color-mix(in srgb, ${NOUS_ALT_BLUE} ${pct}%, #FFFFFF)`
const nousAltTintTransparent = (pct: number) => `color-mix(in srgb, ${NOUS_ALT_BLUE} ${pct}%, transparent)`
/**
* Nous Alt — the hand-authored Nous from before the GitHub fork. Light is
* glass neutrals with brand blue; dark is cream on mission-blue.
*/
export const nousAltTheme: DesktopTheme = {
name: 'nous-alt',
label: 'Nous Alt',
description: 'Glass neutrals, cream on mission-blue',
colors: {
background: '#F8FAFF',
foreground: '#17171A',
card: '#FFFFFF',
cardForeground: '#17171A',
muted: nousAltTint(5),
mutedForeground: '#666678',
popover: '#FFFFFF',
popoverForeground: '#17171A',
primary: NOUS_ALT_BLUE,
primaryForeground: '#FCFCFC',
secondary: nousAltTint(7),
secondaryForeground: '#242432',
accent: nousAltTint(10),
accentForeground: '#202030',
border: nousAltTintTransparent(22),
input: nousAltTintTransparent(30),
ring: NOUS_ALT_BLUE,
midground: NOUS_ALT_BLUE,
composerRing: NOUS_ALT_BLUE,
destructive: '#C72E4D',
destructiveForeground: '#FFFFFF',
sidebarBackground: '#F3F7FF',
sidebarBorder: nousAltTintTransparent(18),
userBubble: nousAltTint(6),
userBubbleBorder: nousAltTintTransparent(24)
},
darkColors: {
background: '#0D2F86',
foreground: NOUS_ALT_CREAM,
card: '#12378F',
cardForeground: NOUS_ALT_CREAM,
muted: '#183F9A',
mutedForeground: '#B5C7F3',
popover: '#123A96',
popoverForeground: NOUS_ALT_CREAM,
primary: NOUS_ALT_CREAM,
primaryForeground: '#0D2F86',
secondary: '#1B45A4',
secondaryForeground: '#E0E8FF',
accent: NOUS_ALT_NAVY,
accentForeground: '#F0F4FF',
border: '#3158AD',
input: '#0B2566',
ring: NOUS_ALT_CREAM,
midground: NOUS_ALT_BLUE,
composerRing: NOUS_ALT_CREAM,
destructive: '#C0473A',
destructiveForeground: '#FEF2F2',
sidebarBackground: '#09286F',
sidebarBorder: '#234A9C',
userBubble: '#143B91',
userBubbleBorder: '#3A63BD'
},
typography: {
fontSans: SYSTEM_SANS,
fontMono: SYSTEM_MONO,
fontUrl: 'https://fonts.googleapis.com/css2?family=Courier+Prime:wght@400;700&display=swap'
}
}
/**
* Midnight — deep blue-violet, near-monotone. Dark only: it has no light
* palette because the whole idea is the dark end of the spectrum.
*/
export const midnightTheme: DesktopTheme = {
name: 'midnight',
label: 'Midnight',
description: 'Deep blue-violet with cool accents',
colors: {
background: '#08081c',
foreground: '#ddd6ff',
card: '#0d0d28',
cardForeground: '#ddd6ff',
muted: '#13133a',
mutedForeground: '#7c7ab0',
popover: '#0f0f2e',
popoverForeground: '#ddd6ff',
primary: '#ddd6ff',
primaryForeground: '#08081c',
secondary: '#1a1a4a',
secondaryForeground: '#c4bff0',
accent: '#1a1a44',
accentForeground: '#d0c8ff',
border: '#1e1e52',
input: '#1e1e52',
ring: '#8b80e8',
midground: '#8b80e8',
destructive: '#b03060',
destructiveForeground: '#fef2f2',
sidebarBackground: '#06061a',
sidebarBorder: '#12123a',
userBubble: '#14143a',
userBubbleBorder: '#242466'
},
typography: {
fontMono: `"JetBrains Mono", ${SYSTEM_MONO}`,
fontUrl: 'https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap'
}
}
export const emberTheme: DesktopTheme = {
name: 'ember',
label: 'Ember',
description: 'Warm crimson and bronze — forge vibes',
colors: {
background: '#160800',
foreground: '#ffd8b0',
card: '#1e0e04',
cardForeground: '#ffd8b0',
muted: '#2a1408',
mutedForeground: '#aa7a56',
popover: '#221008',
popoverForeground: '#ffd8b0',
primary: '#ffd8b0',
primaryForeground: '#160800',
secondary: '#341800',
secondaryForeground: '#f0c090',
accent: '#301600',
accentForeground: '#e8c080',
border: '#3a1c08',
input: '#3a1c08',
ring: '#d97316',
midground: '#d97316',
destructive: '#c43010',
destructiveForeground: '#fef2f2',
sidebarBackground: '#100600',
sidebarBorder: '#2a1004',
userBubble: '#2a1000',
userBubbleBorder: '#4a2010'
},
typography: {
fontMono: `"IBM Plex Mono", ${SYSTEM_MONO}`,
fontUrl: 'https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;700&display=swap'
}
}
/** Clean grayscale. Matches the CLI mono skin and dashboard mono theme. */
export const monoTheme: DesktopTheme = {
name: 'mono',
label: 'Mono',
description: 'Clean grayscale — minimal and focused',
colors: {
background: '#0e0e0e',
foreground: '#eaeaea',
card: '#141414',
cardForeground: '#eaeaea',
muted: '#1e1e1e',
mutedForeground: '#808080',
popover: '#181818',
popoverForeground: '#eaeaea',
primary: '#eaeaea',
primaryForeground: '#0e0e0e',
secondary: '#262626',
secondaryForeground: '#c8c8c8',
accent: '#222222',
accentForeground: '#d8d8d8',
border: '#2a2a2a',
input: '#2a2a2a',
ring: '#9a9a9a',
midground: '#9a9a9a',
destructive: '#a84040',
destructiveForeground: '#fef2f2',
sidebarBackground: '#0a0a0a',
sidebarBorder: '#202020',
userBubble: '#1a1a1a',
userBubbleBorder: '#363636'
}
}
/** Neon green on black. Matches the CLI cyberpunk skin and dashboard theme. */
export const cyberpunkTheme: DesktopTheme = {
name: 'cyberpunk',
label: 'Cyberpunk',
description: 'Neon green on black — matrix terminal',
colors: {
background: '#000a00',
foreground: '#00ff41',
card: '#001200',
cardForeground: '#00ff41',
muted: '#001a00',
mutedForeground: '#1a8a30',
popover: '#001000',
popoverForeground: '#00ff41',
primary: '#00ff41',
primaryForeground: '#000a00',
secondary: '#002800',
secondaryForeground: '#00cc34',
accent: '#002000',
accentForeground: '#00e038',
border: '#003000',
input: '#003000',
ring: '#00ff41',
midground: '#00ff41',
destructive: '#ff003c',
destructiveForeground: '#000a00',
sidebarBackground: '#000600',
sidebarBorder: '#001800',
userBubble: '#001400',
userBubbleBorder: '#004800'
},
typography: {
fontMono: `"Courier New", Courier, monospace, ${EMOJI_FALLBACK}`,
fontSans: `"Courier New", Courier, monospace, ${EMOJI_FALLBACK}`
}
}
/** Cool slate blue for developers. Matches the CLI slate skin. */
export const slateTheme: DesktopTheme = {
name: 'slate',
label: 'Slate',
description: 'Cool slate blue — focused developer theme',
colors: {
background: '#0d1117',
foreground: '#c9d1d9',
card: '#161b22',
cardForeground: '#c9d1d9',
muted: '#21262d',
mutedForeground: '#8b949e',
popover: '#1c2128',
popoverForeground: '#c9d1d9',
primary: '#c9d1d9',
primaryForeground: '#0d1117',
secondary: '#2a3038',
secondaryForeground: '#adb5bf',
accent: '#1e2530',
accentForeground: '#c0c8d0',
border: '#30363d',
input: '#30363d',
ring: '#58a6ff',
midground: '#58a6ff',
destructive: '#cf4848',
destructiveForeground: '#fef2f2',
sidebarBackground: '#090d13',
sidebarBorder: '#1c2228',
userBubble: '#1e2a38',
userBubbleBorder: '#2e4060'
},
typography: {
fontMono: `"JetBrains Mono", ${SYSTEM_MONO}`
}
}
/** AITURK's own accent; the upstream GitHub and Nous presets remain available. */
export const aiturkTheme: DesktopTheme = {
...githubTheme,
name: 'aiturk',
label: 'AITURK',
description: 'TurkServis',
colors: {
...githubTheme.colors,
primary: '#007e74', primaryForeground: '#ffffff', ring: '#007e74',
midground: '#007e74', midgroundForeground: '#ffffff', composerRing: '#007e74',
accent: '#e5f7f4', secondary: '#e5f7f4'
},
darkColors: {
...githubTheme.darkColors!,
primary: '#24d4c6', primaryForeground: '#081525', ring: '#24d4c6',
midground: '#24d4c6', midgroundForeground: '#081525', composerRing: '#24d4c6',
accent: '#113638', secondary: '#113638'
},
typography: { ...DEFAULT_TYPOGRAPHY, fontMono: `"JetBrains Mono", ${SYSTEM_MONO}` }
}
export const BUILTIN_THEMES: Record<string, DesktopTheme> = {
aiturk: aiturkTheme,
nous: nousTheme,
github: githubTheme,
catppuccin: catppuccinTheme,
everforest: everforestTheme,
solarized: solarizedTheme,
'nous-alt': nousAltTheme,
midnight: midnightTheme,
ember: emberTheme,
mono: monoTheme,
slate: slateTheme,
cyberpunk: cyberpunkTheme
}
export const BUILTIN_THEME_LIST = Object.values(BUILTIN_THEMES)
/** Skin used when nothing is persisted or the persisted name is retired. */
export const DEFAULT_SKIN_NAME = 'aiturk'
@@ -0,0 +1,66 @@
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: 'catppuccin',
junk: 'nope'
},
{ name: 'mode', pref: modePref as unknown as Pref, fallback: 'system', a: 'dark', b: 'light', 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)
})
})
// A fresh profile follows the OS. This defaulted to `light`, so a dark-mode
// desktop got a white window on first launch — and, once translucency became
// per-appearance, light's much heavier tint along with it. Main already
// defaulted its own themeSource to 'system', so the two disagreed at boot.
describe('a profile that has never chosen a mode', () => {
beforeEach(() => window.localStorage.clear())
it('follows the OS rather than forcing light', () => {
expect(modePref.resolve('default')).toBe('system')
expect(modePref.resolve('work')).toBe('system')
})
it('still honours an explicit choice', () => {
modePref.assign('default', 'light')
expect(modePref.resolve('default')).toBe('light')
})
})
+94
View File
@@ -0,0 +1,94 @@
import { act, cleanup, render } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { registry } from '@/contrib/registry'
import { __resetBackendSkinSync } from './backend-sync'
import { skinPref, ThemeProvider, useTheme } from './context'
import { midnightTheme } from './presets'
import { requestTheme } from './request'
import type { DesktopTheme } from './types'
import { THEMES_AREA } from './user-themes'
const cssVar = (name: string) => window.document.documentElement.style.getPropertyValue(name)
describe('requestTheme', () => {
let ctx: ReturnType<typeof useTheme>
function Probe() {
ctx = useTheme()
return null
}
const renderProbe = () =>
render(
<ThemeProvider>
<Probe />
</ThemeProvider>
)
beforeEach(() => {
window.localStorage.clear()
__resetBackendSkinSync()
})
afterEach(cleanup)
it('switches the painted theme from outside React', () => {
renderProbe()
let accepted = false
act(() => {
accepted = requestTheme('mono')
})
expect(accepted).toBe(true)
expect(ctx.themeName).toBe('mono')
})
// The imperative door must land in the same place the React one does, or a
// plugin-driven switch would evaporate on the next profile read.
it('persists per profile like a manual pick', () => {
renderProbe()
act(() => void requestTheme('midnight'))
expect(skinPref.resolve('default')).toBe('midnight')
})
it('refuses a name that does not resolve, leaving the appearance untouched', () => {
renderProbe()
act(() => void requestTheme('mono'))
const painted = cssVar('--theme-foreground')
let accepted = true
act(() => {
accepted = requestTheme('a-theme-nobody-installed')
})
expect(accepted).toBe(false)
expect(ctx.themeName).toBe('mono')
expect(cssVar('--theme-foreground')).toBe(painted)
})
// The whole plugin loop: contribute a palette through THEMES_AREA, then
// activate it on an event with no component in scope.
it('activates a theme contributed through the registry', () => {
const zeus: DesktopTheme = { ...midnightTheme, description: 'Zeus', label: 'Zeus', name: 'zeus' }
const dispose = registry.register({ area: THEMES_AREA, data: zeus, id: 'zeus' })
renderProbe()
let accepted = false
act(() => {
accepted = requestTheme('zeus')
})
expect(accepted).toBe(true)
expect(ctx.themeName).toBe('zeus')
dispose()
})
})
+34
View File
@@ -0,0 +1,34 @@
/**
* Imperative theme switching, for callers with no component to hang a hook on.
*
* `useTheme().setTheme` is the React door and covers every in-app surface, but
* not every switch happens during a render. The backend announces a `/skin`
* change over the gateway, and a plugin may want to repaint when a connection
* comes up or a background event fires. Those callers write a name to
* `$pendingSkinApply`; the ThemeProvider drains it through `setTheme`, so an
* imperative switch normalizes, persists per profile, and drops any preview
* exactly like a manual pick — one policy, one owner.
*/
import { $pendingSkinApply } from './backend-sync'
import { resolveTheme } from './user-themes'
/**
* Ask for a theme switch from outside React. Returns whether the name resolved.
*
* An unknown name is refused rather than coerced. `setTheme` falls back to the
* default skin for anything it can't resolve, which is right for a person
* picking from a list but wrong for code reacting to an event: a gateway naming
* a theme the user never installed would silently reset their appearance
* instead of leaving it alone. The boolean doubles as the availability check,
* so a caller needs no separate lookup to know it got what it asked for.
*/
export function requestTheme(name: string): boolean {
if (!resolveTheme(name)) {
return false
}
$pendingSkinApply.set(name)
return true
}
+235
View File
@@ -0,0 +1,235 @@
import { describe, expect, it } from 'vitest'
import { contrastRatio, hexToOklch, withHue } from './color'
import { githubTheme, nousTheme } from './presets'
import { retintTheme, themeHue } from './retint'
import type { DesktopThemeColors } from './types'
const HUES = [0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330]
// A retint seed for each hue, at the authored accent's lightness/chroma.
const seedAt = (hue: number) => withHue(nousTheme.colors.primary, hue)
const NOUS_BLUE = '#0053FD'
describe('themeHue', () => {
it('reads the accent hue that ships', () => {
// Nous blue. Both palettes sit at this hue — light seeds `#0053fd` and dark
// `#4a84fe`, the same blue at two lightnesses, which is what lets one pick
// serve both appearances.
expect(themeHue(nousTheme)).toBe(263)
expect(Math.round(hexToOklch(nousTheme.darkColors!.primary)!.h)).toBe(263)
})
it('reads the upstream GitHub green from the unforked theme', () => {
// `github` keeps the original accent, so the fork's blue can move freely
// without redefining what upstream looks like.
expect(themeHue(githubTheme)).toBe(148)
expect(Math.round(hexToOklch(githubTheme.darkColors!.primary)!.h)).toBe(148)
})
})
// The two seeds are the whole point of the fork, and both are load-bearing:
// `#0053FD` is the brand color and passes on the light sidebar, but only 3.6:1
// on the near-black dark one — so dark carries a lifted twin rather than the
// literal brand hex. Anything that re-derives these must keep both legible.
describe('the shipped nous accents', () => {
const cases = [
{ appearance: 'light', colors: nousTheme.colors, seed: '#0053fd' },
{ appearance: 'dark', colors: nousTheme.darkColors!, seed: '#4a84fe' }
] as const
it.each(cases)('$appearance seeds every accent slot from $seed', ({ colors, seed }) => {
for (const key of ['primary', 'ring', 'midground', 'composerRing'] as const) {
expect(colors[key]).toBe(seed)
}
})
it.each(cases)('$appearance clears AA on its own sidebar', ({ colors, seed }) => {
expect(contrastRatio(seed, colors.sidebarBackground!)).toBeGreaterThanOrEqual(4.5)
})
it.each(cases)('$appearance keeps text on the accent readable', ({ colors, seed }) => {
expect(contrastRatio(seed, colors.primaryForeground)).toBeGreaterThanOrEqual(4.5)
})
it('is one blue at two lightnesses, not two blues', () => {
const light = hexToOklch(nousTheme.colors.primary)!
const dark = hexToOklch(nousTheme.darkColors!.primary)!
expect(Math.abs(light.h - dark.h)).toBeLessThan(2)
expect(dark.l).toBeGreaterThan(light.l)
})
it('leaves GitHub’s neutrals in place — only the accent family is forked', () => {
for (const key of ['background', 'foreground', 'card', 'border', 'sidebarBackground'] as const) {
expect(nousTheme.colors[key]).toBe(githubTheme.colors[key])
expect(nousTheme.darkColors![key]).toBe(githubTheme.darkColors![key])
}
})
})
describe('retintTheme', () => {
// The load-bearing property: the mix ratios in retint.ts must be the same
// ones that produced the shipped palette. If they drift, retinting at the
// theme's OWN hue stops being a no-op — and this catches it.
it('is an identity at the theme’s own accent', () => {
const same = retintTheme(nousTheme, nousTheme.colors.primary)
expect(same.colors).toEqual(nousTheme.colors)
expect(same.darkColors).toEqual(nousTheme.darkColors)
})
it('moves every accent-family slot, in both modes', () => {
const rose = retintTheme(nousTheme, seedAt(350))
for (const mode of ['colors', 'darkColors'] as const) {
const before = nousTheme[mode]!
const after = rose[mode]!
for (const key of [
'primary',
'ring',
'midground',
'composerRing',
'accent',
'secondary',
'userBubble'
] as const) {
expect(after[key], `${mode}.${key}`).not.toBe(before[key])
}
}
})
it('keeps the four seed slots locked together', () => {
const teal = retintTheme(nousTheme, seedAt(195)).colors
expect(teal.ring).toBe(teal.primary)
expect(teal.midground).toBe(teal.primary)
expect(teal.composerRing).toBe(teal.primary)
})
it('leaves the chrome alone', () => {
// The neutrals are the app's surface, not its brand. A hue knob that also
// swung these would make every theme a monochrome wash.
const violet = retintTheme(nousTheme, seedAt(285))
for (const key of ['background', 'foreground', 'card', 'border', 'muted', 'mutedForeground'] as const) {
expect(violet.colors[key], key).toBe(nousTheme.colors[key])
expect(violet.darkColors![key], `dark ${key}`).toBe(nousTheme.darkColors![key])
}
})
it('holds perceived lightness and chroma while only the hue moves', () => {
const base = hexToOklch(nousTheme.colors.primary)!
for (const hue of HUES) {
const seed = hexToOklch(retintTheme(nousTheme, seedAt(hue)).colors.primary)!
expect(Math.abs(seed.l - base.l), `L at ${hue}`).toBeLessThan(0.02)
// Chroma can only be REDUCED, and only where sRGB can't show it.
expect(seed.c, `C at ${hue}`).toBeLessThanOrEqual(base.c + 0.005)
}
})
// The accent labels the sidebar in small uppercase text, so a hue that
// collapses against it ships invisible section headers.
it('keeps the accent readable on the sidebar at every hue', () => {
for (const hue of HUES) {
const t = retintTheme(nousTheme, seedAt(hue))
for (const mode of ['colors', 'darkColors'] as const) {
const c = t[mode] as DesktopThemeColors
const ratio = contrastRatio(c.primary, c.sidebarBackground ?? c.background)
expect(ratio, `${mode} @ ${hue}°`).toBeGreaterThanOrEqual(4.5)
}
}
})
it('re-picks the foreground that sits on the accent', () => {
for (const hue of HUES) {
const c = retintTheme(nousTheme, seedAt(hue)).colors
expect(contrastRatio(c.primary, c.primaryForeground), `on-accent @ ${hue}°`).toBeGreaterThanOrEqual(4.5)
}
})
it('accepts any hex form and ignores junk', () => {
expect(retintTheme(nousTheme, '#0053FD').colors.primary).toBe(retintTheme(nousTheme, '0053fd').colors.primary)
// A half-typed hex from a text input must not blow up the theme.
expect(retintTheme(nousTheme, '#00').colors).toEqual(nousTheme.colors)
expect(retintTheme(nousTheme, 'nonsense').colors).toEqual(nousTheme.colors)
})
// The real motivating case: Nous blue is legible on GitHub's light sidebar
// (5.4:1) but NOT its dark one (3.6:1), so dark has to adapt or ship
// invisible section headers.
describe('a seed that only works in one mode', () => {
const blue = retintTheme(nousTheme, NOUS_BLUE)
it('keeps the picked color where it already passes', () => {
expect(blue.colors.primary.toLowerCase()).toBe(NOUS_BLUE.toLowerCase())
})
it('lightens it for the mode where it does not', () => {
const dark = blue.darkColors!.primary
expect(dark.toLowerCase()).not.toBe(NOUS_BLUE.toLowerCase())
expect(contrastRatio(dark, blue.darkColors!.sidebarBackground!)).toBeGreaterThanOrEqual(4.5)
})
it('adapts by lightness, holding the hue — so it still reads as the brand', () => {
const picked = hexToOklch(NOUS_BLUE)!
const adapted = hexToOklch(blue.darkColors!.primary)!
expect(Math.abs(adapted.h - picked.h)).toBeLessThan(3)
expect(adapted.l).toBeGreaterThan(picked.l)
// Chroma may only fall because sRGB cannot SHOW that colorfulness at the
// higher lightness — `#0053FD`'s C 0.26 is out of gamut once lightened,
// and the clamp trades it away rather than shifting the hue. What must
// not happen is the mix-toward-white collapse, which would also drag the
// hue and leave a pastel; staying well clear of half the original chroma
// is the line between "same blue, lighter" and "washed out".
expect(adapted.c).toBeGreaterThan(picked.c * 0.55)
})
})
it('does not brand a slot that never tracked the accent', () => {
// mono's ring is a neutral gray on purpose.
const neutralRing = {
...nousTheme,
colors: { ...nousTheme.colors, ring: '#9a9a9a' },
darkColors: undefined
}
expect(retintTheme(neutralRing, '#8250df').colors.ring).toBe('#9a9a9a')
})
// A theme may shade its accent across slots rather than repeating one hex —
// midnight runs a `#8b80e8` ring under a `#ddd6ff` primary. Both are the
// same violet; matching on exact equality left the ring behind and produced
// a half-retinted theme.
describe('a theme whose accent slots are shades of each other', () => {
const shaded = {
...nousTheme,
colors: { ...nousTheme.colors, primary: '#ddd6ff', ring: '#8b80e8', midground: '#8b80e8' },
darkColors: undefined
}
it('moves every slot in the family', () => {
const teal = retintTheme(shaded, '#0f9b8e')
expect(teal.colors.ring).not.toBe('#8b80e8')
expect(Math.abs(hexToOklch(teal.colors.ring)!.h - hexToOklch('#0f9b8e')!.h)).toBeLessThan(3)
})
it('keeps each slot at its own lightness, rather than flattening them', () => {
const teal = retintTheme(shaded, '#0f9b8e')
const ring = hexToOklch(teal.colors.ring)!
expect(ring.l).toBeCloseTo(hexToOklch('#8b80e8')!.l, 1)
expect(ring.l).not.toBeCloseTo(hexToOklch(teal.colors.primary)!.l, 1)
})
})
})
+205
View File
@@ -0,0 +1,205 @@
/**
* Accent retinting — re-seed a theme's accent family from one color.
*
* A palette's accent is not one color, it's a family: the seed plus the soft
* surfaces mixed from it. In `nous` that's 7 slots per mode —
* `primary`/`ring`/`midground`/`composerRing` carry the accent itself, and
* `accent`/`secondary`/`userBubble` are mixes of it toward the background. The
* carrying slots need not be the same hex: a theme may shade them (see
* `tracksAccent`), and a re-seed keeps that shading.
*
* So retinting is not "replace some hexes": it's move the seed, then re-derive
* the family with the exact ratios the VS Code converter used. Those ratios
* live in ACCENT_MIX below and are duplicated from `vscode.ts` on purpose —
* they're verified to reproduce the shipped palette bit-for-bit (see
* retint.test.ts), which is what lets a retint with the original seed be a
* no-op.
*
* ONE color in, both modes out. A single hex can't serve both appearances
* literally — Nous blue `#0053FD` is 5.4:1 on GitHub's light sidebar but only
* 3.6:1 on its dark one, i.e. unreadable section headers. Dark therefore gets
* the same hue and chroma at whatever lightness clears AA, which keeps the
* brand color recognizably itself instead of washing it toward white.
*
* The chrome is deliberately untouched. GitHub's neutrals are a cool blue-gray
* (hue ~210°) that reads as the app's surface, not as brand — rotating it with
* the accent turns every theme into a monochrome wash.
*/
import {
ensureContrastOklch,
hexToOklch,
hueDelta,
mixOklab,
normalizeHex,
type Oklch,
oklchToHex,
readableOn
} from './color'
import type { DesktopTheme, DesktopThemeColors } from './types'
/** Small uppercase sidebar labels paint in the accent, so it must clear AA. */
const ACCENT_MIN_CONTRAST = 4.5
/**
* How far a slot's hue may sit from the primary and still count as the same
* accent family. Wide enough for a theme that shades its ring (midnight runs
* 286° against a 293° primary), narrow enough that a second, deliberately
* different accent is left alone.
*/
const ACCENT_HUE_TOLERANCE = 25
/** Below this chroma a colour is a grey and its hue is noise. */
const ACCENT_MIN_CHROMA = 0.03
/** Slots that carry the accent itself, rather than a surface mixed from it. */
const ACCENT_SEED_KEYS = ['primary', 'ring', 'midground', 'composerRing'] as const
/**
* Slots mixed from the seed, as ratios per mode — the converter's own numbers.
* `userBubble` mixes the CARD toward the accent (not the reverse).
*/
const ACCENT_MIX = {
accent: { light: 0.88, dark: 0.82 },
secondary: { light: 0.86, dark: 0.72 },
userBubble: { light: 0.12, dark: 0.18 }
} as const
/**
* Whether a slot belongs to the accent family, and so should follow a re-seed.
*
* Exact equality with `primary` is too strict: midnight's ring is `#8b80e8`
* against a `#ddd6ff` primary — a deeper shade of the same violet, obviously
* accent, and leaving it behind half-retints the theme. Hue is the honest
* test. A slot within ACCENT_HUE_TOLERANCE of the primary is the same colour
* family at another lightness; anything further is a deliberate choice (mono's
* neutral grey ring) and stays put.
*
* Near-greys are decided by chroma, not hue: below ACCENT_MIN_CHROMA a hue
* reading is noise, so a grey tracks the accent only when the accent is itself
* a grey — which is how mono's own primary keeps working.
*/
function tracksAccent(slot: Oklch, primary: Oklch): boolean {
if (slot.c < ACCENT_MIN_CHROMA) {
return primary.c < ACCENT_MIN_CHROMA
}
return Math.abs(hueDelta(primary.h, slot.h)) <= ACCENT_HUE_TOLERANCE
}
/** Re-derive the accent family of one palette around `seed`. */
function retintColors(colors: DesktopThemeColors, seed: string, isDark: boolean): DesktopThemeColors {
const next: DesktopThemeColors = { ...colors }
const pick = (spec: { dark: number; light: number }) => (isDark ? spec.dark : spec.light)
const primary = hexToOklch(colors.primary)
const seedLch = hexToOklch(seed)
for (const key of ACCENT_SEED_KEYS) {
const slot = colors[key]
const lch = slot ? hexToOklch(slot) : null
if (!lch || !primary || !seedLch || !tracksAccent(lch, primary)) {
continue
}
// Take the seed's hue but keep the slot's OWN lightness and chroma, so a
// theme that deliberately runs a deeper ring than its primary keeps that
// relationship instead of being flattened onto one colour.
next[key] = slot === colors.primary ? seed : oklchToHex({ ...lch, h: seedLch.h })
}
// OKLab, not sRGB: blending a saturated blue toward white in gamma space
// drifts it ~8° violet, which is exactly how a clean blue accent produced a
// lavender selection row. See mixOklab.
next.accent = mixOklab(seed, colors.background, pick(ACCENT_MIX.accent))
next.secondary = mixOklab(seed, colors.background, pick(ACCENT_MIX.secondary))
next.userBubble = mixOklab(colors.card, seed, pick(ACCENT_MIX.userBubble))
// Foregrounds that sit ON the accent have to be re-picked: a new seed can
// cross the light/dark readability boundary.
next.primaryForeground = readableOn(seed)
next.midgroundForeground = readableOn(seed)
return next
}
/** The seed a palette should use, adapted to stay legible on its own sidebar. */
function seedFor(colors: DesktopThemeColors, seed: string): string {
return ensureContrastOklch(seed, colors.sidebarBackground ?? colors.background, ACCENT_MIN_CONTRAST)
}
/**
* Carry a light-mode seed across to dark by the theme's OWN lightness offset.
*
* A theme that ships both palettes has already answered "how much lighter does
* this accent get in dark mode" — nous's blues are OKLCH L 0.528 → 0.638 at the
* same hue. Reapplying that delta means a picked color lands in dark exactly
* where the author would have put it, and it makes the round trip exact:
* retinting with the theme's own accent reproduces both palettes byte-for-byte.
*
* Falling back to a bare AA clamp (what this did first) technically passed
* contrast but ignored the author's intent — GitHub green came back as
* `#368548` instead of `#4f9e5e`, a visibly duller dark accent.
*/
function carryToDark(theme: DesktopTheme, seed: string): string {
const light = hexToOklch(theme.colors.primary)
const dark = theme.darkColors ? hexToOklch(theme.darkColors.primary) : null
const picked = hexToOklch(seed)
if (!light || !dark || !picked) {
return seed
}
const shifted = oklchToHex({ ...picked, l: Math.min(1, Math.max(0, picked.l + (dark.l - light.l))) })
return shifted
}
/**
* Return `theme` with its accent family re-seeded from `color` (any CSS hex).
*
* Each mode gets the seed adapted to its own surface, so one pick stays
* readable in both. Passing a theme's existing accent returns it untouched.
* An unparseable color returns the theme unchanged rather than throwing —
* callers are often wiring this straight to a text input.
*/
export function retintTheme(theme: DesktopTheme, color: string): DesktopTheme {
const seed = normalizeHex(color)
if (!seed) {
return theme
}
// Re-seeding with the theme's own accent must return the theme untouched.
// Recomputing dark from it would round-trip through OKLCH and land a bit or
// two off (#4f9e5e → #509e5f) — invisible, but it would mean "no change"
// wasn't actually no change.
if (seed.toLowerCase() === theme.colors.primary.toLowerCase()) {
return theme
}
const light = seedFor(theme.colors, seed)
const retinted: DesktopTheme = {
...theme,
colors: theme.colors.primary === light ? theme.colors : retintColors(theme.colors, light, false)
}
if (theme.darkColors) {
// Carry by the theme's own light→dark offset first, THEN clamp — so the
// author's intent leads and contrast is only a floor, never the target.
const dark = seedFor(theme.darkColors, carryToDark(theme, seed))
retinted.darkColors =
theme.darkColors.primary === dark ? theme.darkColors : retintColors(theme.darkColors, dark, true)
}
return retinted
}
/** The theme's current accent hue in degrees. */
export function themeHue(theme: DesktopTheme): number {
const lch = hexToOklch(theme.colors.primary)
return lch ? Math.round(lch.h) : 0
}
+50
View 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'))
})
})
+117
View File
@@ -0,0 +1,117 @@
/**
* 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
}
}
+101
View File
@@ -0,0 +1,101 @@
/**
* Desktop app theme model.
*
* colors — Tailwind color tokens written directly to CSS vars.
* darkColors — optional hand-tuned dark variant (else `colors` is reused
* unchanged for dark, and a synth pass generates light).
* typography — font families + optional stylesheet URL.
*
* Everything else (layout, sizing, radius, line-height) lives in styles.css.
* Add new themes in `presets.ts` — no other code changes needed.
*/
export interface DesktopThemeColors {
background: string
foreground: string
card: string
cardForeground: string
muted: string
mutedForeground: string
popover: string
popoverForeground: string
primary: string
primaryForeground: string
secondary: string
secondaryForeground: string
accent: string
accentForeground: string
border: string
input: string
/** Generic focus ring — buttons, inputs, etc. */
ring: string
/**
* Brand-accent stroke — focus rings, streaming cursors, active session
* pills, branded scrollbars, text selection. Falls back to `ring`.
* Aliased to the DS `--midground` token.
*/
midground?: string
/** Auto-derived from `midground` luminance when omitted. */
midgroundForeground?: string
/** Composer outline / focus color. Falls back to `midground`. */
composerRing?: string
destructive: string
destructiveForeground: string
sidebarBackground?: string
sidebarBorder?: string
userBubble?: string
userBubbleBorder?: string
}
export interface DesktopThemeTypography {
fontSans: string
fontMono: string
/** Google/Bunny/self-hosted font stylesheet URL. */
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
description: string
/** Light palette (also reused for dark when `darkColors` is omitted). */
colors: DesktopThemeColors
/** 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
}
@@ -0,0 +1,60 @@
import { useCallback } from 'react'
import { useTheme } from './context'
// Retired skin names land on the canonical Nous skin so old muscle memory works.
const ALIASES: Record<string, string> = {
ares: 'ember',
default: 'nous',
gold: 'nous',
hermes: 'nous',
'nous-light': 'nous'
}
export function useSkinCommand() {
const { availableThemes, setTheme, themeName } = useTheme()
return useCallback(
(rawArg: string) => {
const arg = rawArg.trim()
if (!availableThemes.length) {
return 'No desktop themes are available.'
}
const activeIndex = Math.max(
0,
availableThemes.findIndex(t => t.name === themeName)
)
if (!arg || arg === 'next') {
const next = availableThemes[(activeIndex + 1) % availableThemes.length]
setTheme(next.name)
return `Desktop theme switched to ${next.label}.`
}
if (arg === 'list' || arg === 'ls' || arg === 'status') {
const rows = availableThemes.map(t => `${t.name === themeName ? '*' : ' '} ${t.name.padEnd(10)} ${t.label}`)
return ['Desktop themes:', ...rows, '', 'Use /skin <name>, or /skin to cycle.'].join('\n')
}
const normalized = arg.toLowerCase()
const targetName = ALIASES[normalized] || normalized
const target = availableThemes.find(
t => t.name.toLowerCase() === targetName || t.label.toLowerCase() === normalized
)
if (!target) {
return `Unknown desktop theme: ${arg}\nAvailable: ${availableThemes.map(t => t.name).join(', ')}`
}
setTheme(target.name)
return `Desktop theme switched to ${target.label}.`
},
[availableThemes, setTheme, themeName]
)
}
+105
View File
@@ -0,0 +1,105 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { BUILTIN_THEMES, DEFAULT_SKIN_NAME } from './presets'
import {
$marketplaceInstalls,
$userThemes,
installUserTheme,
isUserTheme,
listAllThemes,
marketplaceIdOf,
removeUserTheme,
resolveTheme
} from './user-themes'
import { convertVscodeColorTheme } from './vscode'
const makeTheme = (label: string, source?: string) =>
convertVscodeColorTheme(
{
name: label,
type: 'dark',
colors: { 'editor.background': '#101014', 'editor.foreground': '#fafafa', focusBorder: '#7aa2f7' }
},
source ? { source } : undefined
).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/)
})
})
describe('marketplace install tracking', () => {
beforeEach(() => {
window.localStorage.clear()
$userThemes.set({})
})
it('recovers the extension id only from Marketplace-sourced themes', () => {
expect(marketplaceIdOf(makeTheme('Dracula', 'dracula-theme.theme-dracula'))).toBe('dracula-theme.theme-dracula')
// A pasted (non-Marketplace) import has no extension id to report.
expect(marketplaceIdOf(makeTheme('Pasted'))).toBeNull()
})
it('maps installed Marketplace extension ids to their theme, reactively', () => {
expect($marketplaceInstalls.get().size).toBe(0)
const theme = installUserTheme(makeTheme('Dracula', 'dracula-theme.theme-dracula'))
const map = $marketplaceInstalls.get()
expect(map.get('dracula-theme.theme-dracula')).toEqual(theme)
removeUserTheme(theme.name)
expect($marketplaceInstalls.get().has('dracula-theme.theme-dracula')).toBe(false)
})
it('omits pasted imports (no extension id) from the map', () => {
installUserTheme(makeTheme('Pasted'))
expect($marketplaceInstalls.get().size).toBe(0)
})
})
+193
View File
@@ -0,0 +1,193 @@
/**
* 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, computed } from 'nanostores'
import { registry } from '@/contrib/registry'
import { $backendThemes } from './backend-sync'
import { BUILTIN_THEMES } from './presets'
import type { DesktopTheme, DesktopThemeColors } from './types'
const USER_THEMES_KEY = 'hermes-desktop-user-themes-v1'
// Marketplace imports stamp their description "VS Code · <publisher.extension>"
// (see `convertVscodeColorTheme`). This is the one place that convention is read
// back out, so every install surface can tell what's already installed.
const MARKETPLACE_DESC_PREFIX = 'VS Code · '
// 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])
/** The Marketplace extension id an installed theme came from, or null. */
export function marketplaceIdOf(theme: DesktopTheme): string | null {
return theme.description.startsWith(MARKETPLACE_DESC_PREFIX)
? theme.description.slice(MARKETPLACE_DESC_PREFIX.length)
: null
}
/**
* Reactive `extensionId → installed theme` map, so the install UIs can mark
* Marketplace rows you already have (and re-activate them without re-downloading)
* from one memoized source instead of re-deriving the set on every render.
*/
export const $marketplaceInstalls = computed($userThemes, themes => {
const map = new Map<string, DesktopTheme>()
for (const theme of Object.values(themes)) {
const id = marketplaceIdOf(theme)
if (id) {
map.set(id, theme)
}
}
return map
})
// ── Contributed themes — the `themes` registry area ─────────────────────────
// A data contribution IS a DesktopTheme. Same validity bar as an installed
// theme; built-in names can't be shadowed, and user-installed themes win over
// contributed ones of the same name (the user's explicit install is intent).
export const THEMES_AREA = 'themes'
export function contributedThemes(): DesktopTheme[] {
const seen = new Set<string>()
const out: DesktopTheme[] = []
for (const c of registry.getArea(THEMES_AREA)) {
const theme = c.data as DesktopTheme | undefined
if (theme && isValidTheme(theme) && !BUILTIN_THEMES[theme.name] && !seen.has(theme.name)) {
seen.add(theme.name)
out.push(theme)
}
}
return out
}
/** 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] ??
$backendThemes.get()[name] ??
contributedThemes().find(theme => theme.name === name)
)
}
/** 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 => !shadows(theme)),
...Object.values(backend).filter(theme => !user[theme.name]),
...Object.values(user)
]
}
+171
View File
@@ -0,0 +1,171 @@
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()
})
})
+370
View File
@@ -0,0 +1,370 @@
/**
* 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 } : {})
}
}
}