diff --git a/apps/desktop/src/themes/classic-skin.test.tsx b/apps/desktop/src/themes/classic-skin.test.tsx new file mode 100644 index 0000000000..bf025c9401 --- /dev/null +++ b/apps/desktop/src/themes/classic-skin.test.tsx @@ -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 + +/** 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( + + + + ) + + // 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') + }) +}) diff --git a/apps/desktop/src/themes/presets.ts b/apps/desktop/src/themes/presets.ts index f4f24f9426..70cdb3313c 100644 --- a/apps/desktop/src/themes/presets.ts +++ b/apps/desktop/src/themes/presets.ts @@ -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 = { everforest: everforestTheme, solarized: solarizedTheme, 'nous-alt': nousAltTheme, + classic: classicTheme, midnight: midnightTheme, ember: emberTheme, mono: monoTheme, diff --git a/apps/desktop/src/themes/use-skin-command.ts b/apps/desktop/src/themes/use-skin-command.ts index 72af193464..72b3f50bd3 100644 --- a/apps/desktop/src/themes/use-skin-command.ts +++ b/apps/desktop/src/themes/use-skin-command.ts @@ -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 = { ares: 'ember', default: 'nous', - gold: 'nous', - hermes: 'nous', + gold: 'classic', + hermes: 'classic', 'nous-light': 'nous' }