362 lines
11 KiB
TypeScript
362 lines
11 KiB
TypeScript
/**
|
|
* Desktop theme context.
|
|
*
|
|
* Applies the active theme as CSS custom properties on :root, making every
|
|
* Tailwind utility that references a color or font-family token pick up the
|
|
* change automatically.
|
|
*
|
|
* Persists mode (light/dark/system) and skin separately. Mode controls
|
|
* brightness; skin controls accent family.
|
|
*/
|
|
|
|
import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
|
|
|
import { matchesQuery, useMediaQuery } from '@/hooks/use-media-query'
|
|
|
|
import {
|
|
BUILTIN_THEME_LIST,
|
|
BUILTIN_THEMES,
|
|
DEFAULT_TYPOGRAPHY,
|
|
defaultTheme,
|
|
nousLightTheme,
|
|
nousTheme
|
|
} from './presets'
|
|
import type { DesktopTheme, DesktopThemeColors } from './types'
|
|
|
|
const STORAGE_KEY = 'hermes-desktop-theme-v2' // Stores skin name.
|
|
const MODE_KEY = 'hermes-desktop-mode-v1'
|
|
const DEFAULT_SKIN = 'default'
|
|
|
|
export type ThemeMode = 'light' | 'dark' | 'system'
|
|
|
|
const INJECTED_FONT_URLS = new Set<string>()
|
|
const SKIN_THEME_LIST = BUILTIN_THEME_LIST.filter(t => t.name !== 'nous-light')
|
|
|
|
const NOUS_FONT_FAMILY_FALLBACK = {
|
|
fontSans: nousTheme.typography?.fontSans ?? DEFAULT_TYPOGRAPHY.fontSans,
|
|
fontMono: nousTheme.typography?.fontMono ?? DEFAULT_TYPOGRAPHY.fontMono
|
|
}
|
|
|
|
function effectiveMode(mode: ThemeMode, systemDark = matchesQuery('(prefers-color-scheme: dark)')): 'light' | 'dark' {
|
|
return mode === 'system' ? (systemDark ? 'dark' : 'light') : mode
|
|
}
|
|
|
|
function normalizeSkin(name: string | null | undefined): string {
|
|
if (!name || name === 'nous-light') {
|
|
return DEFAULT_SKIN
|
|
}
|
|
|
|
return BUILTIN_THEMES[name] && name !== 'nous-light' ? name : DEFAULT_SKIN
|
|
}
|
|
|
|
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]
|
|
}
|
|
|
|
function rgbToHex([r, g, b]: [number, number, number]): string {
|
|
return `#${[r, g, b].map(n => Math.round(n).toString(16).padStart(2, '0')).join('')}`
|
|
}
|
|
|
|
function mix(a: string, b: string, amount: number): string {
|
|
const ar = hexToRgb(a)
|
|
const br = hexToRgb(b)
|
|
|
|
if (!ar || !br) {
|
|
return a
|
|
}
|
|
|
|
return rgbToHex([
|
|
ar[0] + (br[0] - ar[0]) * amount,
|
|
ar[1] + (br[1] - ar[1]) * amount,
|
|
ar[2] + (br[2] - ar[2]) * amount
|
|
])
|
|
}
|
|
|
|
function readableOn(hex: string): string {
|
|
const rgb = hexToRgb(hex)
|
|
|
|
if (!rgb) {
|
|
return '#ffffff'
|
|
}
|
|
|
|
const [r, g, b] = rgb.map(v => {
|
|
const c = v / 255
|
|
|
|
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
|
|
})
|
|
|
|
return 0.2126 * r + 0.7152 * g + 0.0722 * b > 0.58 ? '#161616' : '#ffffff'
|
|
}
|
|
|
|
function fontOnly(theme: DesktopTheme): DesktopTheme['typography'] {
|
|
if (!theme.typography) {
|
|
return undefined
|
|
}
|
|
|
|
const { fontSans, fontMono, fontUrl } = theme.typography
|
|
const typography: DesktopTheme['typography'] = {}
|
|
|
|
if (fontSans) {
|
|
typography.fontSans = fontSans
|
|
}
|
|
|
|
if (fontMono) {
|
|
typography.fontMono = fontMono
|
|
}
|
|
|
|
if (fontUrl) {
|
|
typography.fontUrl = fontUrl
|
|
}
|
|
|
|
return typography
|
|
}
|
|
|
|
function lightColors(seed: DesktopTheme, skinName: string): DesktopThemeColors {
|
|
if (skinName === DEFAULT_SKIN) {
|
|
return nousLightTheme.colors
|
|
}
|
|
|
|
if (skinName === 'nous') {
|
|
return seed.colors
|
|
}
|
|
|
|
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)
|
|
|
|
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,
|
|
destructive: '#b94a3a',
|
|
destructiveForeground: '#ffffff',
|
|
sidebarBackground: mix('#fafafa', accent, 0.05),
|
|
sidebarBorder: border,
|
|
userBubble: soft,
|
|
userBubbleBorder: border
|
|
}
|
|
}
|
|
|
|
function darkColors(seed: DesktopTheme, skinName: string): DesktopThemeColors {
|
|
return skinName === DEFAULT_SKIN ? defaultTheme.colors : seed.colors
|
|
}
|
|
|
|
function deriveTheme(skinName: string, mode: 'light' | 'dark'): DesktopTheme {
|
|
const seed = BUILTIN_THEMES[skinName] ?? defaultTheme
|
|
const isDefault = skinName === DEFAULT_SKIN
|
|
const base = mode === 'light' ? nousLightTheme : defaultTheme
|
|
|
|
return {
|
|
...base,
|
|
name: `${skinName}-${mode}`,
|
|
label: `${isDefault ? 'Hermes' : seed.label} ${mode === 'light' ? 'Light' : 'Dark'}`,
|
|
description: `${seed.label} ${mode} palette`,
|
|
colors: mode === 'light' ? lightColors(seed, skinName) : darkColors(seed, skinName),
|
|
typography: fontOnly(seed)
|
|
}
|
|
}
|
|
|
|
function skinNameFromTheme(theme: DesktopTheme, mode: 'light' | 'dark'): string {
|
|
const suffix = `-${mode}`
|
|
|
|
return theme.name.endsWith(suffix) ? theme.name.slice(0, -suffix.length) : theme.name
|
|
}
|
|
|
|
/**
|
|
* Returns the *rendered* mode for a theme, regardless of what the user has
|
|
* toggled. A skin like Nous keeps a white background even when `mode === 'dark'`,
|
|
* so we shouldn't apply the `.dark` class (which assumes a dark surface and
|
|
* triggers shadow/scrollbar/form-control rules tuned for one). 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)
|
|
const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b
|
|
|
|
return luminance > 0.5 ? 'light' : 'dark'
|
|
}
|
|
|
|
// ─── CSS application ────────────────────────────────────────────────────────
|
|
|
|
function applyTheme(theme: DesktopTheme, mode: 'light' | 'dark') {
|
|
if (typeof document === 'undefined') {
|
|
return
|
|
}
|
|
|
|
const root = document.documentElement
|
|
const typo = { ...DEFAULT_TYPOGRAPHY, ...NOUS_FONT_FAMILY_FALLBACK, ...theme.typography }
|
|
const c = theme.colors
|
|
|
|
const rendered = renderedModeFor(theme.colors, mode)
|
|
|
|
root.style.setProperty('color-scheme', rendered)
|
|
root.dataset.hermesTheme = skinNameFromTheme(theme, mode)
|
|
root.classList.toggle('dark', rendered === 'dark')
|
|
|
|
const vars: Record<string, string> = {
|
|
'--dt-background': c.background,
|
|
'--dt-foreground': c.foreground,
|
|
'--dt-card': c.card,
|
|
'--dt-card-foreground': c.cardForeground,
|
|
'--dt-muted': c.muted,
|
|
'--dt-muted-foreground': c.mutedForeground,
|
|
'--dt-popover': c.popover,
|
|
'--dt-popover-foreground': c.popoverForeground,
|
|
'--dt-primary': c.primary,
|
|
'--dt-primary-foreground': c.primaryForeground,
|
|
'--dt-secondary': c.secondary,
|
|
'--dt-secondary-foreground': c.secondaryForeground,
|
|
'--dt-accent': c.accent,
|
|
'--dt-accent-foreground': c.accentForeground,
|
|
'--dt-border': c.border,
|
|
'--dt-input': c.input,
|
|
'--dt-ring': c.ring,
|
|
'--dt-destructive': c.destructive,
|
|
'--dt-destructive-foreground': c.destructiveForeground,
|
|
'--dt-sidebar-bg': c.sidebarBackground ?? c.background,
|
|
'--dt-sidebar-border': c.sidebarBorder ?? c.border,
|
|
'--dt-user-bubble': c.userBubble ?? c.muted,
|
|
'--dt-user-bubble-border': c.userBubbleBorder ?? c.border,
|
|
'--dt-font-sans': typo.fontSans,
|
|
'--dt-font-mono': typo.fontMono
|
|
}
|
|
|
|
for (const [k, v] of Object.entries(vars)) {
|
|
root.style.setProperty(k, v)
|
|
}
|
|
|
|
if (typo.fontUrl && !INJECTED_FONT_URLS.has(typo.fontUrl)) {
|
|
const link = document.createElement('link')
|
|
link.rel = 'stylesheet'
|
|
link.href = typo.fontUrl
|
|
link.setAttribute('data-hermes-theme-font', 'true')
|
|
document.head.appendChild(link)
|
|
INJECTED_FONT_URLS.add(typo.fontUrl)
|
|
}
|
|
}
|
|
|
|
if (typeof window !== 'undefined') {
|
|
const skin = normalizeSkin(window.localStorage.getItem(STORAGE_KEY))
|
|
const mode = (window.localStorage.getItem(MODE_KEY) as ThemeMode) ?? 'light'
|
|
const resolved = effectiveMode(mode)
|
|
applyTheme(deriveTheme(skin, resolved), resolved)
|
|
}
|
|
|
|
// ─── Context ────────────────────────────────────────────────────────────────
|
|
|
|
interface ThemeContextValue {
|
|
theme: DesktopTheme
|
|
themeName: string
|
|
mode: ThemeMode
|
|
availableThemes: Array<{ name: string; label: string; description: string }>
|
|
setTheme: (name: string) => void
|
|
setMode: (mode: ThemeMode) => void
|
|
}
|
|
|
|
const ThemeContext = createContext<ThemeContextValue>({
|
|
theme: nousLightTheme,
|
|
themeName: DEFAULT_SKIN,
|
|
mode: 'light',
|
|
availableThemes: SKIN_THEME_LIST.map(({ name, label, description }) => ({
|
|
name,
|
|
label: name === DEFAULT_SKIN ? 'Hermes' : label,
|
|
description
|
|
})),
|
|
setTheme: () => {},
|
|
setMode: () => {}
|
|
})
|
|
|
|
export function ThemeProvider({ children }: { children: ReactNode }) {
|
|
const [themeName, setThemeNameState] = useState(() => {
|
|
if (typeof window === 'undefined') {
|
|
return DEFAULT_SKIN
|
|
}
|
|
|
|
return normalizeSkin(window.localStorage.getItem(STORAGE_KEY))
|
|
})
|
|
|
|
const [mode, setModeState] = useState<ThemeMode>(() => {
|
|
if (typeof window === 'undefined') {
|
|
return 'light'
|
|
}
|
|
|
|
return (window.localStorage.getItem(MODE_KEY) as ThemeMode) ?? 'light'
|
|
})
|
|
|
|
const systemDark = useMediaQuery('(prefers-color-scheme: dark)')
|
|
const resolvedMode = effectiveMode(mode, systemDark)
|
|
|
|
const activeTheme = useMemo(() => deriveTheme(themeName, resolvedMode), [themeName, resolvedMode])
|
|
|
|
useEffect(() => applyTheme(activeTheme, resolvedMode), [activeTheme, resolvedMode])
|
|
|
|
const setTheme = useCallback((name: string) => {
|
|
const next = normalizeSkin(name)
|
|
setThemeNameState(next)
|
|
window.localStorage.setItem(STORAGE_KEY, next)
|
|
}, [])
|
|
|
|
const setMode = useCallback((next: ThemeMode) => {
|
|
setModeState(next)
|
|
window.localStorage.setItem(MODE_KEY, next)
|
|
}, [])
|
|
|
|
const value = useMemo<ThemeContextValue>(
|
|
() => ({
|
|
theme: activeTheme,
|
|
themeName,
|
|
mode,
|
|
availableThemes: SKIN_THEME_LIST.map(({ name, label, description }) => ({
|
|
name,
|
|
label: name === DEFAULT_SKIN ? 'Hermes' : label,
|
|
description
|
|
})),
|
|
setTheme,
|
|
setMode
|
|
}),
|
|
[activeTheme, themeName, mode, setTheme, setMode]
|
|
)
|
|
|
|
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
|
|
}
|
|
|
|
export function useTheme(): ThemeContextValue {
|
|
return 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])
|
|
}
|