diff --git a/e2e/__screenshots__/Desktop-Chrome/recover-arbitrary-dark-01-key-type.png b/e2e/__screenshots__/Desktop-Chrome/recover-arbitrary-dark-01-key-type.png index a3e10b4cf..30204e00e 100644 Binary files a/e2e/__screenshots__/Desktop-Chrome/recover-arbitrary-dark-01-key-type.png and b/e2e/__screenshots__/Desktop-Chrome/recover-arbitrary-dark-01-key-type.png differ diff --git a/e2e/__screenshots__/Desktop-Chrome/recover-arbitrary-dark-02-preview.png b/e2e/__screenshots__/Desktop-Chrome/recover-arbitrary-dark-02-preview.png index 771a4701c..d27bb9cd6 100644 Binary files a/e2e/__screenshots__/Desktop-Chrome/recover-arbitrary-dark-02-preview.png and b/e2e/__screenshots__/Desktop-Chrome/recover-arbitrary-dark-02-preview.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/recover-arbitrary-dark-01-key-type.png b/e2e/__screenshots__/Mobile-Chrome/recover-arbitrary-dark-01-key-type.png index 6abc59b97..6dcde75b8 100644 Binary files a/e2e/__screenshots__/Mobile-Chrome/recover-arbitrary-dark-01-key-type.png and b/e2e/__screenshots__/Mobile-Chrome/recover-arbitrary-dark-01-key-type.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/recover-arbitrary-dark-02-preview.png b/e2e/__screenshots__/Mobile-Chrome/recover-arbitrary-dark-02-preview.png index 1195838d5..cfa7222cb 100644 Binary files a/e2e/__screenshots__/Mobile-Chrome/recover-arbitrary-dark-02-preview.png and b/e2e/__screenshots__/Mobile-Chrome/recover-arbitrary-dark-02-preview.png differ diff --git a/src/main.tsx b/src/main.tsx index 581904a08..778aa1cb6 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -7,11 +7,14 @@ import i18n from './i18n' import { router } from './routes' import { ServiceProvider } from './services' import { MigrationProvider } from './contexts/MigrationContext' +import { preferencesActions } from './stores/preferences' import './styles/globals.css' const rootElement = document.getElementById('root') if (!rootElement) throw new Error('Root element not found') +preferencesActions.initialize() + createRoot(rootElement).render( diff --git a/src/stores/preferences.test.ts b/src/stores/preferences.test.ts index ce004d2d9..b1f6e7cf1 100644 --- a/src/stores/preferences.test.ts +++ b/src/stores/preferences.test.ts @@ -43,8 +43,55 @@ const localStorageMock = (() => { Object.defineProperty(global, 'localStorage', { value: localStorageMock }) +function mockMatchMedia(initialMatches: boolean) { + const listeners = new Set<(event: MediaQueryListEvent) => void>() + + const mql = { + matches: initialMatches, + media: '(prefers-color-scheme: dark)', + onchange: null, + addEventListener: (type: string, listener: (event: MediaQueryListEvent) => void) => { + if (type === 'change') listeners.add(listener) + }, + removeEventListener: (type: string, listener: (event: MediaQueryListEvent) => void) => { + if (type === 'change') listeners.delete(listener) + }, + addListener: (listener: (event: MediaQueryListEvent) => void) => { + listeners.add(listener) + }, + removeListener: (listener: (event: MediaQueryListEvent) => void) => { + listeners.delete(listener) + }, + dispatchEvent: () => true, + } + + Object.defineProperty(window, 'matchMedia', { + configurable: true, + value: vi.fn(() => mql as unknown as MediaQueryList), + }) + + return { + setMatches(next: boolean) { + mql.matches = next + const event = { matches: next } as MediaQueryListEvent + for (const listener of listeners) listener(event) + }, + } +} + describe('preferencesStore', () => { beforeEach(() => { + vi.clearAllMocks() + vi.unstubAllGlobals() + localStorageMock.clear() + document.documentElement.classList.remove('dark') + // Ensure any previous system-theme listener is detached (initialize() handles cleanup). + preferencesStore.setState(() => ({ + language: 'zh-CN', + currency: 'USD', + theme: 'light', + })) + preferencesActions.initialize() vi.clearAllMocks() localStorageMock.clear() // Reset store state @@ -122,6 +169,14 @@ describe('preferencesStore', () => { const savedValue = localStorageMock.setItem.mock.calls[0]![1] expect(JSON.parse(savedValue).theme).toBe('light') }) + + it('syncs document dark class', () => { + preferencesActions.setTheme('dark') + expect(document.documentElement.classList.contains('dark')).toBe(true) + + preferencesActions.setTheme('light') + expect(document.documentElement.classList.contains('dark')).toBe(false) + }) }) describe('initialize', () => { @@ -150,5 +205,21 @@ describe('preferencesStore', () => { expect(vi.mocked(i18n.changeLanguage)).not.toHaveBeenCalled() expect(document.documentElement.dir).toBe('ltr') }) + + it('applies system theme and tracks prefers-color-scheme changes', () => { + const media = mockMatchMedia(true) + + preferencesStore.setState(() => ({ + language: 'zh-CN', + currency: 'USD', + theme: 'system', + })) + + preferencesActions.initialize() + expect(document.documentElement.classList.contains('dark')).toBe(true) + + media.setMatches(false) + expect(document.documentElement.classList.contains('dark')).toBe(false) + }) }) }) diff --git a/src/stores/preferences.ts b/src/stores/preferences.ts index 45cf29444..9c3997233 100644 --- a/src/stores/preferences.ts +++ b/src/stores/preferences.ts @@ -6,6 +6,9 @@ import { type LanguageCode, languages, defaultLanguage, getLanguageDirection } f // Storage key const PREFERENCES_KEY = 'bfmpay_preferences' +type ThemePreference = 'light' | 'dark' | 'system' +type ResolvedTheme = Exclude + // Currency types export type CurrencyCode = 'USD' | 'CNY' | 'EUR' | 'JPY' | 'KRW' @@ -21,7 +24,7 @@ export const currencies: Record export interface PreferencesState { language: LanguageCode currency: CurrencyCode - theme: 'light' | 'dark' | 'system' + theme: ThemePreference } // Default state @@ -31,6 +34,68 @@ const defaultState: PreferencesState = { theme: 'system', } +let systemThemeMediaQuery: MediaQueryList | null = null +let systemThemeListener: ((event: MediaQueryListEvent) => void) | null = null + +function getSystemThemeMediaQuery(): MediaQueryList | null { + if (typeof window === 'undefined') return null + if (typeof window.matchMedia !== 'function') return null + return window.matchMedia('(prefers-color-scheme: dark)') +} + +function resolveTheme(theme: ThemePreference): ResolvedTheme { + if (theme === 'dark' || theme === 'light') return theme + const mediaQuery = getSystemThemeMediaQuery() + return mediaQuery?.matches ? 'dark' : 'light' +} + +function applyThemeToDocument(theme: ThemePreference): void { + if (typeof document === 'undefined') return + const resolved = resolveTheme(theme) + document.documentElement.classList.toggle('dark', resolved === 'dark') +} + +function stopSystemThemeSync(): void { + if (!systemThemeMediaQuery || !systemThemeListener) return + + if (typeof systemThemeMediaQuery.removeEventListener === 'function') { + systemThemeMediaQuery.removeEventListener('change', systemThemeListener) + } else if (typeof systemThemeMediaQuery.removeListener === 'function') { + systemThemeMediaQuery.removeListener(systemThemeListener) + } + + systemThemeMediaQuery = null + systemThemeListener = null +} + +function startSystemThemeSync(): void { + const mediaQuery = getSystemThemeMediaQuery() + if (!mediaQuery) return + + // Ensure idempotency: don't attach multiple listeners. + if (systemThemeMediaQuery === mediaQuery && systemThemeListener) return + + stopSystemThemeSync() + systemThemeMediaQuery = mediaQuery + systemThemeListener = (event) => { + if (preferencesStore.state.theme !== 'system') return + if (typeof document === 'undefined') return + document.documentElement.classList.toggle('dark', event.matches) + } + + if (typeof mediaQuery.addEventListener === 'function') { + mediaQuery.addEventListener('change', systemThemeListener) + } else if (typeof mediaQuery.addListener === 'function') { + mediaQuery.addListener(systemThemeListener) + } +} + +function syncTheme(theme: ThemePreference): void { + applyThemeToDocument(theme) + if (theme === 'system') startSystemThemeSync() + else stopSystemThemeSync() +} + // Load from localStorage function loadFromStorage(): PreferencesState { try { @@ -86,6 +151,7 @@ export const preferencesActions = { saveToStorage(newState) return newState }) + syncTheme(theme) }, /** 初始化 - 同步 i18n 语言和文字方向 */ @@ -96,6 +162,7 @@ export const preferencesActions = { } // Set document direction for RTL support document.documentElement.dir = getLanguageDirection(state.language) + syncTheme(state.theme) }, }