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.
This commit is contained in:
METO
2026-03-08 10:47:53 +08:00
committed by GitHub
parent 446649576c
commit a0c35bc225
5 changed files with 192 additions and 5 deletions
+94 -1
View File
@@ -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<AppearancePreference>(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()
})
}
}
}
+4
View File
@@ -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',
+4
View File
@@ -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': '语音语言',
+23
View File
@@ -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(<SettingsPage />)
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(<SettingsPage />)
const calledKeys = spyT.mock.calls.map((call) => call[0])
expect(calledKeys).toContain('settings.display.appearance')
expect(calledKeys).toContain('settings.display.appearance.system')
})
})
+67 -4
View File
@@ -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<HTMLDivElement>(null)
const appearanceContainerRef = useRef<HTMLDivElement>(null)
const fontContainerRef = useRef<HTMLDivElement>(null)
const voiceContainerRef = useRef<HTMLDivElement>(null)
const { fontScale, setFontScale } = useFontScale()
const { appearance, setAppearance } = useAppearance()
// Voice language state - read from localStorage
const [voiceLanguage, setVoiceLanguage] = useState<string | null>(() => {
@@ -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 (
<div className="flex h-full flex-col">
@@ -223,6 +238,54 @@ export default function SettingsPage() {
<div className="px-3 py-2 text-xs font-semibold text-[var(--app-hint)] uppercase tracking-wide">
{t('settings.display.title')}
</div>
<div ref={appearanceContainerRef} className="relative">
<button
type="button"
onClick={() => setIsAppearanceOpen(!isAppearanceOpen)}
className="flex w-full items-center justify-between px-3 py-3 text-left transition-colors hover:bg-[var(--app-subtle-bg)]"
aria-expanded={isAppearanceOpen}
aria-haspopup="listbox"
>
<span className="text-[var(--app-fg)]">{t('settings.display.appearance')}</span>
<span className="flex items-center gap-1 text-[var(--app-hint)]">
<span>{t(currentAppearanceLabel)}</span>
<ChevronDownIcon className={`transition-transform ${isAppearanceOpen ? 'rotate-180' : ''}`} />
</span>
</button>
{isAppearanceOpen && (
<div
className="absolute right-3 top-full mt-1 min-w-[160px] rounded-lg border border-[var(--app-border)] bg-[var(--app-bg)] shadow-lg overflow-hidden z-50"
role="listbox"
aria-label={t('settings.display.appearance')}
>
{appearanceOptions.map((opt) => {
const isSelected = appearance === opt.value
return (
<button
key={opt.value}
type="button"
role="option"
aria-selected={isSelected}
onClick={() => handleAppearanceChange(opt.value)}
className={`flex items-center justify-between w-full px-3 py-2 text-base text-left transition-colors ${
isSelected
? 'text-[var(--app-link)] bg-[var(--app-subtle-bg)]'
: 'text-[var(--app-fg)] hover:bg-[var(--app-subtle-bg)]'
}`}
>
<span>{t(opt.labelKey)}</span>
{isSelected && (
<span className="ml-2 text-[var(--app-link)]">
<CheckIcon />
</span>
)}
</button>
)
})}
</div>
)}
</div>
<div ref={fontContainerRef} className="relative">
<button
type="button"