Files
hapi/web/src/hooks/useChatSurfaceColors.ts
T
dfb1805fd6 feat(web): OLED Black theme + per-appearance custom colors (#937)
* test: reproduce issue #866

* feat(web): OLED Black theme + per-appearance custom colors (closes #866)

Add an explicit OLED Black appearance (true #000 canvas, border-based
elevation) alongside system/dark/light, and a curated "key color"
customizer. Each key color (background, surface, text, hint, accent,
border, user bubble) cascades to its --app-* tokens and is stored per
appearance so a color tuned for light never leaks onto pure black.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

---------

Co-authored-by: HAPI <noreply@hapi.run>
2026-06-18 10:15:34 +08:00

271 lines
9.1 KiB
TypeScript

import { useCallback, useEffect, useState } from 'react'
type ThemeMode = 'light' | 'dark' | 'oled'
type SurfaceKey = 'tool-group' | 'user-message'
export type ChatSurfaceColorPreset = 'default' | 'soft-blue' | 'soft-green' | 'soft-yellow'
export type ChatSurfaceColorPreference = 'default' | 'preset:soft-blue' | 'preset:soft-green' | 'preset:soft-yellow' | `custom:#${string}`
export const DEFAULT_CHAT_SURFACE_COLOR_PREFERENCE: ChatSurfaceColorPreference = 'default'
const TOOL_GROUP_BG_STORAGE_KEY = 'hapi-tool-group-bg'
const USER_MESSAGE_BG_STORAGE_KEY = 'hapi-user-message-bg'
const TOOL_GROUP_BG_CSS_VAR = '--app-tool-group-bg'
const USER_MESSAGE_BG_CSS_VAR = '--app-chat-user-surface-bg'
const DEFAULT_PICKER_COLOR = '#f2f4f6'
const PRESET_ACCENTS: Record<Exclude<ChatSurfaceColorPreset, 'default'>, string> = {
'soft-blue': '#7db7ff',
'soft-green': '#8fd19e',
'soft-yellow': '#f0d77a',
}
const THEME_BASES: Record<ThemeMode, Record<SurfaceKey, string>> = {
light: {
'tool-group': '#f2f4f6',
'user-message': '#f2f4f6',
},
dark: {
'tool-group': '#2b2f34',
'user-message': '#2b2f34',
},
oled: {
'tool-group': '#0e0e10',
'user-message': '#141414',
},
}
let initialized = false
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
}
}
function isHexColor(value: string): boolean {
return /^#[0-9a-f]{6}$/i.test(value)
}
function normalizeHexColor(value: string): string | null {
const normalized = value.trim().toLowerCase()
return isHexColor(normalized) ? normalized : null
}
function parseChatSurfaceColorPreference(raw: string | null): ChatSurfaceColorPreference {
if (raw === 'default' || raw === 'preset:soft-blue' || raw === 'preset:soft-green' || raw === 'preset:soft-yellow') {
return raw
}
if (typeof raw === 'string' && raw.startsWith('custom:')) {
const normalized = normalizeHexColor(raw.slice('custom:'.length))
if (normalized) {
return `custom:${normalized}` as ChatSurfaceColorPreference
}
}
return DEFAULT_CHAT_SURFACE_COLOR_PREFERENCE
}
function getThemeMode(): ThemeMode {
if (!isBrowser()) return 'light'
const theme = document.documentElement.getAttribute('data-theme')
if (theme === 'dark' || theme === 'oled') return theme
return 'light'
}
function hexToRgb(hex: string): [number, number, number] {
const normalized = hex.replace('#', '')
return [
Number.parseInt(normalized.slice(0, 2), 16),
Number.parseInt(normalized.slice(2, 4), 16),
Number.parseInt(normalized.slice(4, 6), 16),
]
}
function clampChannel(value: number): number {
return Math.max(0, Math.min(255, value))
}
function rgbToHex(r: number, g: number, b: number): string {
return `#${[r, g, b]
.map((channel) => clampChannel(channel).toString(16).padStart(2, '0'))
.join('')}`
}
function mixHex(base: string, accent: string, ratio: number): string {
const [br, bg, bb] = hexToRgb(base)
const [ar, ag, ab] = hexToRgb(accent)
return rgbToHex(
Math.round(br + (ar - br) * ratio),
Math.round(bg + (ag - bg) * ratio),
Math.round(bb + (ab - bb) * ratio),
)
}
function getAccentColor(pref: ChatSurfaceColorPreference): string | null {
if (pref === 'default') return null
if (pref.startsWith('preset:')) {
return PRESET_ACCENTS[pref.slice('preset:'.length) as keyof typeof PRESET_ACCENTS] ?? null
}
return normalizeHexColor(pref.slice('custom:'.length))
}
function resolveSurfaceColor(pref: ChatSurfaceColorPreference, theme: ThemeMode, surface: SurfaceKey): string | null {
const accent = getAccentColor(pref)
if (!accent) return null
const base = THEME_BASES[theme][surface]
const isDarkBase = theme !== 'light'
const ratio = pref.startsWith('custom:') ? (isDarkBase ? 0.22 : 0.34) : (isDarkBase ? 0.2 : 0.3)
return mixHex(base, accent, ratio)
}
function readStoredToolGroupBackground(): ChatSurfaceColorPreference {
return parseChatSurfaceColorPreference(safeGetItem(TOOL_GROUP_BG_STORAGE_KEY))
}
function readStoredUserMessageBackground(): ChatSurfaceColorPreference {
return parseChatSurfaceColorPreference(safeGetItem(USER_MESSAGE_BG_STORAGE_KEY))
}
function applyChatSurfaceVariables(): void {
if (!isBrowser()) return
const theme = getThemeMode()
const rootStyle = document.documentElement.style
const toolGroupColor = resolveSurfaceColor(readStoredToolGroupBackground(), theme, 'tool-group')
const userMessageColor = resolveSurfaceColor(readStoredUserMessageBackground(), theme, 'user-message')
if (toolGroupColor) rootStyle.setProperty(TOOL_GROUP_BG_CSS_VAR, toolGroupColor)
else rootStyle.removeProperty(TOOL_GROUP_BG_CSS_VAR)
if (userMessageColor) rootStyle.setProperty(USER_MESSAGE_BG_CSS_VAR, userMessageColor)
else rootStyle.removeProperty(USER_MESSAGE_BG_CSS_VAR)
}
function writePreference(key: string, value: ChatSurfaceColorPreference): void {
if (value === DEFAULT_CHAT_SURFACE_COLOR_PREFERENCE) {
safeRemoveItem(key)
} else {
safeSetItem(key, value)
}
applyChatSurfaceVariables()
}
export function getChatSurfaceColorPresetOptions(): ReadonlyArray<{ value: ChatSurfaceColorPreset; labelKey: string }> {
return [
{ value: 'default', labelKey: 'settings.chat.surfaceColor.default' },
{ value: 'soft-blue', labelKey: 'settings.chat.surfaceColor.softBlue' },
{ value: 'soft-green', labelKey: 'settings.chat.surfaceColor.softGreen' },
{ value: 'soft-yellow', labelKey: 'settings.chat.surfaceColor.softYellow' },
]
}
export function toPresetChatSurfaceColorPreference(preset: ChatSurfaceColorPreset): ChatSurfaceColorPreference {
return preset === 'default' ? 'default' : (`preset:${preset}` as ChatSurfaceColorPreference)
}
export function toCustomChatSurfaceColorPreference(value: string): ChatSurfaceColorPreference {
const normalized = normalizeHexColor(value) ?? DEFAULT_PICKER_COLOR
return `custom:${normalized}` as ChatSurfaceColorPreference
}
export function getToolGroupBackgroundPreference(): ChatSurfaceColorPreference {
return readStoredToolGroupBackground()
}
export function getUserMessageBackgroundPreference(): ChatSurfaceColorPreference {
return readStoredUserMessageBackground()
}
export function getChatSurfaceColorPickerValue(pref: ChatSurfaceColorPreference): string {
return getAccentColor(pref) ?? DEFAULT_PICKER_COLOR
}
export function initializeChatSurfaceColors(): void {
if (!isBrowser()) return
applyChatSurfaceVariables()
if (initialized) return
initialized = true
window.addEventListener('storage', applyChatSurfaceVariables)
const themeObserver = new MutationObserver(() => {
applyChatSurfaceVariables()
})
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme'],
})
}
export function useChatSurfaceColors(): {
toolGroupBackground: ChatSurfaceColorPreference
userMessageBackground: ChatSurfaceColorPreference
setToolGroupBackground: (value: ChatSurfaceColorPreference) => void
setUserMessageBackground: (value: ChatSurfaceColorPreference) => void
} {
const [toolGroupBackground, setToolGroupBackgroundState] = useState<ChatSurfaceColorPreference>(getToolGroupBackgroundPreference)
const [userMessageBackground, setUserMessageBackgroundState] = useState<ChatSurfaceColorPreference>(getUserMessageBackgroundPreference)
useEffect(() => {
if (!isBrowser()) return
const onStorage = (event: StorageEvent) => {
if (event.key !== TOOL_GROUP_BG_STORAGE_KEY && event.key !== USER_MESSAGE_BG_STORAGE_KEY) {
return
}
setToolGroupBackgroundState(readStoredToolGroupBackground())
setUserMessageBackgroundState(readStoredUserMessageBackground())
}
window.addEventListener('storage', onStorage)
return () => window.removeEventListener('storage', onStorage)
}, [])
const setToolGroupBackground = useCallback((value: ChatSurfaceColorPreference) => {
setToolGroupBackgroundState(value)
writePreference(TOOL_GROUP_BG_STORAGE_KEY, value)
}, [])
const setUserMessageBackground = useCallback((value: ChatSurfaceColorPreference) => {
setUserMessageBackgroundState(value)
writePreference(USER_MESSAGE_BG_STORAGE_KEY, value)
}, [])
return {
toolGroupBackground,
userMessageBackground,
setToolGroupBackground,
setUserMessageBackground,
}
}