From dfb1805fd6bed19736c2cf1011faeed3a833be1d Mon Sep 17 00:00:00 2001 From: SSU-WEI HUANG Date: Thu, 18 Jun 2026 10:15:34 +0800 Subject: [PATCH] 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 --------- Co-authored-by: HAPI --- web/src/App.tsx | 2 + .../assistant-ui/mermaid-diagram.tsx | 3 +- web/src/hooks/useChatSurfaceColors.ts | 13 +- web/src/hooks/useTheme.test.ts | 25 +- web/src/hooks/useTheme.ts | 14 +- web/src/hooks/useThemeColors.test.ts | 89 ++++ web/src/hooks/useThemeColors.ts | 393 ++++++++++++++++++ web/src/index.css | 85 +++- web/src/lib/locales/en.ts | 12 + web/src/lib/locales/zh-CN.ts | 12 + web/src/routes/settings/index.tsx | 61 +++ 11 files changed, 696 insertions(+), 13 deletions(-) create mode 100644 web/src/hooks/useThemeColors.test.ts create mode 100644 web/src/hooks/useThemeColors.ts diff --git a/web/src/App.tsx b/web/src/App.tsx index 2ff3e6c2..b8a8f010 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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() }, []) diff --git a/web/src/components/assistant-ui/mermaid-diagram.tsx b/web/src/components/assistant-ui/mermaid-diagram.tsx index e2b47583..35a77e17 100644 --- a/web/src/components/assistant-ui/mermaid-diagram.tsx +++ b/web/src/components/assistant-ui/mermaid-diagram.tsx @@ -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') { diff --git a/web/src/hooks/useChatSurfaceColors.ts b/web/src/hooks/useChatSurfaceColors.ts index f4402f16..466f5ef3 100644 --- a/web/src/hooks/useChatSurfaceColors.ts +++ b/web/src/hooks/useChatSurfaceColors.ts @@ -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> = { '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) } diff --git a/web/src/hooks/useTheme.test.ts b/web/src/hooks/useTheme.test.ts index 9468320a..150b4baa 100644 --- a/web/src/hooks/useTheme.test.ts +++ b/web/src/hooks/useTheme.test.ts @@ -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('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('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') + }) }) diff --git a/web/src/hooks/useTheme.ts b/web/src/hooks/useTheme.ts index 7fda73dd..de619189 100644 --- a/web/src/hooks/useTheme.ts +++ b/web/src/hooks/useTheme.ts @@ -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 = { 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', } } diff --git a/web/src/hooks/useThemeColors.test.ts b/web/src/hooks/useThemeColors.test.ts new file mode 100644 index 00000000..54931127 --- /dev/null +++ b/web/src/hooks/useThemeColors.test.ts @@ -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') + }) +}) diff --git a/web/src/hooks/useThemeColors.ts b/web/src/hooks/useThemeColors.ts new file mode 100644 index 00000000..826bc856 --- /dev/null +++ b/web/src/hooks/useThemeColors.ts @@ -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 +} + +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> = { + 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>>> + +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)[scheme] + if (typeof group !== 'object' || group === null) continue + + const cleaned: Partial> = {} + for (const key of THEME_COLOR_KEYS) { + const value = (group as Record)[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(getThemeScheme) + const [overrides, setOverrides] = useState>>( + () => 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, + } +} diff --git a/web/src/index.css b/web/src/index.css index d874750a..5833f9d7 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -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; diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 75c0ce03..7059a53a 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -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', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index d6288b19..a72e63b3 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -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': '会话折叠阈值', diff --git a/web/src/routes/settings/index.tsx b/web/src/routes/settings/index.tsx index dab1264f..e9946b31 100644 --- a/web/src/routes/settings/index.tsx +++ b/web/src/routes/settings/index.tsx @@ -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 ( +
+
+ {props.t('settings.display.themeColors.title')} + {hasAnyCustom && ( + + )} +
+
{props.t('settings.display.themeColors.description')}
+
+ {keys.map((key) => { + const value = getPickerValue(key.id) + const customized = isCustomized(key.id) + return ( +
+ {props.t(key.labelKey)} +
+ {customized && ( + + )} + +
+
+ ) + })} +
+
+ ) +} + export default function SettingsPage() { const { t, locale, setLocale } = useTranslation() const { api } = useAppContext() @@ -733,6 +793,7 @@ export default function SettingsPage() { )} +