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 ( +