feat(desktop): offer Classic Hermes gold/navy as an explicit theme pick (#76579)

Desktop users can now choose the CLI's classic gold-on-navy look in
Settings > Appearance (and via /skin classic, gold or hermes).

It ships as a Desktop built-in under its own id `classic`, converted from
the CLI `default` skin colors through the same skinToDesktopTheme path a
backend skin takes. It is deliberately NOT registered under `default`:
stock config ships `display.skin: default`, and backend sync / the boot
fallback adopt that value, so un-retiring `default` (the reverted #130015)
repainted every user who never picked a theme in Classic gold and cached
it. `default` stays retired and aliased to Nous; Classic is only painted
when someone picks it. Desktop never writes its pick to display.skin, so
the pick stays Desktop-local like every other Desktop theme.

Re-lands #130015 (reverted in #130852).

Co-authored-by: 686f6c61 <github@00b.tech>
This commit is contained in:
teknium1
2026-10-01 15:17:50 -07:00
committed by Teknium
parent b5be2a3c8b
commit 77f54c5c35
3 changed files with 156 additions and 3 deletions

View File

@@ -0,0 +1,123 @@
import { act, cleanup, render } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { useTheme } from './context'
// What the gateway and the Electron bridge report for a STOCK config
// (`display.skin: default`): the CLI's classic gold skin, by name `default`.
const stockDefaultSkin = {
name: 'default',
description: 'Classic Hermes — gold and kawaii',
colors: { banner_text: '#FFF8DC', status_bar_bg: '#1a1a2e', ui_accent: '#FFBF00', banner_border: '#CD7F32' }
}
const cssVar = (name: string) => window.document.documentElement.style.getPropertyValue(name)
const paintedSkin = () => window.document.documentElement.dataset.hermesTheme
type ThemeApi = ReturnType<typeof useTheme>
/** A fresh renderer launch: module state reloads, localStorage survives. */
async function launch(localSkin: typeof stockDefaultSkin | null) {
cleanup()
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: localSkin ? { localSkin: { profile: 'default', skin: localSkin } } : {}
})
vi.resetModules()
const [context, sync, skinCommand] = await Promise.all([
import('./context'),
import('./backend-sync'),
import('./use-skin-command')
])
// The pre-mount boot paint, i.e. the very first frame of the launch.
const bootPaint = paintedSkin()
const api: { theme: ThemeApi | null; skin: ((arg: string) => string) | null } = { skin: null, theme: null }
function Probe() {
api.theme = context.useTheme()
api.skin = skinCommand.useSkinCommand()
return null
}
render(
<context.ThemeProvider>
<Probe />
</context.ThemeProvider>
)
// gateway.ready: the connect-time seed of the backend's active skin.
const connect = () => act(() => sync.ingestBackendSkin(stockDefaultSkin, { apply: false }))
return { api, bootPaint, connect }
}
describe('Classic Hermes is an explicit Desktop pick, never inferred from stock config (#76579)', () => {
beforeEach(() => window.localStorage.clear())
afterEach(() => {
cleanup()
window.localStorage.clear()
Reflect.deleteProperty(window, 'hermesDesktop')
vi.resetModules()
})
it.each([
['display.skin: default', stockDefaultSkin],
['display.skin unset', null]
])('a stock user (%s) with no Desktop pick paints Nous on boot, connect and relaunch', async (_label, local) => {
let run = await launch(local)
expect(run.bootPaint).toBe('nous')
run.connect()
run.connect() // reconnect re-seed
expect(run.api.theme?.themeName).toBe('nous')
expect(paintedSkin()).toBe('nous')
run = await launch(local)
expect(run.bootPaint).toBe('nous')
run.connect()
expect(paintedSkin()).toBe('nous')
})
it('a Classic pick paints gold/navy and survives connect, reconnect and relaunch; a later Nous pick sticks', async () => {
let run = await launch(stockDefaultSkin)
expect(run.api.theme?.availableThemes.find(t => t.name === 'classic')?.label).toBe('Classic Hermes')
act(() => run.api.theme?.setTheme('classic'))
expect(paintedSkin()).toBe('classic')
expect(cssVar('--theme-background-seed')).toBe('#1a1a2e')
expect(cssVar('--theme-primary').toLowerCase()).toBe('#ffbf00')
run.connect()
run.connect()
expect(run.api.theme?.themeName).toBe('classic')
run = await launch(stockDefaultSkin)
expect(run.bootPaint).toBe('classic')
run.connect()
expect(paintedSkin()).toBe('classic')
expect(cssVar('--theme-background-seed')).toBe('#1a1a2e')
act(() => run.api.theme?.setTheme('nous'))
run = await launch(stockDefaultSkin)
run.connect()
expect(run.bootPaint).toBe('nous')
expect(paintedSkin()).toBe('nous')
})
it('/skin gold and /skin hermes select Classic; /skin default stays the Desktop default (Nous)', async () => {
const run = await launch(stockDefaultSkin)
act(() => void run.api.skin?.('gold'))
expect(run.api.theme?.themeName).toBe('classic')
act(() => void run.api.skin?.('nous'))
act(() => void run.api.skin?.('hermes'))
expect(run.api.theme?.themeName).toBe('classic')
act(() => void run.api.skin?.('default'))
expect(run.api.theme?.themeName).toBe('nous')
})
})

