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:
123
apps/desktop/src/themes/classic-skin.test.tsx
Normal file
123
apps/desktop/src/themes/classic-skin.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user