Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
@@ -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'
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'))
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
]
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 } : {})
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user