From a0c35bc2256840cca67a11fb56c58e3fb4811148 Mon Sep 17 00:00:00 2001 From: METO Date: Sun, 8 Mar 2026 10:47:53 +0800 Subject: [PATCH] feat(web): add appearance setting (follow system / dark / light) (#253) * feat(web): add appearance setting (follow system / dark / light) Add user-facing appearance preference to the settings page, allowing users to choose between Follow System, Dark, and Light themes. The preference is persisted to localStorage and takes priority over automatic detection in the existing theme pipeline. * fix(web): update theme on cross-tab appearance change The storage event handler only updated React state without calling updateScheme(), leaving data-theme and useTheme subscribers stale. * fix(web): move cross-tab appearance sync to global initializeTheme The storage event listener was inside useAppearance(), which is only mounted on the settings page. Other pages never received cross-tab theme updates. Move the listener into initializeTheme() so all pages respond to appearance changes from other tabs. --- web/src/hooks/useTheme.ts | 95 +++++++++++++++++++++++++- web/src/lib/locales/en.ts | 4 ++ web/src/lib/locales/zh-CN.ts | 4 ++ web/src/routes/settings/index.test.tsx | 23 +++++++ web/src/routes/settings/index.tsx | 71 +++++++++++++++++-- 5 files changed, 192 insertions(+), 5 deletions(-) diff --git a/web/src/hooks/useTheme.ts b/web/src/hooks/useTheme.ts index 25457ca9..6d177fcb 100644 --- a/web/src/hooks/useTheme.ts +++ b/web/src/hooks/useTheme.ts @@ -1,9 +1,65 @@ -import { useSyncExternalStore } from 'react' +import { useCallback, useEffect, useState, useSyncExternalStore } from 'react' import { getTelegramWebApp } from './useTelegram' type ColorScheme = 'light' | 'dark' +export type AppearancePreference = 'system' | 'dark' | 'light' + +const APPEARANCE_KEY = 'hapi-appearance' + +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 parseAppearance(raw: string | null): AppearancePreference { + if (raw === 'dark' || raw === 'light') return raw + return 'system' +} + +function getStoredAppearance(): AppearancePreference { + return parseAppearance(safeGetItem(APPEARANCE_KEY)) +} + +export function getAppearanceOptions(): ReadonlyArray<{ value: AppearancePreference; labelKey: string }> { + return [ + { value: 'system', labelKey: 'settings.display.appearance.system' }, + { value: 'dark', labelKey: 'settings.display.appearance.dark' }, + { value: 'light', labelKey: 'settings.display.appearance.light' }, + ] +} + function getColorScheme(): ColorScheme { + const pref = getStoredAppearance() + if (pref === 'dark' || pref === 'light') return pref + + // 'system': use Telegram → system preference → light const tg = getTelegramWebApp() if (tg?.colorScheme) { return tg.colorScheme === 'dark' ? 'dark' : 'light' @@ -68,6 +124,36 @@ export function useTheme(): { colorScheme: ColorScheme; isDark: boolean } { } } +export function useAppearance(): { appearance: AppearancePreference; setAppearance: (pref: AppearancePreference) => void } { + const [appearance, setAppearanceState] = useState(getStoredAppearance) + + useEffect(() => { + if (!isBrowser()) return + + const onStorage = (event: StorageEvent) => { + if (event.key !== APPEARANCE_KEY) return + setAppearanceState(parseAppearance(event.newValue)) + } + + window.addEventListener('storage', onStorage) + return () => window.removeEventListener('storage', onStorage) + }, []) + + const setAppearance = useCallback((pref: AppearancePreference) => { + setAppearanceState(pref) + + if (pref === 'system') { + safeRemoveItem(APPEARANCE_KEY) + } else { + safeSetItem(APPEARANCE_KEY, pref) + } + + updateScheme() + }, []) + + return { appearance, setAppearance } +} + // Call this once at app startup to ensure theme is applied and listeners attached export function initializeTheme(): void { currentScheme = getColorScheme() @@ -85,5 +171,12 @@ export function initializeTheme(): void { const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)') mediaQuery.addEventListener('change', updateScheme) } + + // Cross-tab appearance sync: update theme when another tab changes localStorage + if (typeof window !== 'undefined') { + window.addEventListener('storage', (event: StorageEvent) => { + if (event.key === APPEARANCE_KEY) updateScheme() + }) + } } } diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 34253918..9c67e8d2 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -249,6 +249,10 @@ export default { 'settings.language.title': 'Language', 'settings.language.label': 'Language', 'settings.display.title': 'Display', + 'settings.display.appearance': 'Appearance', + 'settings.display.appearance.system': 'Follow System', + 'settings.display.appearance.dark': 'Dark', + 'settings.display.appearance.light': 'Light', '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 9af5990a..fa218ed9 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -251,6 +251,10 @@ export default { 'settings.language.title': '语言', 'settings.language.label': '语言', 'settings.display.title': '显示', + 'settings.display.appearance': '外观', + 'settings.display.appearance.system': '跟随系统', + 'settings.display.appearance.dark': '深色', + 'settings.display.appearance.light': '浅色', 'settings.display.fontSize': '字体大小', 'settings.voice.title': '语音助手', 'settings.voice.language': '语音语言', diff --git a/web/src/routes/settings/index.test.tsx b/web/src/routes/settings/index.test.tsx index fe87ce41..22486831 100644 --- a/web/src/routes/settings/index.test.tsx +++ b/web/src/routes/settings/index.test.tsx @@ -22,6 +22,16 @@ vi.mock('@/hooks/useFontScale', () => ({ ], })) +// Mock useTheme hook +vi.mock('@/hooks/useTheme', () => ({ + useAppearance: () => ({ appearance: 'system', setAppearance: vi.fn() }), + getAppearanceOptions: () => [ + { value: 'system', labelKey: 'settings.display.appearance.system' }, + { value: 'dark', labelKey: 'settings.display.appearance.dark' }, + { value: 'light', labelKey: 'settings.display.appearance.light' }, + ], +})) + // Mock languages vi.mock('@/lib/languages', () => ({ getElevenLabsSupportedLanguages: () => [ @@ -98,4 +108,17 @@ describe('SettingsPage', () => { expect(calledKeys).toContain('settings.about.appVersion') expect(calledKeys).toContain('settings.about.protocolVersion') }) + + it('renders the Appearance setting', () => { + renderWithProviders() + expect(screen.getAllByText('Appearance').length).toBeGreaterThanOrEqual(1) + expect(screen.getAllByText('Follow System').length).toBeGreaterThanOrEqual(1) + }) + + it('uses correct i18n keys for Appearance setting', () => { + const spyT = renderWithSpyT() + const calledKeys = spyT.mock.calls.map((call) => call[0]) + expect(calledKeys).toContain('settings.display.appearance') + expect(calledKeys).toContain('settings.display.appearance.system') + }) }) diff --git a/web/src/routes/settings/index.tsx b/web/src/routes/settings/index.tsx index 0396064e..3c7be672 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 { useAppearance, getAppearanceOptions, type AppearancePreference } from '@/hooks/useTheme' import { PROTOCOL_VERSION } from '@hapi/protocol' const locales: { value: Locale; nativeLabel: string }[] = [ @@ -73,12 +74,15 @@ export default function SettingsPage() { const { t, locale, setLocale } = useTranslation() const goBack = useAppGoBack() const [isOpen, setIsOpen] = useState(false) + const [isAppearanceOpen, setIsAppearanceOpen] = useState(false) const [isFontOpen, setIsFontOpen] = useState(false) const [isVoiceOpen, setIsVoiceOpen] = useState(false) const containerRef = useRef(null) + const appearanceContainerRef = useRef(null) const fontContainerRef = useRef(null) const voiceContainerRef = useRef(null) const { fontScale, setFontScale } = useFontScale() + const { appearance, setAppearance } = useAppearance() // Voice language state - read from localStorage const [voiceLanguage, setVoiceLanguage] = useState(() => { @@ -86,7 +90,9 @@ export default function SettingsPage() { }) const fontScaleOptions = getFontScaleOptions() + const appearanceOptions = getAppearanceOptions() const currentLocale = locales.find((loc) => loc.value === locale) + const currentAppearanceLabel = appearanceOptions.find((opt) => opt.value === appearance)?.labelKey ?? 'settings.display.appearance.system' 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 handleAppearanceChange = (pref: AppearancePreference) => { + setAppearance(pref) + setIsAppearanceOpen(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 && !isAppearanceOpen && !isFontOpen && !isVoiceOpen) return const handleClickOutside = (event: MouseEvent) => { if (isOpen && containerRef.current && !containerRef.current.contains(event.target as Node)) { setIsOpen(false) } + if (isAppearanceOpen && appearanceContainerRef.current && !appearanceContainerRef.current.contains(event.target as Node)) { + setIsAppearanceOpen(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, isAppearanceOpen, isFontOpen, isVoiceOpen]) // Close on escape key useEffect(() => { - if (!isOpen && !isFontOpen && !isVoiceOpen) return + if (!isOpen && !isAppearanceOpen && !isFontOpen && !isVoiceOpen) return const handleEscape = (event: KeyboardEvent) => { if (event.key === 'Escape') { setIsOpen(false) + setIsAppearanceOpen(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, isAppearanceOpen, isFontOpen, isVoiceOpen]) return (
@@ -223,6 +238,54 @@ export default function SettingsPage() {
{t('settings.display.title')}
+
+ + + {isAppearanceOpen && ( +
+ {appearanceOptions.map((opt) => { + const isSelected = appearance === opt.value + return ( + + ) + })} +
+ )} +