View File

@@ -20,6 +20,7 @@
import { THEME_PRESET_PALETTES } from '@hermes/shared'
import { skinToDesktopTheme } from './skin'
import type { DesktopTheme, DesktopThemeTypography } from './types'
// Color-emoji fonts to append to every stack as a last resort. None of the UI
@@ -338,6 +339,31 @@ export const nousAltTheme: DesktopTheme = {
}
}
/**
* Classic Hermes — the CLI's stock `default` skin (gold on navy), offered as a
* Desktop pick under its OWN id. Never `default`: stock config ships
* `display.skin: default`, and Desktop reads that as "no pick" (→ nous), so a
* user only ever gets Classic by choosing it (#76579). The palette goes through
* the same converter a backend skin takes, fed the colors it reads from
* `hermes_cli/skin_engine.py`'s `default`, so it paints what the CLI paints.
*/
export const classicTheme: DesktopTheme = {
...(skinToDesktopTheme({
name: 'classic',
colors: {
status_bar_bg: '#1a1a2e',
banner_text: '#FFF8DC',
ui_accent: '#FFBF00',
banner_border: '#CD7F32',
banner_dim: '#B8860B',
ui_error: '#ef5350',
completion_menu_bg: '#1a1a2e'
}
}) as DesktopTheme),
label: 'Classic Hermes',
description: 'Classic Hermes — gold and kawaii'
}
/**
* Midnight — deep blue-violet, near-monotone. Dark only: it has no light
* palette because the whole idea is the dark end of the spectrum.
@@ -402,6 +428,7 @@ export const BUILTIN_THEMES: Record<string, DesktopTheme> = {
everforest: everforestTheme,
solarized: solarizedTheme,
'nous-alt': nousAltTheme,
classic: classicTheme,
midnight: midnightTheme,
ember: emberTheme,
mono: monoTheme,

View File

@@ -2,12 +2,15 @@ import { useCallback } from 'react'
import { useTheme } from './context'
// Retired skin names land on the canonical Nous skin so old muscle memory works.
// Old names land on a shipped theme so muscle memory works. `gold`/`hermes`
// only ever meant the classic gold look, so they reach Classic Hermes; `default`
// stays Nous because it is also the stock config value, which Desktop reads as
// "the Desktop default" everywhere else (boot, backend sync, setTheme).
const ALIASES: Record<string, string> = {
ares: 'ember',
default: 'nous',
gold: 'nous',
hermes: 'nous',
gold: 'classic',
hermes: 'classic',
'nous-light': 'nous'
}