diff --git a/web/index.html b/web/index.html
index fabb2eeea0..357b910ddf 100644
--- a/web/index.html
+++ b/web/index.html
@@ -41,11 +41,30 @@
(function () {
var lightColor = '#ffffff'
var darkColor = '#1c1c1e'
+ var oledColor = '#000000'
+ var themeBackgrounds = {
+ notion: { light: '#fafafa', dark: '#191919' },
+ one: { light: '#fbfbff', dark: '#1f2433' },
+ proof: { light: '#f8f7f2', dark: '#18231f' },
+ raycast: { light: '#ffffff', dark: '#201617' },
+ 'rose-pine': { light: '#fffaf3', dark: '#191724' },
+ solarized: { light: '#fdf6e3', dark: '#002b36' },
+ vercel: { light: '#ffffff', dark: '#000000' },
+ 'vs-code-plus': { light: '#ffffff', dark: '#1e1e1e' },
+ xcode: { light: '#f7f9ff', dark: '#1f2024' },
+ linear: { light: '#f7f7fb', dark: '#08090d' },
+ lobster: { light: '#fff7f4', dark: '#161821' },
+ material: { light: '#fffbfe', dark: '#1c1b1f' },
+ matrix: { light: '#f5fff7', dark: '#030806' },
+ monokai: { light: '#f8f7ef', dark: '#272822' },
+ 'night-owl': { light: '#fbfdff', dark: '#011627' },
+ nord: { light: '#eceff4', dark: '#2e3440' },
+ }
function getInitialScheme() {
try {
var appearance = localStorage.getItem('hapi-appearance')
- if (appearance === 'dark' || appearance === 'light') return appearance
+ if (appearance === 'dark' || appearance === 'light' || appearance === 'oled') return appearance
} catch (error) {
// Ignore storage errors.
}
@@ -56,8 +75,20 @@
}
var scheme = getInitialScheme()
- var color = scheme === 'dark' ? darkColor : lightColor
+ var colorTheme = 'default'
+ try {
+ var storedColorTheme = localStorage.getItem('hapi-color-theme')
+ if (storedColorTheme && themeBackgrounds[storedColorTheme]) colorTheme = storedColorTheme
+ } catch (error) {
+ // Ignore storage errors.
+ }
+ var paletteScheme = scheme === 'light' ? 'light' : 'dark'
+ var color = colorTheme === 'default'
+ ? (scheme === 'oled' ? oledColor : (scheme === 'dark' ? darkColor : lightColor))
+ : themeBackgrounds[colorTheme][paletteScheme]
document.documentElement.setAttribute('data-theme', scheme)
+ document.documentElement.setAttribute('data-color-theme', colorTheme)
+ document.documentElement.style.backgroundColor = color
document.querySelector('meta[name="theme-color"]').setAttribute('content', color)
})()
@@ -65,6 +96,8 @@
HAPI
diff --git a/web/src/hooks/useColorTheme.test.ts b/web/src/hooks/useColorTheme.test.ts
new file mode 100644
index 0000000000..27532d1827
--- /dev/null
+++ b/web/src/hooks/useColorTheme.test.ts
@@ -0,0 +1,68 @@
+import { act, renderHook } from '@testing-library/react'
+import { beforeEach, describe, expect, it } from 'vitest'
+import {
+ applyColorTheme,
+ getColorThemeStorageKey,
+ getStoredColorTheme,
+ parseColorTheme,
+ useColorTheme,
+} from './useColorTheme'
+
+const THEME_VARS = ['--app-bg', '--app-fg', '--app-link', '--app-button', '--app-secondary-bg']
+
+describe('useColorTheme', () => {
+ beforeEach(() => {
+ localStorage.clear()
+ document.documentElement.removeAttribute('data-color-theme')
+ document.documentElement.setAttribute('data-theme', 'light')
+ for (const name of THEME_VARS) document.documentElement.style.removeProperty(name)
+ })
+
+ it('parses invalid or missing stored values as default', () => {
+ expect(parseColorTheme(null)).toBe('default')
+ expect(parseColorTheme('unknown')).toBe('default')
+ expect(parseColorTheme('notion')).toBe('notion')
+ })
+
+ it('reads the stored color theme preference', () => {
+ localStorage.setItem(getColorThemeStorageKey(), 'rose-pine')
+ expect(getStoredColorTheme()).toBe('rose-pine')
+ })
+
+ it('applies a preset palette to the document css variables', () => {
+ applyColorTheme('one', 'light')
+ expect(document.documentElement).toHaveAttribute('data-color-theme', 'one')
+ expect(document.documentElement.style.getPropertyValue('--app-bg')).toBe('#fbfbff')
+ expect(document.documentElement.style.getPropertyValue('--app-link')).toBe('#526fff')
+ })
+
+ it('removes palette css variables when reset to default', () => {
+ applyColorTheme('one', 'dark')
+ applyColorTheme('default', 'dark')
+
+ expect(document.documentElement).toHaveAttribute('data-color-theme', 'default')
+ expect(document.documentElement.style.getPropertyValue('--app-bg')).toBe('')
+ expect(document.documentElement.style.getPropertyValue('--app-link')).toBe('')
+ })
+
+ it('persists non-default selections and removes the key for default', () => {
+ const { result } = renderHook(() => useColorTheme())
+
+ act(() => result.current.setColorTheme('night-owl'))
+ expect(localStorage.getItem(getColorThemeStorageKey())).toBe('night-owl')
+ expect(document.documentElement.style.getPropertyValue('--app-bg')).toBe('#fbfdff')
+
+ act(() => result.current.setColorTheme('default'))
+ expect(localStorage.getItem(getColorThemeStorageKey())).toBeNull()
+ expect(document.documentElement.style.getPropertyValue('--app-bg')).toBe('')
+ })
+
+ it('uses the current dark scheme when applying from the hook', () => {
+ document.documentElement.setAttribute('data-theme', 'dark')
+ const { result } = renderHook(() => useColorTheme())
+
+ act(() => result.current.setColorTheme('notion'))
+ expect(document.documentElement.style.getPropertyValue('--app-bg')).toBe('#191919')
+ expect(document.documentElement.style.getPropertyValue('--app-fg')).toBe('#d9d9d8')
+ })
+})
diff --git a/web/src/hooks/useColorTheme.ts b/web/src/hooks/useColorTheme.ts
new file mode 100644
index 0000000000..8687ae47b8
--- /dev/null
+++ b/web/src/hooks/useColorTheme.ts
@@ -0,0 +1,361 @@
+import { useCallback, useEffect, useState } from 'react'
+
+export type ColorScheme = 'light' | 'dark' | 'oled'
+export type ColorThemePreset =
+ | 'default'
+ | 'notion'
+ | 'one'
+ | 'proof'
+ | 'raycast'
+ | 'rose-pine'
+ | 'solarized'
+ | 'vercel'
+ | 'vs-code-plus'
+ | 'xcode'
+ | 'linear'
+ | 'lobster'
+ | 'material'
+ | 'matrix'
+ | 'monokai'
+ | 'night-owl'
+ | 'nord'
+
+export type ColorThemeOption = {
+ value: ColorThemePreset
+ label: string
+ preview: {
+ light: string
+ dark: string
+ accent: string
+ }
+}
+
+type ThemePalette = {
+ accent: string
+ background: string
+ foreground: string
+ hint: string
+ secondary: string
+ dialog: string
+ surface: string
+ surfaceHover: string
+ code: string
+ border: string
+ subtle: string
+ buttonText: string
+}
+
+const COLOR_THEME_KEY = 'hapi-color-theme'
+
+const COLOR_THEME_OPTIONS: ReadonlyArray = [
+ { value: 'default', label: 'Default', preview: { light: '#ffffff', dark: '#1c1c1e', accent: '#111827' } },
+ { value: 'notion', label: 'Notion', preview: { light: '#fafafa', dark: '#191919', accent: '#3183d8' } },
+ { value: 'one', label: 'One', preview: { light: '#fbfbff', dark: '#1f2433', accent: '#526fff' } },
+ { value: 'proof', label: 'Proof', preview: { light: '#f8f7f2', dark: '#18231f', accent: '#2f7d5b' } },
+ { value: 'raycast', label: 'Raycast', preview: { light: '#ffffff', dark: '#201617', accent: '#ff5555' } },
+ { value: 'rose-pine', label: 'Rose Pine', preview: { light: '#fffaf3', dark: '#191724', accent: '#c65f7b' } },
+ { value: 'solarized', label: 'Solarized', preview: { light: '#fdf6e3', dark: '#002b36', accent: '#b58900' } },
+ { value: 'vercel', label: 'Vercel', preview: { light: '#ffffff', dark: '#000000', accent: '#0070f3' } },
+ { value: 'vs-code-plus', label: 'VS Code Plus', preview: { light: '#ffffff', dark: '#1e1e1e', accent: '#007acc' } },
+ { value: 'xcode', label: 'Xcode', preview: { light: '#f7f9ff', dark: '#1f2024', accent: '#0a84ff' } },
+ { value: 'linear', label: 'Linear', preview: { light: '#f7f7fb', dark: '#08090d', accent: '#5e6ad2' } },
+ { value: 'lobster', label: 'Lobster', preview: { light: '#fff7f4', dark: '#161821', accent: '#ff5b6e' } },
+ { value: 'material', label: 'Material', preview: { light: '#fffbfe', dark: '#1c1b1f', accent: '#6750a4' } },
+ { value: 'matrix', label: 'Matrix', preview: { light: '#f5fff7', dark: '#030806', accent: '#00ff66' } },
+ { value: 'monokai', label: 'Monokai', preview: { light: '#f8f7ef', dark: '#272822', accent: '#a6e22e' } },
+ { value: 'night-owl', label: 'Night Owl', preview: { light: '#fbfdff', dark: '#011627', accent: '#82aaff' } },
+ { value: 'nord', label: 'Nord', preview: { light: '#eceff4', dark: '#2e3440', accent: '#88c0d0' } },
+]
+
+const PALETTES: Record, Record<'light' | 'dark', ThemePalette>> = {
+ notion: {
+ light: palette('#3183d8', '#fafafa', '#37352f', '#787774', '#f1f1ef'),
+ dark: palette('#3183d8', '#191919', '#d9d9d8', '#9b9a97', '#252525'),
+ },
+ one: {
+ light: palette('#526fff', '#fbfbff', '#383a42', '#696c77', '#f0f2ff'),
+ dark: palette('#7b8cff', '#1f2433', '#d8dee9', '#aab2c0', '#2c3245'),
+ },
+ proof: {
+ light: palette('#2f7d5b', '#f8f7f2', '#26352f', '#64706b', '#eef1ea'),
+ dark: palette('#7fc8a6', '#18231f', '#d9e4dc', '#9dafaa', '#23332d'),
+ },
+ raycast: {
+ light: palette('#ff4d4d', '#ffffff', '#2b2b31', '#72727a', '#fff0f0'),
+ dark: palette('#ff6363', '#201617', '#f3eded', '#c9bfc0', '#321f21'),
+ },
+ 'rose-pine': {
+ light: palette('#d7827e', '#fffaf3', '#575279', '#797593', '#f2e9e1'),
+ dark: palette('#ebbcba', '#191724', '#e0def4', '#908caa', '#26233a'),
+ },
+ solarized: {
+ light: palette('#b58900', '#fdf6e3', '#586e75', '#839496', '#eee8d5'),
+ dark: palette('#268bd2', '#002b36', '#93a1a1', '#839496', '#073642'),
+ },
+ vercel: {
+ light: palette('#0070f3', '#ffffff', '#111111', '#666666', '#f5f5f5'),
+ dark: palette('#3291ff', '#000000', '#ededed', '#a1a1a1', '#111111'),
+ },
+ 'vs-code-plus': {
+ light: palette('#007acc', '#ffffff', '#24292f', '#6e7781', '#f3f6f8'),
+ dark: palette('#3794ff', '#1e1e1e', '#d4d4d4', '#9cdcfe', '#252526'),
+ },
+ xcode: {
+ light: palette('#0a84ff', '#f7f9ff', '#1f2328', '#59636e', '#eef4ff'),
+ dark: palette('#409cff', '#1f2024', '#eef2ff', '#a9b0bd', '#2a2c33'),
+ },
+ linear: {
+ light: palette('#5e6ad2', '#f7f7fb', '#25262f', '#70717d', '#eeeeF8'),
+ dark: palette('#8a91f6', '#08090d', '#f7f8ff', '#a6a8b5', '#151722'),
+ },
+ lobster: {
+ light: palette('#e84d68', '#fff7f4', '#33262b', '#7d636a', '#ffe9e2'),
+ dark: palette('#ff5b6e', '#161821', '#f5d7dc', '#be9ca5', '#242333'),
+ },
+ material: {
+ light: palette('#6750a4', '#fffbfe', '#1c1b1f', '#6f6a73', '#f2edf7'),
+ dark: palette('#d0bcff', '#1c1b1f', '#e6e1e5', '#cac4cf', '#2b2930'),
+ },
+ matrix: {
+ light: palette('#10883a', '#f5fff7', '#102016', '#55735f', '#e8f8ec'),
+ dark: palette('#00ff66', '#030806', '#d8ffe4', '#6aff9b', '#07150d'),
+ },
+ monokai: {
+ light: palette('#7a8f00', '#f8f7ef', '#3b3a32', '#747065', '#efeee4'),
+ dark: palette('#a6e22e', '#272822', '#f8f8f2', '#cfcfc2', '#33342d'),
+ },
+ 'night-owl': {
+ light: palette('#4876d9', '#fbfdff', '#25354a', '#60708a', '#eef6ff'),
+ dark: palette('#82aaff', '#011627', '#d6deeb', '#7fdbca', '#0b2942'),
+ },
+ nord: {
+ light: palette('#5e81ac', '#eceff4', '#2e3440', '#667085', '#e5e9f0'),
+ dark: palette('#88c0d0', '#2e3440', '#eceff4', '#d8dee9', '#3b4252'),
+ },
+}
+
+function palette(accent: string, background: string, foreground: string, hint: string, secondary: string): ThemePalette {
+ const isDark = relativeLuminance(background) < 0.5
+ return {
+ accent,
+ background,
+ foreground,
+ hint,
+ secondary,
+ dialog: isDark ? mix(background, '#ffffff', 0.05) : mix(background, '#000000', 0.015),
+ surface: isDark ? mix(background, '#ffffff', 0.09) : mix(background, '#000000', 0.035),
+ surfaceHover: isDark ? mix(background, '#ffffff', 0.14) : mix(background, '#000000', 0.065),
+ code: isDark ? mix(background, '#ffffff', 0.11) : mix(background, '#000000', 0.045),
+ border: withAlpha(isDark ? '#ffffff' : '#0f172a', isDark ? 0.11 : 0.10),
+ subtle: withAlpha(isDark ? '#ffffff' : '#0f172a', isDark ? 0.06 : 0.045),
+ buttonText: readableText(accent),
+ }
+}
+
+function isBrowser(): boolean {
+ return typeof window !== 'undefined' && typeof document !== 'undefined'
+}
+
+function safeGetItem(key: string): string | null {
+ if (!isBrowser()) return null
+ try {
+ return localStorage.getItem(key)
+ } catch {
+ return null
+ }
+}
+
+function safeSetItem(key: string, value: string): void {
+ if (!isBrowser()) return
+ try {
+ localStorage.setItem(key, value)
+ } catch {
+ // Ignore storage errors
+ }
+}
+
+function safeRemoveItem(key: string): void {
+ if (!isBrowser()) return
+ try {
+ localStorage.removeItem(key)
+ } catch {
+ // Ignore storage errors
+ }
+}
+
+export function parseColorTheme(raw: string | null): ColorThemePreset {
+ return COLOR_THEME_OPTIONS.some((option) => option.value === raw) ? raw as ColorThemePreset : 'default'
+}
+
+export function getStoredColorTheme(): ColorThemePreset {
+ return parseColorTheme(safeGetItem(COLOR_THEME_KEY))
+}
+
+export function getColorThemeOptions(): ReadonlyArray {
+ return COLOR_THEME_OPTIONS
+}
+
+export function getColorThemeLabel(theme: ColorThemePreset): string {
+ return COLOR_THEME_OPTIONS.find((option) => option.value === theme)?.label ?? 'Default'
+}
+
+export function getColorThemePreview(theme: ColorThemePreset): ColorThemeOption['preview'] {
+ return COLOR_THEME_OPTIONS.find((option) => option.value === theme)?.preview ?? COLOR_THEME_OPTIONS[0]!.preview
+}
+
+export function getColorThemeStorageKey(): string {
+ return COLOR_THEME_KEY
+}
+
+export function getColorThemeBackground(theme: ColorThemePreset, scheme: ColorScheme): string | null {
+ return theme === 'default' ? null : PALETTES[theme][toPaletteScheme(scheme)].background
+}
+
+export function applyColorTheme(theme: ColorThemePreset = getStoredColorTheme(), scheme: ColorScheme): void {
+ if (!isBrowser()) return
+
+ const root = document.documentElement
+ root.setAttribute('data-color-theme', theme)
+
+ if (theme === 'default') {
+ removeThemeProperties(root)
+ return
+ }
+
+ const values = PALETTES[theme][toPaletteScheme(scheme)]
+ const properties: Record = {
+ '--app-bg': values.background,
+ '--app-fg': values.foreground,
+ '--app-hint': values.hint,
+ '--app-link': values.accent,
+ '--app-button': values.accent,
+ '--app-button-text': values.buttonText,
+ '--app-banner-bg': values.accent,
+ '--app-banner-text': values.buttonText,
+ '--app-secondary-bg': values.secondary,
+ '--app-dialog-bg': values.dialog,
+ '--app-chat-user-bg': values.surface,
+ '--app-chat-user-fg': values.foreground,
+ '--app-chat-user-chip-bg': withAlpha(values.accent, scheme === 'dark' ? 0.24 : 0.15),
+ '--app-chat-user-chip-fg': values.accent,
+ '--app-tool-card-bg': values.surface,
+ '--app-tool-card-hover-bg': values.surfaceHover,
+ '--app-tool-card-accent': values.hint,
+ '--app-tool-card-muted-action-fg': withAlpha(values.hint, 0.72),
+ '--app-tool-card-subtitle': values.hint,
+ '--app-code-header-bg': values.surfaceHover,
+ '--app-code-header-fg': values.hint,
+ '--app-code-bg': values.code,
+ '--app-inline-code-bg': values.code,
+ '--app-inline-code-fg': values.foreground,
+ '--app-md-quote-bg': values.surface,
+ '--app-md-quote-border': withAlpha(values.accent, 0.35),
+ '--app-md-quote-fg': values.foreground,
+ '--app-md-table-bg': values.surface,
+ '--app-md-table-head-bg': values.surfaceHover,
+ '--app-reasoning-bg': values.surface,
+ '--app-border': values.border,
+ '--app-divider': values.border,
+ '--app-subtle-bg': values.subtle,
+ '--app-scrollbar-thumb': withAlpha(values.hint, 0.38),
+ '--app-scrollbar-thumb-hover': withAlpha(values.hint, 0.56),
+ }
+
+ for (const [key, value] of Object.entries(properties)) {
+ root.style.setProperty(key, value)
+ }
+}
+
+function removeThemeProperties(root: HTMLElement): void {
+ const properties = [
+ '--app-bg', '--app-fg', '--app-hint', '--app-link', '--app-button', '--app-button-text', '--app-banner-bg', '--app-banner-text',
+ '--app-secondary-bg', '--app-dialog-bg', '--app-chat-user-bg', '--app-chat-user-fg', '--app-chat-user-chip-bg',
+ '--app-chat-user-chip-fg', '--app-tool-card-bg', '--app-tool-card-hover-bg', '--app-tool-card-accent',
+ '--app-tool-card-muted-action-fg', '--app-tool-card-subtitle', '--app-code-header-bg', '--app-code-header-fg', '--app-code-bg',
+ '--app-inline-code-bg', '--app-inline-code-fg', '--app-md-quote-bg', '--app-md-quote-border', '--app-md-quote-fg', '--app-md-table-bg',
+ '--app-md-table-head-bg', '--app-reasoning-bg', '--app-border', '--app-divider', '--app-subtle-bg', '--app-scrollbar-thumb', '--app-scrollbar-thumb-hover',
+ ]
+ for (const property of properties) root.style.removeProperty(property)
+}
+
+
+function getDocumentColorScheme(): ColorScheme {
+ if (!isBrowser()) return 'light'
+ const theme = document.documentElement.getAttribute('data-theme')
+ return theme === 'dark' || theme === 'oled' ? theme : 'light'
+}
+
+function toPaletteScheme(scheme: ColorScheme): 'light' | 'dark' {
+ return scheme === 'light' ? 'light' : 'dark'
+}
+
+export function useColorTheme(): { colorTheme: ColorThemePreset; setColorTheme: (theme: ColorThemePreset) => void } {
+ const [colorTheme, setColorThemeState] = useState(getStoredColorTheme)
+
+ useEffect(() => {
+ if (!isBrowser()) return
+ const onStorage = (event: StorageEvent) => {
+ if (event.key !== COLOR_THEME_KEY) return
+ const nextTheme = parseColorTheme(event.newValue)
+ setColorThemeState(nextTheme)
+ applyColorTheme(nextTheme, getDocumentColorScheme())
+ window.dispatchEvent(new CustomEvent('hapi-color-theme-change', { detail: nextTheme }))
+ }
+ window.addEventListener('storage', onStorage)
+ return () => window.removeEventListener('storage', onStorage)
+ }, [])
+
+ const setColorTheme = useCallback((theme: ColorThemePreset) => {
+ setColorThemeState(theme)
+ applyColorTheme(theme, getDocumentColorScheme())
+ if (theme === 'default') {
+ safeRemoveItem(COLOR_THEME_KEY)
+ } else {
+ safeSetItem(COLOR_THEME_KEY, theme)
+ }
+ window.dispatchEvent(new CustomEvent('hapi-color-theme-change', { detail: theme }))
+ }, [])
+
+ return { colorTheme, setColorTheme }
+}
+
+function mix(a: string, b: string, weight: number): string {
+ const ca = parseHex(a)
+ const cb = parseHex(b)
+ const channel = (x: number, y: number) => Math.round(x * (1 - weight) + y * weight)
+ return toHex(channel(ca.r, cb.r), channel(ca.g, cb.g), channel(ca.b, cb.b))
+}
+
+function withAlpha(hex: string, alpha: number): string {
+ const color = parseHex(hex)
+ return `rgba(${color.r}, ${color.g}, ${color.b}, ${alpha})`
+}
+
+function readableText(hex: string): string {
+ return relativeLuminance(hex) > 0.45 ? '#111827' : '#ffffff'
+}
+
+function relativeLuminance(hex: string): number {
+ const { r, g, b } = parseHex(hex)
+ const convert = (value: number) => {
+ const next = value / 255
+ return next <= 0.03928 ? next / 12.92 : ((next + 0.055) / 1.055) ** 2.4
+ }
+ return 0.2126 * convert(r) + 0.7152 * convert(g) + 0.0722 * convert(b)
+}
+
+function parseHex(hex: string): { r: number; g: number; b: number } {
+ const clean = hex.replace('#', '')
+ const value = clean.length === 3
+ ? clean.split('').map((char) => `${char}${char}`).join('')
+ : clean
+ return {
+ r: parseInt(value.slice(0, 2), 16),
+ g: parseInt(value.slice(2, 4), 16),
+ b: parseInt(value.slice(4, 6), 16),
+ }
+}
+
+function toHex(r: number, g: number, b: number): string {
+ return `#${[r, g, b].map((value) => value.toString(16).padStart(2, '0')).join('')}`
+}
diff --git a/web/src/hooks/useTheme.test.ts b/web/src/hooks/useTheme.test.ts
index 150b4baa58..8a1699a9ec 100644
--- a/web/src/hooks/useTheme.test.ts
+++ b/web/src/hooks/useTheme.test.ts
@@ -26,6 +26,33 @@ describe('useTheme', () => {
expect(meta?.hasAttribute('media')).toBe(false)
})
+
+
+
+
+ it('clears the boot-time inline html background when runtime theme initializes', () => {
+ document.documentElement.style.backgroundColor = '#fbfbff'
+ localStorage.setItem('hapi-color-theme', 'one')
+
+ initializeTheme()
+
+ expect(document.documentElement.style.backgroundColor).toBe('')
+ expect(document.documentElement.style.getPropertyValue('--app-bg')).toBe('#fbfbff')
+ })
+
+ it('updates browser theme color after a cross-tab color theme change', () => {
+ localStorage.setItem('hapi-color-theme', 'one')
+ initializeTheme()
+ expect(document.querySelector('meta[name="theme-color"]')?.content).toBe('#fbfbff')
+
+ localStorage.setItem('hapi-color-theme', 'notion')
+ act(() => {
+ window.dispatchEvent(new StorageEvent('storage', { key: 'hapi-color-theme', newValue: 'notion' }))
+ })
+
+ expect(document.querySelector('meta[name="theme-color"]')?.content).toBe('#fafafa')
+ })
+
it('updates the browser theme color when appearance changes', () => {
const { result } = renderHook(() => useAppearance())
diff --git a/web/src/hooks/useTheme.ts b/web/src/hooks/useTheme.ts
index de61918990..ba5e68cc5d 100644
--- a/web/src/hooks/useTheme.ts
+++ b/web/src/hooks/useTheme.ts
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useState, useSyncExternalStore } from 'react'
import { getTelegramWebApp } from './useTelegram'
-
-type ColorScheme = 'light' | 'dark' | 'oled'
+import { applyColorTheme, getColorThemeBackground, getColorThemeStorageKey, getStoredColorTheme, type ColorScheme } from './useColorTheme'
export type AppearancePreference = 'system' | 'dark' | 'light' | 'oled'
@@ -93,7 +92,7 @@ function applyBrowserThemeColor(scheme: ColorScheme): void {
document.head.appendChild(meta)
}
- meta.content = THEME_COLORS[scheme]
+ meta.content = getColorThemeBackground(getStoredColorTheme(), scheme) ?? THEME_COLORS[scheme]
meta.removeAttribute('media')
}
@@ -102,7 +101,9 @@ export function getThemeColor(scheme: ColorScheme): string {
}
function applyTheme(scheme: ColorScheme): void {
+ document.documentElement.style.removeProperty('background-color')
document.documentElement.setAttribute('data-theme', scheme)
+ applyColorTheme(getStoredColorTheme(), scheme)
applyBrowserThemeColor(scheme)
}
@@ -128,9 +129,9 @@ function getSnapshot(): ColorScheme {
return currentScheme
}
-function updateScheme(): void {
+function updateScheme(force = false): void {
const newScheme = getColorScheme()
- if (newScheme !== currentScheme) {
+ if (force || newScheme !== currentScheme) {
currentScheme = newScheme
applyTheme(newScheme)
listeners.forEach((cb) => cb())
@@ -190,18 +191,20 @@ export function initializeTheme(): void {
const tg = getTelegramWebApp()
if (tg?.onEvent) {
// Telegram theme changes
- tg.onEvent('themeChanged', updateScheme)
+ tg.onEvent('themeChanged', () => updateScheme())
} else if (typeof window !== 'undefined' && window.matchMedia) {
// Browser system preference changes
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
- mediaQuery.addEventListener('change', updateScheme)
+ mediaQuery.addEventListener('change', () => updateScheme())
}
// Cross-tab appearance sync: update theme when another tab changes localStorage
if (typeof window !== 'undefined') {
window.addEventListener('storage', (event: StorageEvent) => {
if (event.key === APPEARANCE_KEY) updateScheme()
+ if (event.key === getColorThemeStorageKey()) updateScheme(true)
})
+ window.addEventListener('hapi-color-theme-change', () => updateScheme(true))
}
}
}
diff --git a/web/src/hooks/useThemeColors.test.ts b/web/src/hooks/useThemeColors.test.ts
index 5493112788..ae9dd23eda 100644
--- a/web/src/hooks/useThemeColors.test.ts
+++ b/web/src/hooks/useThemeColors.test.ts
@@ -78,6 +78,44 @@ describe('useThemeColors', () => {
expect(localStorage.getItem('hapi-theme-colors')).toBeNull()
})
+
+
+ it('preserves color theme preset variables when no custom colors are stored', () => {
+ localStorage.setItem('hapi-color-theme', 'one')
+ setScheme('light')
+
+ applyThemeColors()
+
+ expect(document.documentElement.style.getPropertyValue('--app-bg').trim()).toBe('#fbfbff')
+ expect(document.documentElement.style.getPropertyValue('--app-link').trim()).toBe('#526fff')
+ })
+
+ it('layers custom colors over color theme presets', () => {
+ localStorage.setItem('hapi-color-theme', 'one')
+ localStorage.setItem('hapi-theme-colors', JSON.stringify({ light: { background: '#123456' } }))
+ setScheme('light')
+
+ applyThemeColors()
+
+ expect(document.documentElement.style.getPropertyValue('--app-bg').trim()).toBe('#123456')
+ expect(document.documentElement.style.getPropertyValue('--app-link').trim()).toBe('#526fff')
+ })
+
+
+
+ it('reapplies color theme preset changes from cross-tab storage events without Settings mounted', () => {
+ localStorage.setItem('hapi-color-theme', 'one')
+ setScheme('light')
+ initializeThemeColors()
+ expect(document.documentElement.style.getPropertyValue('--app-bg').trim()).toBe('#fbfbff')
+
+ localStorage.setItem('hapi-color-theme', 'notion')
+ window.dispatchEvent(new StorageEvent('storage', { key: 'hapi-color-theme', newValue: 'notion' }))
+
+ expect(document.documentElement.style.getPropertyValue('--app-bg').trim()).toBe('#fafafa')
+ expect(document.documentElement.style.getPropertyValue('--app-link').trim()).toBe('#3183d8')
+ })
+
it('reapplies stored colors for the active appearance during initialization', () => {
localStorage.setItem('hapi-theme-colors', JSON.stringify({ oled: { background: '#0b0b0b' } }))
setScheme('oled')
diff --git a/web/src/hooks/useThemeColors.ts b/web/src/hooks/useThemeColors.ts
index 826bc856cf..8b7ff40c15 100644
--- a/web/src/hooks/useThemeColors.ts
+++ b/web/src/hooks/useThemeColors.ts
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from 'react'
+import { applyColorTheme, getColorThemeStorageKey, getStoredColorTheme, type ColorScheme } from './useColorTheme'
/**
* Per-appearance "key color" customization.
@@ -243,24 +244,24 @@ export function applyThemeColors(): void {
if (!isBrowser()) return
const scheme = getThemeScheme()
+ applyColorTheme(getStoredColorTheme(), scheme as ColorScheme)
+
const overrides = getStoredThemeColors()[scheme] ?? {}
const rootStyle = document.documentElement.style
for (const key of THEME_COLOR_KEYS) {
const override = overrides[key.id]
const hex = override && isHexColor(override) ? override : null
+ if (!hex) continue
for (const cssVar of key.targets) {
- if (hex) rootStyle.setProperty(cssVar, hex)
- else rootStyle.removeProperty(cssVar)
+ rootStyle.setProperty(cssVar, hex)
}
- if (key.derivedTargets) {
- const derived = hex && key.derive ? key.derive(hex, scheme) : {}
+ if (key.derivedTargets && key.derive) {
+ const derived = key.derive(hex, scheme)
for (const cssVar of key.derivedTargets) {
- const value = derived[cssVar]
- if (value) rootStyle.setProperty(cssVar, value)
- else rootStyle.removeProperty(cssVar)
+ rootStyle.setProperty(cssVar, derived[cssVar]!)
}
}
}
@@ -280,8 +281,9 @@ export function initializeThemeColors(): void {
initialized = true
window.addEventListener('storage', (event: StorageEvent) => {
- if (event.key === STORAGE_KEY) applyThemeColors()
+ if (event.key === STORAGE_KEY || event.key === getColorThemeStorageKey()) applyThemeColors()
})
+ window.addEventListener('hapi-color-theme-change', applyThemeColors)
const themeObserver = new MutationObserver(() => {
applyThemeColors()
@@ -323,13 +325,16 @@ export function useThemeColors(): {
})
const onStorage = (event: StorageEvent) => {
- if (event.key === STORAGE_KEY) refresh()
+ if (event.key === STORAGE_KEY || event.key === getColorThemeStorageKey()) refresh()
}
+ const onColorThemeChange = () => refresh()
window.addEventListener('storage', onStorage)
+ window.addEventListener('hapi-color-theme-change', onColorThemeChange)
return () => {
themeObserver.disconnect()
window.removeEventListener('storage', onStorage)
+ window.removeEventListener('hapi-color-theme-change', onColorThemeChange)
}
}, [])
diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts
index dcaeeb20d3..30007f6de8 100644
--- a/web/src/lib/locales/en.ts
+++ b/web/src/lib/locales/en.ts
@@ -519,6 +519,7 @@ export default {
'settings.display.appearance.dark': 'Dark',
'settings.display.appearance.oled': 'OLED Black',
'settings.display.appearance.light': 'Light',
+ 'settings.display.colorTheme': 'Color theme',
'settings.display.themeColors.title': 'Custom colors',
'settings.display.themeColors.description': 'Applies to the current appearance. Switch appearance to customize each one separately.',
'settings.display.themeColors.reset': 'Reset',
diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts
index 7508af1039..681532a03f 100644
--- a/web/src/lib/locales/zh-CN.ts
+++ b/web/src/lib/locales/zh-CN.ts
@@ -523,6 +523,7 @@ export default {
'settings.display.appearance.dark': '深色',
'settings.display.appearance.oled': 'OLED 纯黑',
'settings.display.appearance.light': '浅色',
+ 'settings.display.colorTheme': '颜色主题',
'settings.display.themeColors.title': '自定义配色',
'settings.display.themeColors.description': '应用于当前外观。切换外观可分别自定义每种配色。',
'settings.display.themeColors.reset': '重置',
diff --git a/web/src/routes/settings/index.test.tsx b/web/src/routes/settings/index.test.tsx
index cd437768b2..c547c676bf 100644
--- a/web/src/routes/settings/index.test.tsx
+++ b/web/src/routes/settings/index.test.tsx
@@ -94,6 +94,20 @@ vi.mock('@/hooks/useTheme', () => ({
],
}))
+vi.mock('@/hooks/useColorTheme', () => ({
+ useColorTheme: () => ({ colorTheme: 'notion', setColorTheme: vi.fn() }),
+ getColorThemeOptions: () => [
+ { value: 'default', label: 'Default', preview: { light: '#ffffff', dark: '#1c1c1e', accent: '#111827' } },
+ { value: 'notion', label: 'Notion', preview: { light: '#fafafa', dark: '#191919', accent: '#3183d8' } },
+ { value: 'one', label: 'One', preview: { light: '#fbfbff', dark: '#1f2433', accent: '#526fff' } },
+ ],
+ getColorThemePreview: (theme: string) => ({
+ light: theme === 'notion' ? '#fafafa' : '#ffffff',
+ dark: theme === 'notion' ? '#191919' : '#1c1c1e',
+ accent: theme === 'one' ? '#526fff' : '#3183d8',
+ }),
+}))
+
// Mock languages
vi.mock('@/lib/languages', () => ({
getElevenLabsSupportedLanguages: () => [
@@ -227,6 +241,7 @@ describe('SettingsPage', () => {
const calledKeys = spyT.mock.calls.map((call) => call[0])
expect(calledKeys).toContain('settings.display.appearance')
expect(calledKeys).toContain('settings.display.appearance.system')
+ expect(calledKeys).toContain('settings.display.colorTheme')
expect(calledKeys).toContain('settings.display.sessionPreviewLimit')
expect(calledKeys).toContain('settings.display.sessionPreviewLimit.decrease')
expect(calledKeys).toContain('settings.display.sessionPreviewLimit.increase')
@@ -234,6 +249,12 @@ describe('SettingsPage', () => {
expect(calledKeys).toContain('settings.display.sessionListStatus.standard')
})
+ it('renders the Color theme setting', () => {
+ renderWithProviders()
+ expect(screen.getAllByText('Color theme').length).toBeGreaterThanOrEqual(1)
+ expect(screen.getAllByText('Notion').length).toBeGreaterThanOrEqual(1)
+ })
+
it('renders the Terminal Font Size setting', () => {
renderWithProviders()
expect(screen.getAllByText('Terminal Font Size').length).toBeGreaterThanOrEqual(1)
diff --git a/web/src/routes/settings/index.tsx b/web/src/routes/settings/index.tsx
index 256033628c..8698398ada 100644
--- a/web/src/routes/settings/index.tsx
+++ b/web/src/routes/settings/index.tsx
@@ -36,6 +36,7 @@ import {
type ChatSurfaceColorPreset,
} from '@/hooks/useChatSurfaceColors'
import { useAppearance, getAppearanceOptions, type AppearancePreference } from '@/hooks/useTheme'
+import { getColorThemeOptions, getColorThemePreview, useColorTheme, type ColorThemePreset } from '@/hooks/useColorTheme'
import { useThemeColors, type ThemeColorKeyId } from '@/hooks/useThemeColors'
import { PROTOCOL_VERSION } from '@hapi/protocol'
import { VoiceRespondsControls, VoiceSoundsControls, VoicePersonaControls, VoiceDiagnosticsControls } from '@/components/settings/VoiceAdvancedControls'
@@ -253,6 +254,20 @@ function SessionPreviewLimitControl(props: {
)
}
+function ColorThemeSwatch(props: { theme: ColorThemePreset; selected?: boolean }) {
+ const preview = getColorThemePreview(props.theme)
+ return (
+
+
+ Aa
+
+ )
+}
+
function ChatSurfaceColorControl(props: {
label: string
preference: ChatSurfaceColorPreference
@@ -375,6 +390,7 @@ export default function SettingsPage() {
const goBack = useAppGoBack()
const [isOpen, setIsOpen] = useState(false)
const [isAppearanceOpen, setIsAppearanceOpen] = useState(false)
+ const [isColorThemeOpen, setIsColorThemeOpen] = useState(false)
const [isFontOpen, setIsFontOpen] = useState(false)
const [isTerminalFontOpen, setIsTerminalFontOpen] = useState(false)
const [isChatOpen, setIsChatOpen] = useState(false)
@@ -385,6 +401,7 @@ export default function SettingsPage() {
const [isVoicePickerOpen, setIsVoicePickerOpen] = useState(false)
const containerRef = useRef(null)
const appearanceContainerRef = useRef(null)
+ const colorThemeContainerRef = useRef(null)
const fontContainerRef = useRef(null)
const terminalFontContainerRef = useRef(null)
const chatContainerRef = useRef(null)
@@ -407,6 +424,7 @@ export default function SettingsPage() {
setUserMessageBackground,
} = useChatSurfaceColors()
const { appearance, setAppearance } = useAppearance()
+ const { colorTheme, setColorTheme } = useColorTheme()
// Voice language state - read from localStorage
const [voiceLanguage, setVoiceLanguage] = useState(() => {
@@ -444,8 +462,10 @@ export default function SettingsPage() {
const terminalToolDisplayModeOptions = getTerminalToolDisplayModeOptions()
const sessionListStatusModeOptions = getSessionListStatusModeOptions()
const appearanceOptions = getAppearanceOptions()
+ const colorThemeOptions = getColorThemeOptions()
const currentLocale = locales.find((loc) => loc.value === locale)
const currentAppearanceLabel = appearanceOptions.find((opt) => opt.value === appearance)?.labelKey ?? 'settings.display.appearance.system'
+ const currentColorThemeLabel = colorThemeOptions.find((opt) => opt.value === colorTheme)?.label ?? 'Default'
const currentFontScaleLabel = fontScaleOptions.find((opt) => opt.value === fontScale)?.label ?? '100%'
const currentTerminalFontSizeLabel = terminalFontSizeOptions.find((opt) => opt.value === terminalFontSize)?.label ?? '13px'
const currentComposerEnterBehaviorLabel = composerEnterBehaviorOptions.find((opt) => opt.value === composerEnterBehavior)?.labelKey ?? 'settings.chat.enterBehavior.send'
@@ -497,6 +517,11 @@ export default function SettingsPage() {
setIsAppearanceOpen(false)
}
+ const handleColorThemeChange = (theme: ColorThemePreset) => {
+ setColorTheme(theme)
+ setIsColorThemeOpen(false)
+ }
+
const handleFontScaleChange = (newScale: FontScale) => {
setFontScale(newScale)
setIsFontOpen(false)
@@ -608,7 +633,7 @@ export default function SettingsPage() {
// Close dropdown when clicking outside
useEffect(() => {
- if (!isOpen && !isAppearanceOpen && !isFontOpen && !isTerminalFontOpen && !isChatOpen && !isTerminalToolDisplayOpen && !isSessionListStatusOpen && !isVoiceOpen && !isVoiceBackendOpen && !isVoicePickerOpen) return
+ if (!isOpen && !isAppearanceOpen && !isColorThemeOpen && !isFontOpen && !isTerminalFontOpen && !isChatOpen && !isTerminalToolDisplayOpen && !isSessionListStatusOpen && !isVoiceOpen && !isVoiceBackendOpen && !isVoicePickerOpen) return
const handleClickOutside = (event: MouseEvent) => {
if (isOpen && containerRef.current && !containerRef.current.contains(event.target as Node)) {
@@ -617,6 +642,9 @@ export default function SettingsPage() {
if (isAppearanceOpen && appearanceContainerRef.current && !appearanceContainerRef.current.contains(event.target as Node)) {
setIsAppearanceOpen(false)
}
+ if (isColorThemeOpen && colorThemeContainerRef.current && !colorThemeContainerRef.current.contains(event.target as Node)) {
+ setIsColorThemeOpen(false)
+ }
if (isFontOpen && fontContainerRef.current && !fontContainerRef.current.contains(event.target as Node)) {
setIsFontOpen(false)
}
@@ -645,16 +673,17 @@ export default function SettingsPage() {
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
- }, [isOpen, isAppearanceOpen, isFontOpen, isTerminalFontOpen, isChatOpen, isTerminalToolDisplayOpen, isSessionListStatusOpen, isVoiceOpen, isVoiceBackendOpen, isVoicePickerOpen])
+ }, [isOpen, isAppearanceOpen, isColorThemeOpen, isFontOpen, isTerminalFontOpen, isChatOpen, isTerminalToolDisplayOpen, isSessionListStatusOpen, isVoiceOpen, isVoiceBackendOpen, isVoicePickerOpen])
// Close on escape key
useEffect(() => {
- if (!isOpen && !isAppearanceOpen && !isFontOpen && !isTerminalFontOpen && !isChatOpen && !isTerminalToolDisplayOpen && !isSessionListStatusOpen && !isVoiceOpen && !isVoiceBackendOpen && !isVoicePickerOpen) return
+ if (!isOpen && !isAppearanceOpen && !isColorThemeOpen && !isFontOpen && !isTerminalFontOpen && !isChatOpen && !isTerminalToolDisplayOpen && !isSessionListStatusOpen && !isVoiceOpen && !isVoiceBackendOpen && !isVoicePickerOpen) return
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setIsOpen(false)
setIsAppearanceOpen(false)
+ setIsColorThemeOpen(false)
setIsFontOpen(false)
setIsTerminalFontOpen(false)
setIsChatOpen(false)
@@ -668,7 +697,7 @@ export default function SettingsPage() {
document.addEventListener('keydown', handleEscape)
return () => document.removeEventListener('keydown', handleEscape)
- }, [isOpen, isAppearanceOpen, isFontOpen, isTerminalFontOpen, isChatOpen, isTerminalToolDisplayOpen, isSessionListStatusOpen, isVoiceOpen, isVoiceBackendOpen, isVoicePickerOpen])
+ }, [isOpen, isAppearanceOpen, isColorThemeOpen, isFontOpen, isTerminalFontOpen, isChatOpen, isTerminalToolDisplayOpen, isSessionListStatusOpen, isVoiceOpen, isVoiceBackendOpen, isVoicePickerOpen])
return (
@@ -796,6 +825,58 @@ export default function SettingsPage() {
)}
+
+
+
+ {isColorThemeOpen && (
+
+ {colorThemeOptions.map((opt) => {
+ const isSelected = colorTheme === opt.value
+ return (
+
+ )
+ })}
+
+ )}
+