feat(theme): 主题三态偏好模型,默认跟随系统自动切换暗黑模式
- 新增 src/lib/theme.ts:三态偏好单一真源
(system/light/dark,无存储值默认跟随系统)
- ThemeToggle 改三态循环(Monitor→Sun→Moon):
实时监听 prefers-color-scheme(仅 system 档生效)
+ storage 跨标签页同步;尺寸位置不变
- layout.tsx FOUC 内联脚本支持 system 档,
首帧前解析避免闪烁
- e2e 视觉回归 L2 改用 test.use({ colorScheme }) 驱动
(事后 setAttribute 存在被挂载效果覆盖的竞态)
- header.test.tsx lucide 白名单补 Monitor
- CLAUDE.md 同步三态语义与 variant 踩坑
- 新增 scripts/audit/theme-system-verify.mjs
(55 断言:首帧无闪烁/循环/实时跟随/旧值兼容/
污染回退/6 路由冒烟)
验证:单测 131 套件 1662 通过 0 失败;
tsc 0 错;eslint 0 错;next build 通过;
运行时 55/55 PASS(dogfood-output/theme-system-verify/)
This commit is contained in:
@@ -64,6 +64,8 @@ jest.mock('lucide-react', () => ({
|
||||
MessageCircle: () => <span data-testid="message-circle-icon" />,
|
||||
Sun: () => <span data-testid="sun-icon" />,
|
||||
Moon: () => <span data-testid="moon-icon" />,
|
||||
// ThemeToggle 为三态(跟随系统 / 浅色 / 深色),Monitor 是「跟随系统」档的图标
|
||||
Monitor: () => <span data-testid="monitor-icon" />,
|
||||
}));
|
||||
|
||||
jest.mock('@/components/ui/button', () => {
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
import { render, screen, act } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { ThemeToggle } from '@/components/theme/theme-toggle';
|
||||
import {
|
||||
SYSTEM_PREFERENCE_QUERY,
|
||||
THEME_STORAGE_KEY,
|
||||
type ThemePreference,
|
||||
} from '@/lib/theme';
|
||||
|
||||
type ChangeListener = (event: MediaQueryListEvent) => void;
|
||||
|
||||
/**
|
||||
* 可控的 matchMedia 桩:除 matches 外,捕获 change 监听以便测试中模拟
|
||||
* 用户在操作系统里切换浅/深色。
|
||||
*/
|
||||
function mockMatchMedia(matches: boolean) {
|
||||
const listeners = new Set<ChangeListener>();
|
||||
const mql = {
|
||||
matches,
|
||||
media: SYSTEM_PREFERENCE_QUERY,
|
||||
onchange: null,
|
||||
addListener: jest.fn(),
|
||||
removeListener: jest.fn(),
|
||||
addEventListener: jest.fn((type: string, cb: ChangeListener) => {
|
||||
if (type === 'change') listeners.add(cb);
|
||||
}),
|
||||
removeEventListener: jest.fn((type: string, cb: ChangeListener) => {
|
||||
if (type === 'change') listeners.delete(cb);
|
||||
}),
|
||||
dispatchEvent: jest.fn(),
|
||||
};
|
||||
|
||||
window.matchMedia = jest.fn().mockReturnValue(mql) as unknown as typeof window.matchMedia;
|
||||
|
||||
return {
|
||||
mql,
|
||||
/** 模拟操作系统主题切换 */
|
||||
emitSystemChange(next: boolean) {
|
||||
mql.matches = next;
|
||||
listeners.forEach((cb) => {
|
||||
cb({ matches: next, media: SYSTEM_PREFERENCE_QUERY } as MediaQueryListEvent);
|
||||
});
|
||||
},
|
||||
listenerCount: () => listeners.size,
|
||||
};
|
||||
}
|
||||
|
||||
function currentTheme(): string | null {
|
||||
return document.documentElement.getAttribute('data-theme');
|
||||
}
|
||||
|
||||
function visibleIcon(): string | null {
|
||||
const icon = document.querySelector('[data-testid^="icon-"]');
|
||||
return icon?.getAttribute('data-testid') ?? null;
|
||||
}
|
||||
|
||||
describe('ThemeToggle 三态循环', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
});
|
||||
|
||||
describe('默认跟随系统', () => {
|
||||
it('系统为深色且未选过时,首帧即应用 dark 并显示"跟随系统"图标', () => {
|
||||
mockMatchMedia(true);
|
||||
render(<ThemeToggle />);
|
||||
|
||||
expect(currentTheme()).toBe('dark');
|
||||
expect(visibleIcon()).toBe('icon-monitor');
|
||||
expect(screen.getByRole('button')).toHaveAttribute(
|
||||
'data-theme-preference',
|
||||
'system',
|
||||
);
|
||||
});
|
||||
|
||||
it('系统为浅色且未选过时,应用 light', () => {
|
||||
mockMatchMedia(false);
|
||||
render(<ThemeToggle />);
|
||||
|
||||
expect(currentTheme()).toBe('light');
|
||||
expect(visibleIcon()).toBe('icon-monitor');
|
||||
});
|
||||
|
||||
it('未选过时不写 localStorage,避免把默认偏好固化成显式选择', () => {
|
||||
mockMatchMedia(true);
|
||||
render(<ThemeToggle />);
|
||||
|
||||
expect(window.localStorage.getItem(THEME_STORAGE_KEY)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('循环切换', () => {
|
||||
it('按 system → light → dark → system 循环,并同步 DOM 与存储', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockMatchMedia(true);
|
||||
render(<ThemeToggle />);
|
||||
const button = screen.getByRole('button');
|
||||
|
||||
const expectations: Array<[ThemePreference, string, string]> = [
|
||||
['light', 'light', 'icon-sun'],
|
||||
['dark', 'dark', 'icon-moon'],
|
||||
['system', 'dark', 'icon-monitor'],
|
||||
];
|
||||
|
||||
for (const [preference, theme, icon] of expectations) {
|
||||
await user.click(button);
|
||||
expect(window.localStorage.getItem(THEME_STORAGE_KEY)).toBe(preference);
|
||||
expect(currentTheme()).toBe(theme);
|
||||
expect(visibleIcon()).toBe(icon);
|
||||
expect(button).toHaveAttribute('data-theme-preference', preference);
|
||||
}
|
||||
});
|
||||
|
||||
it('显式浅色会覆盖深色系统偏好', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockMatchMedia(true);
|
||||
render(<ThemeToggle />);
|
||||
|
||||
await user.click(screen.getByRole('button')); // system → light
|
||||
|
||||
expect(currentTheme()).toBe('light');
|
||||
});
|
||||
});
|
||||
|
||||
describe('向后兼容旧存储值', () => {
|
||||
it.each<[ThemePreference, string]>([
|
||||
['light', 'light'],
|
||||
['dark', 'dark'],
|
||||
])('旧值 %s 仍被当作显式覆盖(系统为深色)', (stored, expected) => {
|
||||
window.localStorage.setItem(THEME_STORAGE_KEY, stored);
|
||||
mockMatchMedia(true);
|
||||
render(<ThemeToggle />);
|
||||
|
||||
expect(currentTheme()).toBe(expected);
|
||||
});
|
||||
|
||||
it('存储值被污染时回退到跟随系统', () => {
|
||||
window.localStorage.setItem(THEME_STORAGE_KEY, 'not-a-theme');
|
||||
mockMatchMedia(true);
|
||||
render(<ThemeToggle />);
|
||||
|
||||
expect(currentTheme()).toBe('dark');
|
||||
expect(visibleIcon()).toBe('icon-monitor');
|
||||
});
|
||||
});
|
||||
|
||||
describe('实时跟随系统偏好', () => {
|
||||
it('system 档下系统切换为浅色时立即反色,无需刷新', () => {
|
||||
const media = mockMatchMedia(true);
|
||||
render(<ThemeToggle />);
|
||||
expect(currentTheme()).toBe('dark');
|
||||
|
||||
act(() => media.emitSystemChange(false));
|
||||
|
||||
expect(currentTheme()).toBe('light');
|
||||
expect(visibleIcon()).toBe('icon-monitor');
|
||||
});
|
||||
|
||||
it('system 档下系统切换为深色时立即反色', () => {
|
||||
const media = mockMatchMedia(false);
|
||||
render(<ThemeToggle />);
|
||||
expect(currentTheme()).toBe('light');
|
||||
|
||||
act(() => media.emitSystemChange(true));
|
||||
|
||||
expect(currentTheme()).toBe('dark');
|
||||
});
|
||||
|
||||
it('已显式选择的用户不受系统偏好变化干扰', async () => {
|
||||
const user = userEvent.setup();
|
||||
const media = mockMatchMedia(false);
|
||||
render(<ThemeToggle />);
|
||||
|
||||
await user.click(screen.getByRole('button')); // system → light
|
||||
await user.click(screen.getByRole('button')); // light → dark
|
||||
expect(currentTheme()).toBe('dark');
|
||||
|
||||
act(() => media.emitSystemChange(false));
|
||||
|
||||
expect(currentTheme()).toBe('dark');
|
||||
expect(visibleIcon()).toBe('icon-moon');
|
||||
});
|
||||
|
||||
it('卸载后移除监听,避免内存泄漏', () => {
|
||||
const media = mockMatchMedia(true);
|
||||
const { unmount } = render(<ThemeToggle />);
|
||||
expect(media.listenerCount()).toBeGreaterThan(0);
|
||||
|
||||
unmount();
|
||||
|
||||
expect(media.listenerCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('跨标签页同步', () => {
|
||||
it('其他标签页修改偏好后本页同步生效', () => {
|
||||
mockMatchMedia(false);
|
||||
render(<ThemeToggle />);
|
||||
expect(currentTheme()).toBe('light');
|
||||
|
||||
act(() => {
|
||||
window.localStorage.setItem(THEME_STORAGE_KEY, 'dark');
|
||||
window.dispatchEvent(
|
||||
new StorageEvent('storage', {
|
||||
key: THEME_STORAGE_KEY,
|
||||
newValue: 'dark',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(currentTheme()).toBe('dark');
|
||||
expect(visibleIcon()).toBe('icon-moon');
|
||||
});
|
||||
|
||||
it('忽略无关 key 的 storage 事件', () => {
|
||||
mockMatchMedia(false);
|
||||
render(<ThemeToggle />);
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new StorageEvent('storage', { key: 'unrelated', newValue: 'dark' }),
|
||||
);
|
||||
});
|
||||
|
||||
expect(currentTheme()).toBe('light');
|
||||
});
|
||||
});
|
||||
|
||||
describe('可访问性', () => {
|
||||
it('按钮标签同时说明当前设置与生效主题', () => {
|
||||
mockMatchMedia(true);
|
||||
render(<ThemeToggle />);
|
||||
|
||||
const label = screen.getByRole('button').getAttribute('aria-label') ?? '';
|
||||
expect(label).toContain('跟随系统');
|
||||
expect(label).toContain('深色');
|
||||
});
|
||||
|
||||
it('每次切换后标签同步更新', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockMatchMedia(false);
|
||||
render(<ThemeToggle />);
|
||||
const button = screen.getByRole('button');
|
||||
|
||||
await user.click(button);
|
||||
expect(button.getAttribute('aria-label')).toContain('浅色');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,67 +1,116 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Sun, Moon } from 'lucide-react';
|
||||
import { Monitor, Moon, Sun } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type Theme = 'light' | 'dark';
|
||||
|
||||
const STORAGE_KEY = 'novalon-theme';
|
||||
import {
|
||||
DEFAULT_PREFERENCE,
|
||||
RESOLVED_THEME_LABELS,
|
||||
SYSTEM_PREFERENCE_QUERY,
|
||||
THEME_PREFERENCE_LABELS,
|
||||
THEME_STORAGE_KEY,
|
||||
applyTheme,
|
||||
getSystemTheme,
|
||||
nextPreference,
|
||||
normalizePreference,
|
||||
persistPreference,
|
||||
readStoredPreference,
|
||||
resolveTheme,
|
||||
type ResolvedTheme,
|
||||
type ThemePreference,
|
||||
} from '@/lib/theme';
|
||||
|
||||
/**
|
||||
* 读取当前应生效的主题:
|
||||
* 1. 优先用用户显式选择(localStorage,与 layout.tsx FOUC 脚本同一 key)
|
||||
* 2. 回退到 FOUC 脚本已同步写入 <html data-theme> 的值
|
||||
* 3. 默认浅色
|
||||
* 这样即便用户刷新,状态也与首帧 FOUC 脚本完全一致,无闪烁。
|
||||
* 三档主题切换器:跟随系统 → 浅色 → 深色 → 跟随系统。
|
||||
*
|
||||
* 与首帧防 FOUC 脚本(src/app/layout.tsx)共用同一个 localStorage key 与解析规则,
|
||||
* 保证刷新前后状态一致、无闪烁。
|
||||
*
|
||||
* 两个「跟随」行为:
|
||||
* 1. 系统偏好实时变化 —— 仅在「跟随系统」档生效,显式选择过的用户不会被系统覆盖
|
||||
* 2. 跨标签页同步 —— 监听 storage 事件,多开标签页时状态不打架
|
||||
*/
|
||||
function resolveTheme(): Theme {
|
||||
if (typeof window === 'undefined') return 'light';
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (stored === 'light' || stored === 'dark') return stored;
|
||||
const attr = document.documentElement.getAttribute('data-theme');
|
||||
if (attr === 'light' || attr === 'dark') return attr as Theme;
|
||||
return 'light';
|
||||
}
|
||||
|
||||
function applyTheme(theme: Theme) {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, theme);
|
||||
} catch {
|
||||
/* 隐私模式等写入失败时忽略:仅本次会话生效 */
|
||||
}
|
||||
}
|
||||
|
||||
export function ThemeToggle({ className }: { className?: string }) {
|
||||
// 初始为 null,挂载后再解析真实主题,避免 SSR/CSR 水合不一致
|
||||
const [theme, setTheme] = useState<Theme | null>(null);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
// 初始为 null,挂载后再解析真实状态,避免 SSR/CSR 水合不一致
|
||||
const [preference, setPreference] = useState<ThemePreference | null>(null);
|
||||
const [systemTheme, setSystemTheme] = useState<ResolvedTheme | null>(null);
|
||||
|
||||
// 必须挂载后读取:FOUC 脚本已在 <head> 同步写入 data-theme,
|
||||
// 此处从 DOM/localStorage 还原真实主题。SSR 阶段 window 不可用,
|
||||
// 故无法用 useState 惰性初始化(否则首帧与客户端水合不一致)。
|
||||
// 此处从 localStorage / matchMedia 还原用户偏好与系统偏好。
|
||||
useEffect(() => {
|
||||
// 主题同步必须从挂载后 DOM 读取,此 setState 无法避免
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setTheme(resolveTheme());
|
||||
setMounted(true);
|
||||
setPreference(readStoredPreference() ?? DEFAULT_PREFERENCE);
|
||||
setSystemTheme(getSystemTheme());
|
||||
}, []);
|
||||
|
||||
const isDark = theme === 'dark';
|
||||
// 实时跟随系统偏好。只更新 systemTheme,是否真正反色交给 resolveTheme 决定,
|
||||
// 因此显式选择 light/dark 的用户不会被系统偏好变化打断。
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
|
||||
return;
|
||||
}
|
||||
let mql: MediaQueryList;
|
||||
try {
|
||||
mql = window.matchMedia(SYSTEM_PREFERENCE_QUERY);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const handleChange = (event: MediaQueryListEvent) => {
|
||||
setSystemTheme(event.matches ? 'dark' : 'light');
|
||||
};
|
||||
// Safari 14 之前只有已废弃的 addListener,需保留回退分支
|
||||
if (typeof mql.addEventListener === 'function') {
|
||||
mql.addEventListener('change', handleChange);
|
||||
return () => mql.removeEventListener('change', handleChange);
|
||||
}
|
||||
mql.addListener(handleChange);
|
||||
return () => mql.removeListener(handleChange);
|
||||
}, []);
|
||||
|
||||
const toggle = () => {
|
||||
const next: Theme = isDark ? 'light' : 'dark';
|
||||
setTheme(next);
|
||||
applyTheme(next);
|
||||
// 跨标签页同步:另一个标签页改了偏好,本页跟随
|
||||
useEffect(() => {
|
||||
const handleStorage = (event: StorageEvent) => {
|
||||
if (event.key !== THEME_STORAGE_KEY) return;
|
||||
const next = normalizePreference(event.newValue);
|
||||
if (next) setPreference(next);
|
||||
};
|
||||
window.addEventListener('storage', handleStorage);
|
||||
return () => window.removeEventListener('storage', handleStorage);
|
||||
}, []);
|
||||
|
||||
// 挂载完成前为 null:此时保留 FOUC 脚本已写入的主题,不覆盖
|
||||
const resolved =
|
||||
preference && systemTheme ? resolveTheme(preference, systemTheme) : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (resolved) applyTheme(resolved);
|
||||
}, [resolved]);
|
||||
|
||||
const handleToggle = () => {
|
||||
const next = nextPreference(preference ?? DEFAULT_PREFERENCE);
|
||||
setPreference(next);
|
||||
persistPreference(next);
|
||||
};
|
||||
|
||||
// 挂载前渲染占位,避免与 FOUC 已应用的主题产生水合差异
|
||||
const mounted = resolved !== null;
|
||||
const Icon = preference === 'light' ? Sun : preference === 'dark' ? Moon : Monitor;
|
||||
|
||||
// 显式判空而非复用 mounted 布尔值:TS 无法从 mounted 反推出 preference/resolved 非空
|
||||
const label =
|
||||
preference && resolved
|
||||
? `主题:${THEME_PREFERENCE_LABELS[preference]}(当前${RESOLVED_THEME_LABELS[resolved]}),点击切换为${THEME_PREFERENCE_LABELS[nextPreference(preference)]}`
|
||||
: '切换主题';
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
aria-label={isDark ? '切换到浅色模式' : '切换到暗黑模式'}
|
||||
aria-pressed={isDark}
|
||||
onClick={handleToggle}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
data-testid="theme-toggle"
|
||||
data-theme-preference={preference ?? undefined}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center w-9 h-9 rounded-lg border border-border-primary',
|
||||
'text-text-tertiary hover:text-text-primary hover:bg-bg-hover',
|
||||
@@ -70,13 +119,8 @@ export function ThemeToggle({ className }: { className?: string }) {
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* 挂载前渲染占位,避免与 FOUC 已应用的主题产生水合差异 */}
|
||||
{mounted ? (
|
||||
isDark ? (
|
||||
<Moon className="w-4 h-4" aria-hidden="true" />
|
||||
) : (
|
||||
<Sun className="w-4 h-4" aria-hidden="true" />
|
||||
)
|
||||
<Icon className="w-4 h-4" aria-hidden="true" />
|
||||
) : (
|
||||
<span className="block w-4 h-4" aria-hidden="true" />
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user