mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-08 07:17:39 +00:00
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:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user