mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-22 16:25:58 +00:00
400 lines
16 KiB
TypeScript
400 lines
16 KiB
TypeScript
/**
|
|
* 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 { hexToRgb, mix, readableOn } from './color'
|
|
import { BUILTIN_THEME_LIST, BUILTIN_THEMES, DEFAULT_SKIN_NAME, DEFAULT_TYPOGRAPHY, nousTheme } from './presets'
|
|
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'
|
|
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
|
|
|
|
const normalizeMode = (value: string | null): ThemeMode =>
|
|
value === 'light' || value === 'dark' || value === 'system' ? value : 'light'
|
|
|
|
// ─── 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)
|
|
|
|
// 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
|
|
|
|
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)
|
|
|
|
// 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),
|
|
'--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,
|
|
'--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
|
|
}
|
|
|
|
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: () => {}
|
|
})
|
|
|
|
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 registryVersion = useStore($registryVersion)
|
|
|
|
const availableThemes = useMemo(
|
|
() =>
|
|
listAllThemes().map(({ name, label, description }) => ({
|
|
name,
|
|
label,
|
|
description
|
|
})),
|
|
// userThemes + registryVersion ARE listAllThemes' reactivity.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
[userThemes, registryVersion]
|
|
)
|
|
|
|
const [themeName, setThemeNameState] = useState(() =>
|
|
typeof window === 'undefined' ? DEFAULT_SKIN_NAME : skinPref.resolve(readBootProfileKey())
|
|
)
|
|
|
|
const [mode, setModeState] = useState<ThemeMode>(() =>
|
|
typeof window === 'undefined' ? 'light' : 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])
|
|
|
|
const systemDark = useMediaQuery('(prefers-color-scheme: dark)')
|
|
const resolvedMode = resolveMode(mode, systemDark)
|
|
const activeTheme = useMemo(() => deriveTheme(themeName, resolvedMode), [themeName, resolvedMode])
|
|
|
|
// What actually gets painted (matches the `.dark` class applyTheme toggles).
|
|
const renderedMode = useMemo(() => renderedModeFor(activeTheme.colors, resolvedMode), [activeTheme, resolvedMode])
|
|
|
|
useEffect(() => applyTheme(activeTheme, resolvedMode), [activeTheme, resolvedMode])
|
|
|
|
// 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)
|
|
setThemeNameState(next)
|
|
skinPref.assign(liveProfile(), next)
|
|
}, [])
|
|
|
|
const setMode = useCallback((next: ThemeMode) => {
|
|
setModeState(next)
|
|
modePref.assign(liveProfile(), next)
|
|
}, [])
|
|
|
|
// 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: activeTheme, themeName, mode, resolvedMode, renderedMode, availableThemes, setTheme, setMode }),
|
|
[activeTheme, themeName, mode, resolvedMode, renderedMode, availableThemes, setTheme, setMode]
|
|
)
|
|
|
|
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
|
|
}
|
|
|
|
export const useTheme = (): ThemeContextValue => useContext(ThemeContext)
|
|
|
|
/** Sync the desktop skin with the active Hermes backend theme on connect. */
|
|
export function useSyncThemeFromBackend(backendThemeName: string | undefined, setTheme: (name: string) => void) {
|
|
useEffect(() => {
|
|
if (backendThemeName && BUILTIN_THEMES[backendThemeName]) {
|
|
setTheme(backendThemeName)
|
|
}
|
|
}, [backendThemeName, setTheme])
|
|
}
|