From b80ebc59657cf417a7cbd21002efdee05b733da0 Mon Sep 17 00:00:00 2001 From: gaius-codius Date: Wed, 18 Feb 2026 12:49:33 +0000 Subject: [PATCH 01/27] feat(web): add theme selector with Catpuccin as optional theme Restore the original dark theme as default and offer Catpuccin Mocha as an opt-in theme. Add a manual theme toggle in Settings > Display with System, Light, Dark, and Catpuccin options. Theme persists via localStorage and syncs across tabs. --- web/src/hooks/useTheme.ts | 173 +++++++++++++++++++++--------- web/src/index.css | 107 +++++++++++++++++- web/src/lib/locales/en.ts | 5 + web/src/lib/locales/zh-CN.ts | 5 + web/src/main.tsx | 2 + web/src/routes/settings/index.tsx | 71 +++++++++++- 6 files changed, 306 insertions(+), 57 deletions(-) diff --git a/web/src/hooks/useTheme.ts b/web/src/hooks/useTheme.ts index 25457ca99..73c0b336e 100644 --- a/web/src/hooks/useTheme.ts +++ b/web/src/hooks/useTheme.ts @@ -1,28 +1,72 @@ -import { useSyncExternalStore } from 'react' +import { useCallback, useEffect, useLayoutEffect, useState } from 'react' import { getTelegramWebApp } from './useTelegram' -type ColorScheme = 'light' | 'dark' +export type ThemePreference = 'system' | 'light' | 'dark' | 'catpuccin' +type ResolvedTheme = 'light' | 'dark' | 'catpuccin' -function getColorScheme(): ColorScheme { +const STORAGE_KEY = 'hapi-theme' + +function isBrowser(): boolean { + return typeof window !== 'undefined' && typeof document !== 'undefined' +} + +const useIsomorphicLayoutEffect = isBrowser() ? useLayoutEffect : useEffect + +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 parseThemePreference(raw: string | null): ThemePreference { + if (raw === 'light' || raw === 'dark' || raw === 'catpuccin') return raw + return 'system' +} + +function getSystemColorScheme(): 'light' | 'dark' { const tg = getTelegramWebApp() if (tg?.colorScheme) { return tg.colorScheme === 'dark' ? 'dark' : 'light' } - - // Fallback to system preference for browser environment - if (typeof window !== 'undefined' && window.matchMedia) { + if (isBrowser() && window.matchMedia) { return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' } - return 'light' } -function isIOS(): boolean { - return /iPad|iPhone|iPod/.test(navigator.userAgent) +function resolveTheme(pref: ThemePreference): ResolvedTheme { + if (pref === 'system') return getSystemColorScheme() + return pref +} + +function applyTheme(theme: ResolvedTheme): void { + if (!isBrowser()) return + document.documentElement.setAttribute('data-theme', theme) } -function applyTheme(scheme: ColorScheme): void { - document.documentElement.setAttribute('data-theme', scheme) +function isIOS(): boolean { + return /iPad|iPhone|iPod/.test(navigator.userAgent) } function applyPlatform(): void { @@ -31,59 +75,84 @@ function applyPlatform(): void { } } -// External store for theme state -let currentScheme: ColorScheme = getColorScheme() -const listeners = new Set<() => void>() - -// Apply theme immediately at module load (before React renders) -applyTheme(currentScheme) - -function subscribe(callback: () => void): () => void { - listeners.add(callback) - return () => listeners.delete(callback) +function getInitialPreference(): ThemePreference { + return parseThemePreference(safeGetItem(STORAGE_KEY)) } -function getSnapshot(): ColorScheme { - return currentScheme +export function initializeTheme(): void { + applyPlatform() + applyTheme(resolveTheme(getInitialPreference())) } -function updateScheme(): void { - const newScheme = getColorScheme() - if (newScheme !== currentScheme) { - currentScheme = newScheme - applyTheme(newScheme) - listeners.forEach((cb) => cb()) - } +export function getThemeOptions(): ReadonlyArray<{ value: ThemePreference; label: string }> { + return [ + { value: 'system', label: 'system' }, + { value: 'light', label: 'light' }, + { value: 'dark', label: 'dark' }, + { value: 'catpuccin', label: 'catpuccin' }, + ] } -// Track if theme listeners have been set up -let listenersInitialized = false +export function useTheme(): { + themePreference: ThemePreference + setThemePreference: (pref: ThemePreference) => void + isDark: boolean +} { + const [themePreference, setThemePreferenceState] = useState(getInitialPreference) -export function useTheme(): { colorScheme: ColorScheme; isDark: boolean } { - const colorScheme = useSyncExternalStore(subscribe, getSnapshot, getSnapshot) + const resolved = resolveTheme(themePreference) - return { - colorScheme, - isDark: colorScheme === 'dark', - } -} + useIsomorphicLayoutEffect(() => { + applyTheme(resolved) + }, [resolved]) -// Call this once at app startup to ensure theme is applied and listeners attached -export function initializeTheme(): void { - currentScheme = getColorScheme() - applyTheme(currentScheme) + // Listen for system color scheme changes (only matters when pref is 'system') + useEffect(() => { + if (themePreference !== 'system') return undefined - // Set up listeners only once (after SDK may have loaded) - if (!listenersInitialized) { - listenersInitialized = true const tg = getTelegramWebApp() if (tg?.onEvent) { - // Telegram theme changes - tg.onEvent('themeChanged', updateScheme) - } else if (typeof window !== 'undefined' && window.matchMedia) { - // Browser system preference changes - const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)') - mediaQuery.addEventListener('change', updateScheme) + const handler = () => applyTheme(resolveTheme('system')) + tg.onEvent('themeChanged', handler) + return () => tg.offEvent?.('themeChanged', handler) } + + if (isBrowser() && window.matchMedia) { + const mq = window.matchMedia('(prefers-color-scheme: dark)') + const handler = () => applyTheme(resolveTheme('system')) + mq.addEventListener('change', handler) + return () => mq.removeEventListener('change', handler) + } + + return undefined + }, [themePreference]) + + // Cross-tab sync + useEffect(() => { + if (!isBrowser()) return + + const onStorage = (event: StorageEvent) => { + if (event.key !== STORAGE_KEY) return + const next = parseThemePreference(event.newValue) + setThemePreferenceState(next) + } + + window.addEventListener('storage', onStorage) + return () => window.removeEventListener('storage', onStorage) + }, []) + + const setThemePreference = useCallback((pref: ThemePreference) => { + setThemePreferenceState(pref) + if (pref === 'system') { + safeRemoveItem(STORAGE_KEY) + } else { + safeSetItem(STORAGE_KEY, pref) + } + }, []) + + return { + themePreference, + setThemePreference, + isDark: resolved !== 'light', } } diff --git a/web/src/index.css b/web/src/index.css index d74b031bd..76d1c50fb 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -11,6 +11,7 @@ --app-banner-bg: var(--tg-theme-button-color, #111827); --app-banner-text: var(--tg-theme-button-text-color, #ffffff); --app-secondary-bg: var(--tg-theme-secondary-bg-color, #f3f4f6); + --app-selected-bg: rgba(59, 130, 246, 0.08); /* Theme-aware colors (light mode defaults) */ --app-border: rgba(0, 0, 0, 0.1); @@ -33,6 +34,9 @@ --app-git-untracked-color: #8E8E93; /* Badge colors (light) */ + --app-badge-info-bg: rgba(59, 130, 246, 0.15); + --app-badge-info-text: #1d4ed8; + --app-badge-info-border: rgba(59, 130, 246, 0.25); --app-badge-warning-bg: rgba(245, 158, 11, 0.2); --app-badge-warning-text: #b45309; --app-badge-warning-border: rgba(245, 158, 11, 0.3); @@ -43,6 +47,22 @@ --app-badge-error-text: #b91c1c; --app-badge-error-border: rgba(239, 68, 68, 0.3); + --app-perm-warning: #c2410c; + + /* Agent flavor colors (light) */ + --app-flavor-claude: #92400e; + --app-flavor-claude-bg: rgba(245, 158, 11, 0.12); + --app-flavor-claude-border: rgba(245, 158, 11, 0.25); + --app-flavor-codex: #065f46; + --app-flavor-codex-bg: rgba(16, 185, 129, 0.12); + --app-flavor-codex-border: rgba(16, 185, 129, 0.25); + --app-flavor-gemini: #1e40af; + --app-flavor-gemini-bg: rgba(59, 130, 246, 0.12); + --app-flavor-gemini-border: rgba(59, 130, 246, 0.25); + --app-flavor-opencode: #5b21b6; + --app-flavor-opencode-bg: rgba(139, 92, 246, 0.12); + --app-flavor-opencode-border: rgba(139, 92, 246, 0.25); + --app-font-scale: 1; } @@ -57,6 +77,7 @@ --app-banner-bg: var(--tg-theme-button-color, #3A3A3C); --app-banner-text: var(--tg-theme-button-text-color, #ffffff); --app-secondary-bg: var(--tg-theme-secondary-bg-color, #2C2C2E); + --app-selected-bg: rgba(59, 130, 246, 0.08); --app-border: rgba(255, 255, 255, 0.1); --app-divider: rgba(255, 255, 255, 0.08); @@ -78,6 +99,9 @@ --app-git-untracked-color: #9ca3af; /* Badge colors (dark) */ + --app-badge-info-bg: rgba(96, 165, 250, 0.2); + --app-badge-info-text: #60a5fa; + --app-badge-info-border: rgba(96, 165, 250, 0.3); --app-badge-warning-bg: rgba(251, 191, 36, 0.2); --app-badge-warning-text: #fbbf24; --app-badge-warning-border: rgba(251, 191, 36, 0.3); @@ -87,6 +111,85 @@ --app-badge-error-bg: rgba(248, 113, 113, 0.2); --app-badge-error-text: #fca5a5; --app-badge-error-border: rgba(248, 113, 113, 0.35); + + --app-perm-warning: #fb923c; + + /* Agent flavor colors (dark) */ + --app-flavor-claude: #fbbf24; + --app-flavor-claude-bg: rgba(245, 158, 11, 0.12); + --app-flavor-claude-border: rgba(245, 158, 11, 0.25); + --app-flavor-codex: #34d399; + --app-flavor-codex-bg: rgba(16, 185, 129, 0.12); + --app-flavor-codex-border: rgba(16, 185, 129, 0.25); + --app-flavor-gemini: #60a5fa; + --app-flavor-gemini-bg: rgba(59, 130, 246, 0.12); + --app-flavor-gemini-border: rgba(59, 130, 246, 0.25); + --app-flavor-opencode: #a78bfa; + --app-flavor-opencode-bg: rgba(139, 92, 246, 0.12); + --app-flavor-opencode-border: rgba(139, 92, 246, 0.25); +} + +[data-theme="catpuccin"] { + /* Primary colors — Catpuccin Mocha */ + --app-bg: #1e1e2e; + --app-fg: #cdd6f4; + --app-hint: #6c7086; + --app-link: #cdd6f4; + --app-button: #cdd6f4; + --app-button-text: #1e1e2e; + --app-banner-bg: #313244; + --app-banner-text: #cdd6f4; + --app-secondary-bg: #313244; + --app-selected-bg: rgba(137, 180, 250, 0.06); + + --app-border: rgba(255, 255, 255, 0.1); + --app-divider: rgba(255, 255, 255, 0.08); + --app-subtle-bg: rgba(255, 255, 255, 0.05); + --app-code-bg: #282c34; + --app-inline-code-bg: rgba(255, 255, 255, 0.1); + + /* Diff colors (dark) */ + --app-diff-added-bg: #0d2e1f; + --app-diff-added-text: #c9d1d9; + --app-diff-removed-bg: #3f1b23; + --app-diff-removed-text: #c9d1d9; + + /* Git status colors */ + --app-git-staged-color: #a6e3a1; + --app-git-unstaged-color: #fab387; + --app-git-deleted-color: #f38ba8; + --app-git-renamed-color: #89b4fa; + --app-git-untracked-color: #6c7086; + + /* Badge colors — Catpuccin Mocha */ + --app-badge-info-bg: rgba(137, 180, 250, 0.12); + --app-badge-info-text: #89b4fa; + --app-badge-info-border: rgba(137, 180, 250, 0.22); + --app-badge-warning-bg: rgba(250, 179, 135, 0.15); + --app-badge-warning-text: #fab387; + --app-badge-warning-border: rgba(250, 179, 135, 0.25); + --app-badge-success-bg: rgba(166, 227, 161, 0.15); + --app-badge-success-text: #a6e3a1; + --app-badge-success-border: rgba(166, 227, 161, 0.25); + --app-badge-error-bg: rgba(243, 139, 168, 0.15); + --app-badge-error-text: #f38ba8; + --app-badge-error-border: rgba(243, 139, 168, 0.25); + + --app-perm-warning: #fab387; + + /* Agent flavor colors — Catpuccin Mocha */ + --app-flavor-claude: #fab387; + --app-flavor-claude-bg: rgba(250, 179, 135, 0.10); + --app-flavor-claude-border: rgba(250, 179, 135, 0.20); + --app-flavor-codex: #a6e3a1; + --app-flavor-codex-bg: rgba(166, 227, 161, 0.10); + --app-flavor-codex-border: rgba(166, 227, 161, 0.20); + --app-flavor-gemini: #74c7ec; + --app-flavor-gemini-bg: rgba(116, 199, 236, 0.10); + --app-flavor-gemini-border: rgba(116, 199, 236, 0.20); + --app-flavor-opencode: #cba6f7; + --app-flavor-opencode-bg: rgba(203, 166, 247, 0.10); + --app-flavor-opencode-border: rgba(203, 166, 247, 0.20); } html { @@ -167,7 +270,9 @@ body { } html[data-theme="dark"] .shiki, -html[data-theme="dark"] .shiki span { +html[data-theme="dark"] .shiki span, +html[data-theme="catpuccin"] .shiki, +html[data-theme="catpuccin"] .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 832756124..be75d3ef8 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -241,6 +241,11 @@ export default { 'settings.language.title': 'Language', 'settings.language.label': 'Language', 'settings.display.title': 'Display', + 'settings.display.theme': 'Theme', + 'settings.display.theme.system': 'System', + 'settings.display.theme.light': 'Light', + 'settings.display.theme.dark': 'Dark', + 'settings.display.theme.catpuccin': 'Catpuccin', 'settings.display.fontSize': 'Font Size', 'settings.voice.title': 'Voice Assistant', 'settings.voice.language': 'Voice Language', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 48c75d513..141a46d31 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -243,6 +243,11 @@ export default { 'settings.language.title': '语言', 'settings.language.label': '语言', 'settings.display.title': '显示', + 'settings.display.theme': '主题', + 'settings.display.theme.system': '跟随系统', + 'settings.display.theme.light': '浅色', + 'settings.display.theme.dark': '深色', + 'settings.display.theme.catpuccin': 'Catpuccin', 'settings.display.fontSize': '字体大小', 'settings.voice.title': '语音助手', 'settings.voice.language': '语音语言', diff --git a/web/src/main.tsx b/web/src/main.tsx index b88697c06..3eba2a1f6 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -6,6 +6,7 @@ import { RouterProvider, createMemoryHistory } from '@tanstack/react-router' import './index.css' import { registerSW } from 'virtual:pwa-register' import { initializeFontScale } from '@/hooks/useFontScale' +import { initializeTheme } from '@/hooks/useTheme' import { getTelegramWebApp, isTelegramEnvironment, loadTelegramSdk } from './hooks/useTelegram' import { queryClient } from './lib/query-client' import { createAppRouter } from './router' @@ -34,6 +35,7 @@ function getInitialPath(): string { async function bootstrap() { initializeFontScale() + initializeTheme() // Only load Telegram SDK in Telegram environment (with 3s timeout) const isTelegram = isTelegramEnvironment() diff --git a/web/src/routes/settings/index.tsx b/web/src/routes/settings/index.tsx index 0396064e5..1403b90df 100644 --- a/web/src/routes/settings/index.tsx +++ b/web/src/routes/settings/index.tsx @@ -3,6 +3,7 @@ import { useTranslation, type Locale } from '@/lib/use-translation' import { useAppGoBack } from '@/hooks/useAppGoBack' import { getElevenLabsSupportedLanguages, getLanguageDisplayName, type Language } from '@/lib/languages' import { getFontScaleOptions, useFontScale, type FontScale } from '@/hooks/useFontScale' +import { getThemeOptions, useTheme, type ThemePreference } from '@/hooks/useTheme' import { PROTOCOL_VERSION } from '@hapi/protocol' const locales: { value: Locale; nativeLabel: string }[] = [ @@ -73,20 +74,25 @@ export default function SettingsPage() { const { t, locale, setLocale } = useTranslation() const goBack = useAppGoBack() const [isOpen, setIsOpen] = useState(false) + const [isThemeOpen, setIsThemeOpen] = useState(false) const [isFontOpen, setIsFontOpen] = useState(false) const [isVoiceOpen, setIsVoiceOpen] = useState(false) const containerRef = useRef(null) + const themeContainerRef = useRef(null) const fontContainerRef = useRef(null) const voiceContainerRef = useRef(null) const { fontScale, setFontScale } = useFontScale() + const { themePreference, setThemePreference } = useTheme() // Voice language state - read from localStorage const [voiceLanguage, setVoiceLanguage] = useState(() => { return localStorage.getItem('hapi-voice-lang') }) + const themeOptions = getThemeOptions() const fontScaleOptions = getFontScaleOptions() const currentLocale = locales.find((loc) => loc.value === locale) + const currentThemeLabel = t(`settings.display.theme.${themePreference}`) const currentFontScaleLabel = fontScaleOptions.find((opt) => opt.value === fontScale)?.label ?? '100%' const currentVoiceLanguage = voiceLanguages.find((lang) => lang.code === voiceLanguage) @@ -95,6 +101,11 @@ export default function SettingsPage() { setIsOpen(false) } + const handleThemeChange = (newTheme: ThemePreference) => { + setThemePreference(newTheme) + setIsThemeOpen(false) + } + const handleFontScaleChange = (newScale: FontScale) => { setFontScale(newScale) setIsFontOpen(false) @@ -112,12 +123,15 @@ export default function SettingsPage() { // Close dropdown when clicking outside useEffect(() => { - if (!isOpen && !isFontOpen && !isVoiceOpen) return + if (!isOpen && !isThemeOpen && !isFontOpen && !isVoiceOpen) return const handleClickOutside = (event: MouseEvent) => { if (isOpen && containerRef.current && !containerRef.current.contains(event.target as Node)) { setIsOpen(false) } + if (isThemeOpen && themeContainerRef.current && !themeContainerRef.current.contains(event.target as Node)) { + setIsThemeOpen(false) + } if (isFontOpen && fontContainerRef.current && !fontContainerRef.current.contains(event.target as Node)) { setIsFontOpen(false) } @@ -128,15 +142,16 @@ export default function SettingsPage() { document.addEventListener('mousedown', handleClickOutside) return () => document.removeEventListener('mousedown', handleClickOutside) - }, [isOpen, isFontOpen, isVoiceOpen]) + }, [isOpen, isThemeOpen, isFontOpen, isVoiceOpen]) // Close on escape key useEffect(() => { - if (!isOpen && !isFontOpen && !isVoiceOpen) return + if (!isOpen && !isThemeOpen && !isFontOpen && !isVoiceOpen) return const handleEscape = (event: KeyboardEvent) => { if (event.key === 'Escape') { setIsOpen(false) + setIsThemeOpen(false) setIsFontOpen(false) setIsVoiceOpen(false) } @@ -144,7 +159,7 @@ export default function SettingsPage() { document.addEventListener('keydown', handleEscape) return () => document.removeEventListener('keydown', handleEscape) - }, [isOpen, isFontOpen, isVoiceOpen]) + }, [isOpen, isThemeOpen, isFontOpen, isVoiceOpen]) return (
@@ -223,6 +238,54 @@ export default function SettingsPage() {
{t('settings.display.title')}
+
+ + + {isThemeOpen && ( +
+ {themeOptions.map((opt) => { + const isSelected = themePreference === opt.value + return ( + + ) + })} +
+ )} +
{QUICK_INPUT_ROWS.map((row, rowIndex) => (
+ + { + setPasteDialogOpen(open) + if (!open) { + setManualPasteText('') + } + }} + > + + + {t('terminal.paste.fallbackTitle')} + + {t('terminal.paste.fallbackDescription')} + + +