mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-07 06:52:28 +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:
@@ -4,6 +4,7 @@ import { useQueryClient } from '@tanstack/react-query'
|
||||
import { getTelegramWebApp, isTelegramApp } from '@/hooks/useTelegram'
|
||||
import { initializeChatSurfaceColors } from '@/hooks/useChatSurfaceColors'
|
||||
import { initializeTheme } from '@/hooks/useTheme'
|
||||
import { initializeThemeColors } from '@/hooks/useThemeColors'
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
import { useAuthSource } from '@/hooks/useAuthSource'
|
||||
import { useServerUrl } from '@/hooks/useServerUrl'
|
||||
@@ -72,6 +73,7 @@ function AppInner() {
|
||||
tg?.ready()
|
||||
tg?.expand()
|
||||
initializeTheme()
|
||||
initializeThemeColors()
|
||||
initializeChatSurfaceColors()
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -14,7 +14,8 @@ async function getMermaid() {
|
||||
|
||||
function resolveTheme() {
|
||||
if (typeof document === 'undefined') return 'light' as const
|
||||
return document.documentElement.dataset.theme === 'dark' ? 'dark' as const : 'light' as const
|
||||
const theme = document.documentElement.dataset.theme
|
||||
return theme === 'dark' || theme === 'oled' ? 'dark' as const : 'light' as const
|
||||
}
|
||||
|
||||
async function ensureMermaid(theme: 'light' | 'dark') {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
+83
-2
@@ -155,6 +155,84 @@
|
||||
--app-badge-error-border: rgba(248, 113, 113, 0.35);
|
||||
}
|
||||
|
||||
[data-theme="oled"] {
|
||||
/*
|
||||
* Pure-black canvas for OLED panels. Unlike Dark mode we never defer to
|
||||
* --tg-theme-* here, otherwise Telegram's gray backgrounds would defeat the
|
||||
* point of OLED black. Elevation comes from borders, not gray fills.
|
||||
*/
|
||||
--app-bg: #000000;
|
||||
--app-fg: #f5f5f7;
|
||||
--app-hint: #8e8e93;
|
||||
--app-link: #4ea1ff;
|
||||
--app-button: #4ea1ff;
|
||||
--app-button-text: #000000;
|
||||
--app-banner-bg: #1a1a1c;
|
||||
--app-banner-text: #f5f5f7;
|
||||
--app-secondary-bg: #0a0a0b;
|
||||
|
||||
--app-dialog-bg: #0c0c0e;
|
||||
--app-chat-user-bg: #141414;
|
||||
--app-chat-user-surface-bg: var(--app-chat-user-bg);
|
||||
--app-chat-user-fg: #f5f7fa;
|
||||
--app-chat-user-chip-bg: rgba(78, 161, 255, 0.18);
|
||||
--app-chat-user-chip-fg: #8fc4ff;
|
||||
--app-tool-card-bg: #0e0e10;
|
||||
--app-tool-group-bg: var(--app-tool-card-bg);
|
||||
--app-tool-card-hover-bg: #161618;
|
||||
--app-tool-card-accent: #b8c0cb;
|
||||
--app-tool-card-muted-action-fg: #6b6f78;
|
||||
--app-tool-card-subtitle: #9aa0aa;
|
||||
--app-code-header-bg: #161618;
|
||||
--app-code-header-fg: #c4cbd6;
|
||||
--app-code-copy-hover-bg: rgba(255, 255, 255, 0.08);
|
||||
--app-inline-code-border: transparent;
|
||||
--app-inline-code-fg: #f5f7fa;
|
||||
--app-md-quote-bg: #131316;
|
||||
--app-md-quote-border: #3a3a40;
|
||||
--app-md-quote-fg: #d7dde6;
|
||||
--app-md-table-bg: #0e0e10;
|
||||
--app-md-table-head-bg: #161618;
|
||||
--app-reasoning-bg: #0e0e10;
|
||||
--app-mermaid-node-fill: #16181d;
|
||||
--app-mermaid-node-border: #4ea1ff;
|
||||
--app-mermaid-cluster-fill: #101216;
|
||||
--app-mermaid-text: #edf1f5;
|
||||
--app-link-muted: rgba(255, 255, 255, 0.22);
|
||||
--app-scrollbar-thumb: rgba(196, 203, 214, 0.24);
|
||||
--app-scrollbar-thumb-hover: rgba(196, 203, 214, 0.4);
|
||||
|
||||
--app-border: rgba(255, 255, 255, 0.12);
|
||||
--app-divider: rgba(255, 255, 255, 0.1);
|
||||
--app-subtle-bg: rgba(255, 255, 255, 0.06);
|
||||
--app-code-bg: #0e0e10;
|
||||
--app-inline-code-bg: #1a1a1d;
|
||||
|
||||
/* Diff colors (oled) */
|
||||
--app-diff-added-bg: #07251a;
|
||||
--app-diff-added-text: #c9d1d9;
|
||||
--app-diff-removed-bg: #2c1217;
|
||||
--app-diff-removed-text: #c9d1d9;
|
||||
|
||||
/* Git status colors (reuse dark hues) */
|
||||
--app-git-staged-color: #4ade80;
|
||||
--app-git-unstaged-color: #f59e0b;
|
||||
--app-git-deleted-color: #f87171;
|
||||
--app-git-renamed-color: #60a5fa;
|
||||
--app-git-untracked-color: #9ca3af;
|
||||
|
||||
/* Badge colors (reuse dark hues, lower bg opacity) */
|
||||
--app-badge-warning-bg: rgba(251, 191, 36, 0.16);
|
||||
--app-badge-warning-text: #fbbf24;
|
||||
--app-badge-warning-border: rgba(251, 191, 36, 0.28);
|
||||
--app-badge-success-bg: rgba(74, 222, 128, 0.16);
|
||||
--app-badge-success-text: #4ade80;
|
||||
--app-badge-success-border: rgba(74, 222, 128, 0.28);
|
||||
--app-badge-error-bg: rgba(248, 113, 113, 0.16);
|
||||
--app-badge-error-text: #fca5a5;
|
||||
--app-badge-error-border: rgba(248, 113, 113, 0.3);
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: calc(100% * var(--app-font-scale, 1));
|
||||
color-scheme: light;
|
||||
@@ -162,7 +240,8 @@ html {
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
[data-theme="dark"],
|
||||
[data-theme="oled"] {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
@@ -436,7 +515,9 @@ body {
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .shiki,
|
||||
html[data-theme="dark"] .shiki span {
|
||||
html[data-theme="dark"] .shiki span,
|
||||
html[data-theme="oled"] .shiki,
|
||||
html[data-theme="oled"] .shiki span {
|
||||
color: var(--shiki-dark) !important;
|
||||
font-style: var(--shiki-dark-font-style) !important;
|
||||
font-weight: var(--shiki-dark-font-weight) !important;
|
||||
|
||||
@@ -513,7 +513,19 @@ export default {
|
||||
'settings.display.appearance': 'Appearance',
|
||||
'settings.display.appearance.system': 'Follow System',
|
||||
'settings.display.appearance.dark': 'Dark',
|
||||
'settings.display.appearance.oled': 'OLED Black',
|
||||
'settings.display.appearance.light': 'Light',
|
||||
'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',
|
||||
'settings.display.themeColors.resetAll': 'Reset all',
|
||||
'settings.display.themeColors.key.background': 'Background',
|
||||
'settings.display.themeColors.key.surface': 'Cards & surfaces',
|
||||
'settings.display.themeColors.key.text': 'Text',
|
||||
'settings.display.themeColors.key.hint': 'Muted text',
|
||||
'settings.display.themeColors.key.accent': 'Accent & links',
|
||||
'settings.display.themeColors.key.border': 'Borders',
|
||||
'settings.display.themeColors.key.userBubble': 'Your message bubble',
|
||||
'settings.display.fontSize': 'Font Size',
|
||||
'settings.display.terminalFontSize': 'Terminal Font Size',
|
||||
'settings.display.sessionPreviewLimit': 'Sessions Before Folding',
|
||||
|
||||
@@ -517,7 +517,19 @@ export default {
|
||||
'settings.display.appearance': '外观',
|
||||
'settings.display.appearance.system': '跟随系统',
|
||||
'settings.display.appearance.dark': '深色',
|
||||
'settings.display.appearance.oled': 'OLED 纯黑',
|
||||
'settings.display.appearance.light': '浅色',
|
||||
'settings.display.themeColors.title': '自定义配色',
|
||||
'settings.display.themeColors.description': '应用于当前外观。切换外观可分别自定义每种配色。',
|
||||
'settings.display.themeColors.reset': '重置',
|
||||
'settings.display.themeColors.resetAll': '全部重置',
|
||||
'settings.display.themeColors.key.background': '背景',
|
||||
'settings.display.themeColors.key.surface': '卡片与表面',
|
||||
'settings.display.themeColors.key.text': '文字',
|
||||
'settings.display.themeColors.key.hint': '次要文字',
|
||||
'settings.display.themeColors.key.accent': '强调与链接',
|
||||
'settings.display.themeColors.key.border': '边框',
|
||||
'settings.display.themeColors.key.userBubble': '你的消息气泡',
|
||||
'settings.display.fontSize': '字体大小',
|
||||
'settings.display.terminalFontSize': '终端字体大小',
|
||||
'settings.display.sessionPreviewLimit': '会话折叠阈值',
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
type ChatSurfaceColorPreset,
|
||||
} from '@/hooks/useChatSurfaceColors'
|
||||
import { useAppearance, getAppearanceOptions, type AppearancePreference } from '@/hooks/useTheme'
|
||||
import { useThemeColors, type ThemeColorKeyId } from '@/hooks/useThemeColors'
|
||||
import { PROTOCOL_VERSION } from '@hapi/protocol'
|
||||
import { VoiceRespondsControls, VoiceSoundsControls, VoicePersonaControls, VoiceDiagnosticsControls } from '@/components/settings/VoiceAdvancedControls'
|
||||
|
||||
@@ -308,6 +309,65 @@ function ChatSurfaceColorControl(props: {
|
||||
)
|
||||
}
|
||||
|
||||
function ThemeColorControl(props: { t: (key: string) => string }) {
|
||||
const { keys, getPickerValue, isCustomized, hasAnyCustom, setColor, resetColor, resetAll } = useThemeColors()
|
||||
|
||||
return (
|
||||
<div className="border-t border-[var(--app-divider)] px-3 py-3">
|
||||
<div className="mb-1 flex items-center justify-between gap-3">
|
||||
<span className="text-[var(--app-fg)]">{props.t('settings.display.themeColors.title')}</span>
|
||||
{hasAnyCustom && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetAll}
|
||||
className="text-sm text-[var(--app-link)] transition-colors hover:underline"
|
||||
>
|
||||
{props.t('settings.display.themeColors.resetAll')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mb-3 text-sm text-[var(--app-hint)]">{props.t('settings.display.themeColors.description')}</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{keys.map((key) => {
|
||||
const value = getPickerValue(key.id)
|
||||
const customized = isCustomized(key.id)
|
||||
return (
|
||||
<div key={key.id} className="flex items-center justify-between gap-3">
|
||||
<span className="text-sm text-[var(--app-fg)]">{props.t(key.labelKey)}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{customized && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => resetColor(key.id as ThemeColorKeyId)}
|
||||
className="text-xs text-[var(--app-hint)] transition-colors hover:text-[var(--app-link)]"
|
||||
>
|
||||
{props.t('settings.display.themeColors.reset')}
|
||||
</button>
|
||||
)}
|
||||
<label
|
||||
className={`inline-flex items-center rounded-xl border px-2 py-1 transition-colors ${
|
||||
customized
|
||||
? 'border-[var(--app-link)] bg-[var(--app-subtle-bg)]'
|
||||
: 'border-[var(--app-border)] bg-[var(--app-bg)]'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
aria-label={props.t(key.labelKey)}
|
||||
type="color"
|
||||
value={value}
|
||||
onChange={(event) => setColor(key.id as ThemeColorKeyId, event.target.value)}
|
||||
className="h-8 w-11 cursor-pointer appearance-none border-0 bg-transparent p-0"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { t, locale, setLocale } = useTranslation()
|
||||
const { api } = useAppContext()
|
||||
@@ -733,6 +793,7 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ThemeColorControl t={t} />
|
||||
<div ref={fontContainerRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
Reference in New Issue
Block a user