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:
2026-09-03 16:01:08 +08:00
parent a9d55298fc
commit d91eeca5de
9 changed files with 962 additions and 71 deletions
+107
View File
@@ -0,0 +1,107 @@
/**
* 主题偏好单一真源(暗黑模式基础设施)
*
* 三档偏好模型:`system` 跟随操作系统、`light` / `dark` 为显式覆盖。
* 偏好(用户意图)与主题(实际生效值)分离:偏好持久化为 `data-theme` 之外的独立概念,
* 因此 `html[data-theme]` 只是解析结果的输出,不能再被当作偏好读回
* —— 否则 system 档在深色系统下会被误读成显式 dark,循环卡死。
*
* 信源:MDN `prefers-color-scheme` / `Window.matchMedia`
* https://developer.mozilla.org/docs/Web/CSS/@media/prefers-color-scheme
*/
/** localStorage 键名。旧版本仅存 'light' | 'dark',本次扩展出 'system',旧值语义保持不变 */
export const THEME_STORAGE_KEY = 'novalon-theme';
/** 用户可选的三档偏好 */
export type ThemePreference = 'system' | 'light' | 'dark';
/** 实际生效的主题(偏好 + 系统偏好解析后的结果) */
export type ResolvedTheme = 'light' | 'dark';
/** 循环顺序:跟随系统 → 浅色 → 深色 → 跟随系统 */
export const THEME_CYCLE = ['system', 'light', 'dark'] as const;
/**
* 每一档的下一档。
* 用字面量映射而非「数组取模」实现:项目开启了 noUncheckedIndexedAccess
* 数组下标取模会得到 `ThemePreference | undefined`,需要额外的非空断言。
*/
const NEXT_PREFERENCE: Record<ThemePreference, ThemePreference> = {
system: 'light',
light: 'dark',
dark: 'system',
};
/** 未做过显式选择时的默认偏好 —— 跟随系统 */
export const DEFAULT_PREFERENCE: ThemePreference = 'system';
/** 系统深色偏好查询串,供组件与内联脚本共用 */
export const SYSTEM_PREFERENCE_QUERY = '(prefers-color-scheme: dark)';
export const THEME_PREFERENCE_LABELS: Record<ThemePreference, string> = {
system: '跟随系统',
light: '浅色',
dark: '深色',
};
export const RESOLVED_THEME_LABELS: Record<ResolvedTheme, string> = {
light: '浅色',
dark: '深色',
};
/** 归一化任意存储值;非法值返回 null,由调用方回退到默认偏好 */
export function normalizePreference(value: unknown): ThemePreference | null {
return value === 'system' || value === 'light' || value === 'dark' ? value : null;
}
/**
* 读取操作系统当前偏好。
* matchMedia 缺失(老浏览器)或抛错(受限环境)时回退浅色,保证 SSR 与隐私场景不崩。
*/
export function getSystemTheme(): ResolvedTheme {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
return 'light';
}
try {
return window.matchMedia(SYSTEM_PREFERENCE_QUERY).matches ? 'dark' : 'light';
} catch {
return 'light';
}
}
/** 偏好 + 系统偏好 → 实际生效主题 */
export function resolveTheme(
preference: ThemePreference,
system: ResolvedTheme = getSystemTheme(),
): ResolvedTheme {
return preference === 'system' ? system : preference;
}
/** 偏好在循环中的下一档 */
export function nextPreference(current: ThemePreference): ThemePreference {
return NEXT_PREFERENCE[current];
}
/** 把解析结果应用到 DOM,驱动 globals.css 的 `html[data-theme='dark']` 变量覆盖块 */
export function applyTheme(theme: ResolvedTheme): void {
document.documentElement.setAttribute('data-theme', theme);
}
/** 持久化偏好。写入失败(隐私模式)时静默降级为仅本次会话生效 */
export function persistPreference(preference: ThemePreference): void {
try {
window.localStorage.setItem(THEME_STORAGE_KEY, preference);
} catch {
/* 隐私模式等写入失败时忽略 */
}
}
/** 读取持久化的偏好;无值或值被污染时返回 null */
export function readStoredPreference(): ThemePreference | null {
try {
return normalizePreference(window.localStorage.getItem(THEME_STORAGE_KEY));
} catch {
return null;
}
}