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
+2
View File
@@ -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') {
+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,
}
}
+83 -2
View File
@@ -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;
+12
View File
@@ -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',
+12
View File
@@ -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': '会话折叠阈值',
+61
View File
@@ -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"