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
+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"