diff --git a/web/index.html b/web/index.html index fabb2eee..357b910d 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 00000000..682ee1f5 --- /dev/null +++ b/web/src/hooks/useColorTheme.test.ts @@ -0,0 +1,75 @@ +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', '--app-chat-user-chip-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') + }) + + it('uses dark palette contrast values for OLED', () => { + applyColorTheme('one', 'oled') + + expect(document.documentElement.style.getPropertyValue('--app-bg')).toBe('#1f2433') + expect(document.documentElement.style.getPropertyValue('--app-chat-user-chip-bg')).toBe('rgba(123, 140, 255, 0.24)') + }) +}) diff --git a/web/src/hooks/useColorTheme.ts b/web/src/hooks/useColorTheme.ts new file mode 100644 index 00000000..b79432b8 --- /dev/null +++ b/web/src/hooks/useColorTheme.ts @@ -0,0 +1,382 @@ +import { useCallback, useEffect, useState } from 'react' +import type { ThemeColorKeyId } from './useThemeColors' + +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 + labelKey: 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', labelKey: 'settings.display.colorTheme.default', preview: { light: '#ffffff', dark: '#1c1c1e', accent: '#111827' } }, + { value: 'notion', labelKey: 'settings.display.colorTheme.notion', preview: { light: '#fafafa', dark: '#191919', accent: '#3183d8' } }, + { value: 'one', labelKey: 'settings.display.colorTheme.one', preview: { light: '#fbfbff', dark: '#1f2433', accent: '#526fff' } }, + { value: 'proof', labelKey: 'settings.display.colorTheme.proof', preview: { light: '#f8f7f2', dark: '#18231f', accent: '#2f7d5b' } }, + { value: 'raycast', labelKey: 'settings.display.colorTheme.raycast', preview: { light: '#ffffff', dark: '#201617', accent: '#ff5555' } }, + { value: 'rose-pine', labelKey: 'settings.display.colorTheme.rosePine', preview: { light: '#fffaf3', dark: '#191724', accent: '#c65f7b' } }, + { value: 'solarized', labelKey: 'settings.display.colorTheme.solarized', preview: { light: '#fdf6e3', dark: '#002b36', accent: '#b58900' } }, + { value: 'vercel', labelKey: 'settings.display.colorTheme.vercel', preview: { light: '#ffffff', dark: '#000000', accent: '#0070f3' } }, + { value: 'vs-code-plus', labelKey: 'settings.display.colorTheme.vsCodePlus', preview: { light: '#ffffff', dark: '#1e1e1e', accent: '#007acc' } }, + { value: 'xcode', labelKey: 'settings.display.colorTheme.xcode', preview: { light: '#f7f9ff', dark: '#1f2024', accent: '#0a84ff' } }, + { value: 'linear', labelKey: 'settings.display.colorTheme.linear', preview: { light: '#f7f7fb', dark: '#08090d', accent: '#5e6ad2' } }, + { value: 'lobster', labelKey: 'settings.display.colorTheme.lobster', preview: { light: '#fff7f4', dark: '#161821', accent: '#ff5b6e' } }, + { value: 'material', labelKey: 'settings.display.colorTheme.material', preview: { light: '#fffbfe', dark: '#1c1b1f', accent: '#6750a4' } }, + { value: 'matrix', labelKey: 'settings.display.colorTheme.matrix', preview: { light: '#f5fff7', dark: '#030806', accent: '#00ff66' } }, + { value: 'monokai', labelKey: 'settings.display.colorTheme.monokai', preview: { light: '#f8f7ef', dark: '#272822', accent: '#a6e22e' } }, + { value: 'night-owl', labelKey: 'settings.display.colorTheme.nightOwl', preview: { light: '#fbfdff', dark: '#011627', accent: '#82aaff' } }, + { value: 'nord', labelKey: 'settings.display.colorTheme.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 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 getColorThemePickerValue(theme: ColorThemePreset, scheme: ColorScheme, id: ThemeColorKeyId): string | null { + if (theme === 'default') return null + const palette = PALETTES[theme][toPaletteScheme(scheme)] + const values: Record = { + background: palette.background, + surface: palette.secondary, + text: palette.foreground, + hint: palette.hint, + accent: palette.accent, + border: compositeOnBackground(palette.border, palette.background), + userBubble: palette.surface, + } + return values[id] +} + +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, toPaletteScheme(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 compositeOnBackground(rgba: string, background: string): string { + const match = /^rgba\((\d+), (\d+), (\d+), ([\d.]+)\)$/.exec(rgba) + if (!match) return rgba + const base = parseHex(background) + const alpha = Number(match[4]) + const blend = (foreground: number, behind: number) => Math.round(foreground * alpha + behind * (1 - alpha)) + return toHex(blend(Number(match[1]), base.r), blend(Number(match[2]), base.g), blend(Number(match[3]), base.b)) +} + +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 150b4baa..8a1699a9 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 de619189..ba5e68cc 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 54931127..5ce0c508 100644 --- a/web/src/hooks/useThemeColors.test.ts +++ b/web/src/hooks/useThemeColors.test.ts @@ -78,6 +78,53 @@ 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('uses the active color theme as the custom color picker baseline', () => { + localStorage.setItem('hapi-color-theme', 'one') + setScheme('light') + const { result } = renderHook(() => useThemeColors()) + + expect(result.current.getPickerValue('background')).toBe('#fbfbff') + expect(result.current.getPickerValue('accent')).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 826bc856..34f8be34 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, getColorThemePickerValue, 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]!) } } } @@ -268,7 +269,7 @@ export function applyThemeColors(): void { export function getThemeColorPickerValue(scheme: ThemeScheme, id: ThemeColorKeyId): string { const override = getStoredThemeColors()[scheme]?.[id] - return override ?? DEFAULT_HEX[scheme][id] + return override ?? getColorThemePickerValue(getStoredColorTheme(), scheme, id) ?? DEFAULT_HEX[scheme][id] } export function initializeThemeColors(): void { @@ -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) } }, []) @@ -374,7 +379,7 @@ export function useThemeColors(): { }, []) const getPickerValue = useCallback( - (id: ThemeColorKeyId) => overrides[id] ?? DEFAULT_HEX[scheme][id], + (id: ThemeColorKeyId) => overrides[id] ?? getColorThemePickerValue(getStoredColorTheme(), scheme, id) ?? DEFAULT_HEX[scheme][id], [overrides, scheme], ) diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 3b7c30a0..9a79e191 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -563,6 +563,24 @@ 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.colorTheme.default': 'Default', + 'settings.display.colorTheme.notion': 'Notion', + 'settings.display.colorTheme.one': 'One', + 'settings.display.colorTheme.proof': 'Proof', + 'settings.display.colorTheme.raycast': 'Raycast', + 'settings.display.colorTheme.rosePine': 'Rose Pine', + 'settings.display.colorTheme.solarized': 'Solarized', + 'settings.display.colorTheme.vercel': 'Vercel', + 'settings.display.colorTheme.vsCodePlus': 'VS Code Plus', + 'settings.display.colorTheme.xcode': 'Xcode', + 'settings.display.colorTheme.linear': 'Linear', + 'settings.display.colorTheme.lobster': 'Lobster', + 'settings.display.colorTheme.material': 'Material', + 'settings.display.colorTheme.matrix': 'Matrix', + 'settings.display.colorTheme.monokai': 'Monokai', + 'settings.display.colorTheme.nightOwl': 'Night Owl', + 'settings.display.colorTheme.nord': 'Nord', '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 ef1cb15f..b25e9036 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -567,6 +567,24 @@ export default { 'settings.display.appearance.dark': '深色', 'settings.display.appearance.oled': 'OLED 纯黑', 'settings.display.appearance.light': '浅色', + 'settings.display.colorTheme': '颜色主题', + 'settings.display.colorTheme.default': '默认', + 'settings.display.colorTheme.notion': 'Notion', + 'settings.display.colorTheme.one': 'One', + 'settings.display.colorTheme.proof': 'Proof', + 'settings.display.colorTheme.raycast': 'Raycast', + 'settings.display.colorTheme.rosePine': 'Rose Pine', + 'settings.display.colorTheme.solarized': 'Solarized', + 'settings.display.colorTheme.vercel': 'Vercel', + 'settings.display.colorTheme.vsCodePlus': 'VS Code Plus', + 'settings.display.colorTheme.xcode': 'Xcode', + 'settings.display.colorTheme.linear': 'Linear', + 'settings.display.colorTheme.lobster': 'Lobster', + 'settings.display.colorTheme.material': 'Material', + 'settings.display.colorTheme.matrix': 'Matrix', + 'settings.display.colorTheme.monokai': 'Monokai', + 'settings.display.colorTheme.nightOwl': 'Night Owl', + 'settings.display.colorTheme.nord': 'Nord', 'settings.display.themeColors.title': '自定义配色', 'settings.display.themeColors.description': '应用于当前外观。切换外观可分别自定义每种配色。', 'settings.display.themeColors.reset': '重置', diff --git a/web/src/routes/settings/display.tsx b/web/src/routes/settings/display.tsx index ffc5c427..e6183c5e 100644 --- a/web/src/routes/settings/display.tsx +++ b/web/src/routes/settings/display.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react' import { useTranslation } from '@/lib/use-translation' import { getAppearanceOptions, useAppearance } from '@/hooks/useTheme' +import { getColorThemeOptions, getColorThemePreview, useColorTheme, type ColorThemePreset } from '@/hooks/useColorTheme' import { getFontScaleOptions, useFontScale } from '@/hooks/useFontScale' import { getTerminalFontSizeOptions, useTerminalFontSize } from '@/hooks/useTerminalFontSize' import { getSessionListStatusModeOptions, useSessionListStatusMode } from '@/hooks/useSessionListStatusMode' @@ -17,6 +18,46 @@ function PlusIcon() { return } +function ColorThemePicker() { + const { t } = useTranslation() + const { colorTheme, setColorTheme } = useColorTheme() + + return ( +
+ {t('settings.display.colorTheme')} +
+ {getColorThemeOptions().map((option) => ( + + ))} +
+
+ ) +} + +function ColorThemeOption(props: { theme: ColorThemePreset; label: string; selected: boolean; onSelect: (theme: ColorThemePreset) => void }) { + const preview = getColorThemePreview(props.theme) + return ( + + ) +} + function SessionPreviewLimitControl() { const { t } = useTranslation() const { sessionPreviewLimit, setSessionPreviewLimit } = useSessionPreviewLimit() @@ -105,6 +146,7 @@ export default function SettingsDisplayPage() { options={getAppearanceOptions().map((option) => ({ value: option.value, label: t(option.labelKey) }))} onChange={setAppearance} /> + diff --git a/web/src/routes/settings/index.test.tsx b/web/src/routes/settings/index.test.tsx index e1698b67..ff66266f 100644 --- a/web/src/routes/settings/index.test.tsx +++ b/web/src/routes/settings/index.test.tsx @@ -10,15 +10,27 @@ import SettingsVoicePage from './voice' import SettingsVoiceVoicesPage from './voice-voices' import SettingsVoiceAdvancedPage from './voice-advanced' -const { navigate, setAppearance, setFontScale, setTerminalFontSize, setComposerEnterBehavior, setVoice } = vi.hoisted(() => ({ +const { navigate, setAppearance, setColorTheme, setFontScale, setTerminalFontSize, setComposerEnterBehavior, setVoice } = vi.hoisted(() => ({ navigate: vi.fn(), setAppearance: vi.fn(), + setColorTheme: vi.fn(), setFontScale: vi.fn(), setTerminalFontSize: vi.fn(), setComposerEnterBehavior: vi.fn(), setVoice: vi.fn(), })) +vi.mock('@/hooks/useColorTheme', () => ({ + useColorTheme: () => ({ colorTheme: 'default', setColorTheme }), + getColorThemeOptions: () => [ + { value: 'default', labelKey: 'settings.display.colorTheme.default' }, + { value: 'nord', labelKey: 'settings.display.colorTheme.nord' }, + ], + getColorThemePreview: (theme: string) => theme === 'nord' + ? { light: '#eceff4', dark: '#2e3440', accent: '#88c0d0' } + : { light: '#ffffff', dark: '#1c1c1e', accent: '#111827' }, +})) + vi.mock('@tanstack/react-router', () => ({ useNavigate: () => navigate, })) @@ -172,6 +184,8 @@ describe('responsive settings pages', () => { it('renders compact display controls without dropdown popovers', () => { renderPage() expect(screen.getByRole('radio', { name: 'OLED Black' })).toBeInTheDocument() + fireEvent.click(screen.getByRole('radio', { name: 'Nord' })) + expect(setColorTheme).toHaveBeenCalledWith('nord') expect(screen.getByRole('radio', { name: '120%' })).toBeInTheDocument() expect(screen.getByRole('spinbutton', { name: 'Sessions Before Folding' })).toHaveValue(8) expect(screen.queryByRole('listbox')).not.toBeInTheDocument()