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>
This commit is contained in:
SSU-WEI HUANG
2026-06-18 10:15:34 +08:00
committed by GitHub
co-authored by HAPI
parent ce67823fc3
commit dfb1805fd6
11 changed files with 696 additions and 13 deletions
+10 -3
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from 'react'
type ThemeMode = 'light' | 'dark'
type ThemeMode = 'light' | 'dark' | 'oled'
type SurfaceKey = 'tool-group' | 'user-message'
export type ChatSurfaceColorPreset = 'default' | 'soft-blue' | 'soft-green' | 'soft-yellow'
@@ -29,6 +29,10 @@ const THEME_BASES: Record<ThemeMode, Record<SurfaceKey, string>> = {
'tool-group': '#2b2f34',
'user-message': '#2b2f34',
},
oled: {
'tool-group': '#0e0e10',
'user-message': '#141414',
},
}
let initialized = false
@@ -90,7 +94,9 @@ function parseChatSurfaceColorPreference(raw: string | null): ChatSurfaceColorPr
function getThemeMode(): ThemeMode {
if (!isBrowser()) return 'light'
return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light'
const theme = document.documentElement.getAttribute('data-theme')
if (theme === 'dark' || theme === 'oled') return theme
return 'light'
}
function hexToRgb(hex: string): [number, number, number] {
@@ -135,7 +141,8 @@ function resolveSurfaceColor(pref: ChatSurfaceColorPreference, theme: ThemeMode,
if (!accent) return null
const base = THEME_BASES[theme][surface]
const ratio = pref.startsWith('custom:') ? (theme === 'dark' ? 0.22 : 0.34) : (theme === 'dark' ? 0.2 : 0.3)
const isDarkBase = theme !== 'light'
const ratio = pref.startsWith('custom:') ? (isDarkBase ? 0.22 : 0.34) : (isDarkBase ? 0.2 : 0.3)
return mixHex(base, accent, ratio)
}
+24 -1
View File
@@ -1,6 +1,6 @@
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it } from 'vitest'
import { getThemeColor, initializeTheme, useAppearance } from '@/hooks/useTheme'
import { getAppearanceOptions, getThemeColor, initializeTheme, useAppearance } from '@/hooks/useTheme'
describe('useTheme', () => {
beforeEach(() => {
@@ -43,4 +43,27 @@ describe('useTheme', () => {
expect(document.documentElement).toHaveAttribute('data-theme', 'light')
expect(document.querySelector<HTMLMetaElement>('meta[name="theme-color"]')?.content).toBe(getThemeColor('light'))
})
it('exposes OLED Black as a selectable appearance option', () => {
expect(getAppearanceOptions().some((opt) => opt.value === 'oled')).toBe(true)
})
it('applies the OLED appearance with a pure-black browser theme color', () => {
localStorage.setItem('hapi-appearance', 'oled')
initializeTheme()
expect(document.documentElement).toHaveAttribute('data-theme', 'oled')
expect(getThemeColor('oled')).toBe('#000000')
expect(document.querySelector<HTMLMetaElement>('meta[name="theme-color"]')?.content).toBe('#000000')
})
it('does not auto-select OLED for the system appearance', () => {
// No stored appearance => system; system must resolve to light/dark, never OLED.
initializeTheme()
const theme = document.documentElement.getAttribute('data-theme')
expect(theme === 'light' || theme === 'dark').toBe(true)
expect(theme).not.toBe('oled')
})
})
+8 -6
View File
@@ -1,14 +1,15 @@
import { useCallback, useEffect, useState, useSyncExternalStore } from 'react'
import { getTelegramWebApp } from './useTelegram'
type ColorScheme = 'light' | 'dark'
type ColorScheme = 'light' | 'dark' | 'oled'
export type AppearancePreference = 'system' | 'dark' | 'light'
export type AppearancePreference = 'system' | 'dark' | 'light' | 'oled'
const APPEARANCE_KEY = 'hapi-appearance'
const THEME_COLORS: Record<ColorScheme, string> = {
light: '#ffffff',
dark: '#1c1c1e',
oled: '#000000',
}
function isBrowser(): boolean {
@@ -43,7 +44,7 @@ function safeRemoveItem(key: string): void {
}
function parseAppearance(raw: string | null): AppearancePreference {
if (raw === 'dark' || raw === 'light') return raw
if (raw === 'dark' || raw === 'light' || raw === 'oled') return raw
return 'system'
}
@@ -55,15 +56,16 @@ export function getAppearanceOptions(): ReadonlyArray<{ value: AppearancePrefere
return [
{ value: 'system', labelKey: 'settings.display.appearance.system' },
{ value: 'dark', labelKey: 'settings.display.appearance.dark' },
{ value: 'oled', labelKey: 'settings.display.appearance.oled' },
{ value: 'light', labelKey: 'settings.display.appearance.light' },
]
}
function getColorScheme(): ColorScheme {
const pref = getStoredAppearance()
if (pref === 'dark' || pref === 'light') return pref
if (pref === 'dark' || pref === 'light' || pref === 'oled') return pref
// 'system': use Telegram → system preference → light
// 'system': use Telegram → system preference → light (never auto-selects OLED)
const tg = getTelegramWebApp()
if (tg?.colorScheme) {
return tg.colorScheme === 'dark' ? 'dark' : 'light'
@@ -143,7 +145,7 @@ export function useTheme(): { colorScheme: ColorScheme; isDark: boolean } {
return {
colorScheme,
isDark: colorScheme === 'dark',
isDark: colorScheme === 'dark' || colorScheme === 'oled',
}
}
+89
View File
@@ -0,0 +1,89 @@
import { act, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
applyThemeColors,
initializeThemeColors,
THEME_COLOR_KEYS,
useThemeColors,
} from '@/hooks/useThemeColors'
function setScheme(scheme: string): void {
document.documentElement.setAttribute('data-theme', scheme)
}
describe('useThemeColors', () => {
beforeEach(() => {
localStorage.clear()
document.documentElement.removeAttribute('data-theme')
document.documentElement.removeAttribute('style')
})
afterEach(() => {
vi.restoreAllMocks()
})
it('exposes a curated set of key colors including background and accent', () => {
const ids = THEME_COLOR_KEYS.map((key) => key.id)
expect(THEME_COLOR_KEYS.length).toBeGreaterThanOrEqual(6)
expect(ids).toContain('background')
expect(ids).toContain('accent')
})
it('writes the mapped CSS variables when a key color is customized', () => {
setScheme('oled')
const { result } = renderHook(() => useThemeColors())
act(() => result.current.setColor('background', '#123456'))
expect(document.documentElement.style.getPropertyValue('--app-bg').trim()).toBe('#123456')
expect(localStorage.getItem('hapi-theme-colors')).toContain('123456')
})
it('ignores invalid hex values', () => {
setScheme('oled')
const { result } = renderHook(() => useThemeColors())
act(() => result.current.setColor('background', 'not-a-color'))
expect(document.documentElement.style.getPropertyValue('--app-bg')).toBe('')
expect(localStorage.getItem('hapi-theme-colors')).toBeNull()
})
it('scopes custom colors per appearance', () => {
setScheme('dark')
const { result } = renderHook(() => useThemeColors())
act(() => result.current.setColor('background', '#111111'))
expect(document.documentElement.style.getPropertyValue('--app-bg').trim()).toBe('#111111')
// A dark-only override must not leak into the light appearance.
act(() => setScheme('light'))
applyThemeColors()
expect(document.documentElement.style.getPropertyValue('--app-bg')).toBe('')
// Switching back restores it.
act(() => setScheme('dark'))
applyThemeColors()
expect(document.documentElement.style.getPropertyValue('--app-bg').trim()).toBe('#111111')
})
it('resets a key color back to the theme default', () => {
setScheme('oled')
const { result } = renderHook(() => useThemeColors())
act(() => result.current.setColor('background', '#123456'))
act(() => result.current.resetColor('background'))
expect(document.documentElement.style.getPropertyValue('--app-bg')).toBe('')
expect(localStorage.getItem('hapi-theme-colors')).toBeNull()
})
it('reapplies stored colors for the active appearance during initialization', () => {
localStorage.setItem('hapi-theme-colors', JSON.stringify({ oled: { background: '#0b0b0b' } }))
setScheme('oled')
initializeThemeColors()
expect(document.documentElement.style.getPropertyValue('--app-bg').trim()).toBe('#0b0b0b')
})
})
+393
View File
@@ -0,0 +1,393 @@
import { useCallback, useEffect, useState } from 'react'
/**
* Per-appearance "key color" customization.
*
* Unlike {@link useChatSurfaceColors} (which tints two chat surfaces), this hook
* exposes a curated set of key colors. Each key cascades to a small group of
* `--app-*` tokens (and a couple of derived ones) so the whole palette stays
* coherent without a 60-token editor. Overrides are scoped per appearance
* (`light | dark | oled`) because a color that reads well on white will not on
* pure black.
*/
export type ThemeScheme = 'light' | 'dark' | 'oled'
export type ThemeColorKeyId =
| 'background'
| 'surface'
| 'text'
| 'hint'
| 'accent'
| 'border'
| 'userBubble'
interface ThemeColorKey {
id: ThemeColorKeyId
labelKey: string
/** Tokens set directly to the chosen hex. */
targets: readonly string[]
/** Tokens computed from the chosen hex (cleared together with the base). */
derivedTargets?: readonly string[]
derive?: (hex: string, scheme: ThemeScheme) => Record<string, string>
}
const STORAGE_KEY = 'hapi-theme-colors'
export const THEME_COLOR_KEYS: readonly ThemeColorKey[] = [
{
id: 'background',
labelKey: 'settings.display.themeColors.key.background',
targets: ['--app-bg'],
},
{
id: 'surface',
labelKey: 'settings.display.themeColors.key.surface',
targets: [
'--app-secondary-bg',
'--app-dialog-bg',
'--app-tool-card-bg',
'--app-reasoning-bg',
'--app-md-table-bg',
'--app-code-bg',
'--app-inline-code-bg',
],
derivedTargets: ['--app-tool-card-hover-bg'],
derive: (hex, scheme) => ({
'--app-tool-card-hover-bg': mixHex(hex, contrastColor(scheme), 0.08),
}),
},
{
id: 'text',
labelKey: 'settings.display.themeColors.key.text',
targets: ['--app-fg', '--app-chat-user-fg', '--app-inline-code-fg'],
},
{
id: 'hint',
labelKey: 'settings.display.themeColors.key.hint',
targets: ['--app-hint', '--app-tool-card-subtitle'],
},
{
id: 'accent',
labelKey: 'settings.display.themeColors.key.accent',
targets: ['--app-link', '--app-chat-user-chip-fg'],
},
{
id: 'border',
labelKey: 'settings.display.themeColors.key.border',
targets: ['--app-border', '--app-divider'],
},
{
id: 'userBubble',
labelKey: 'settings.display.themeColors.key.userBubble',
targets: ['--app-chat-user-bg'],
},
]
/** Fallback swatch values that mirror the CSS theme defaults (no override set). */
const DEFAULT_HEX: Record<ThemeScheme, Record<ThemeColorKeyId, string>> = {
light: {
background: '#ffffff',
surface: '#f2f4f6',
text: '#111827',
hint: '#6b7280',
accent: '#111827',
border: '#e2e8f0',
userBubble: '#f2f4f6',
},
dark: {
background: '#1c1c1e',
surface: '#2b2f34',
text: '#ffffff',
hint: '#8e8e93',
accent: '#ffffff',
border: '#2a2a2c',
userBubble: '#2b2f34',
},
oled: {
background: '#000000',
surface: '#0e0e10',
text: '#f5f5f7',
hint: '#8e8e93',
accent: '#4ea1ff',
border: '#1f1f22',
userBubble: '#141414',
},
}
type StoredThemeColors = Partial<Record<ThemeScheme, Partial<Record<ThemeColorKeyId, string>>>>
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)
}
export function normalizeThemeColor(value: string): string | null {
const normalized = value.trim().toLowerCase()
return isHexColor(normalized) ? normalized : null
}
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 contrastColor(scheme: ThemeScheme): string {
return scheme === 'light' ? '#000000' : '#ffffff'
}
export function getThemeScheme(): ThemeScheme {
if (!isBrowser()) return 'light'
const theme = document.documentElement.getAttribute('data-theme')
if (theme === 'dark' || theme === 'oled') return theme
return 'light'
}
function getStoredThemeColors(): StoredThemeColors {
const raw = safeGetItem(STORAGE_KEY)
if (!raw) return {}
let parsed: unknown
try {
parsed = JSON.parse(raw)
} catch {
return {}
}
if (typeof parsed !== 'object' || parsed === null) return {}
const result: StoredThemeColors = {}
for (const scheme of ['light', 'dark', 'oled'] as const) {
const group = (parsed as Record<string, unknown>)[scheme]
if (typeof group !== 'object' || group === null) continue
const cleaned: Partial<Record<ThemeColorKeyId, string>> = {}
for (const key of THEME_COLOR_KEYS) {
const value = (group as Record<string, unknown>)[key.id]
if (typeof value === 'string') {
const normalized = normalizeThemeColor(value)
if (normalized) cleaned[key.id] = normalized
}
}
if (Object.keys(cleaned).length > 0) result[scheme] = cleaned
}
return result
}
function writeStoredThemeColors(value: StoredThemeColors): void {
const hasAny = Object.values(value).some((group) => group && Object.keys(group).length > 0)
if (!hasAny) {
safeRemoveItem(STORAGE_KEY)
} else {
safeSetItem(STORAGE_KEY, JSON.stringify(value))
}
}
/** Re-apply the stored overrides for the currently-active appearance. */
export function applyThemeColors(): void {
if (!isBrowser()) return
const scheme = getThemeScheme()
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
for (const cssVar of key.targets) {
if (hex) rootStyle.setProperty(cssVar, hex)
else rootStyle.removeProperty(cssVar)
}
if (key.derivedTargets) {
const derived = hex && key.derive ? key.derive(hex, scheme) : {}
for (const cssVar of key.derivedTargets) {
const value = derived[cssVar]
if (value) rootStyle.setProperty(cssVar, value)
else rootStyle.removeProperty(cssVar)
}
}
}
}
export function getThemeColorPickerValue(scheme: ThemeScheme, id: ThemeColorKeyId): string {
const override = getStoredThemeColors()[scheme]?.[id]
return override ?? DEFAULT_HEX[scheme][id]
}
export function initializeThemeColors(): void {
if (!isBrowser()) return
applyThemeColors()
if (initialized) return
initialized = true
window.addEventListener('storage', (event: StorageEvent) => {
if (event.key === STORAGE_KEY) applyThemeColors()
})
const themeObserver = new MutationObserver(() => {
applyThemeColors()
})
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme'],
})
}
export function useThemeColors(): {
scheme: ThemeScheme
keys: readonly ThemeColorKey[]
getPickerValue: (id: ThemeColorKeyId) => string
isCustomized: (id: ThemeColorKeyId) => boolean
hasAnyCustom: boolean
setColor: (id: ThemeColorKeyId, value: string) => void
resetColor: (id: ThemeColorKeyId) => void
resetAll: () => void
} {
const [scheme, setScheme] = useState<ThemeScheme>(getThemeScheme)
const [overrides, setOverrides] = useState<Partial<Record<ThemeColorKeyId, string>>>(
() => getStoredThemeColors()[getThemeScheme()] ?? {},
)
useEffect(() => {
if (!isBrowser()) return
const refresh = () => {
const next = getThemeScheme()
setScheme(next)
setOverrides(getStoredThemeColors()[next] ?? {})
}
const themeObserver = new MutationObserver(refresh)
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme'],
})
const onStorage = (event: StorageEvent) => {
if (event.key === STORAGE_KEY) refresh()
}
window.addEventListener('storage', onStorage)
return () => {
themeObserver.disconnect()
window.removeEventListener('storage', onStorage)
}
}, [])
const setColor = useCallback((id: ThemeColorKeyId, value: string) => {
const normalized = normalizeThemeColor(value)
if (!normalized) return
const activeScheme = getThemeScheme()
const all = getStoredThemeColors()
all[activeScheme] = { ...(all[activeScheme] ?? {}), [id]: normalized }
writeStoredThemeColors(all)
applyThemeColors()
setScheme(activeScheme)
setOverrides(all[activeScheme] ?? {})
}, [])
const resetColor = useCallback((id: ThemeColorKeyId) => {
const activeScheme = getThemeScheme()
const all = getStoredThemeColors()
const group = all[activeScheme]
if (group) {
delete group[id]
if (Object.keys(group).length === 0) delete all[activeScheme]
}
writeStoredThemeColors(all)
applyThemeColors()
setScheme(activeScheme)
setOverrides(all[activeScheme] ?? {})
}, [])
const resetAll = useCallback(() => {
const activeScheme = getThemeScheme()
const all = getStoredThemeColors()
delete all[activeScheme]
writeStoredThemeColors(all)
applyThemeColors()
setScheme(activeScheme)
setOverrides({})
}, [])
const getPickerValue = useCallback(
(id: ThemeColorKeyId) => overrides[id] ?? DEFAULT_HEX[scheme][id],
[overrides, scheme],
)
const isCustomized = useCallback((id: ThemeColorKeyId) => Boolean(overrides[id]), [overrides])
return {
scheme,
keys: THEME_COLOR_KEYS,
getPickerValue,
isCustomized,
hasAnyCustom: Object.keys(overrides).length > 0,
setColor,
resetColor,
resetAll,
}
}