Files
novalon-website/src/lib/theme.ts
T
zhangxiang d91eeca5de 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/)
2026-09-03 16:01:08 +08:00

108 lines
3.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 主题偏好单一真源(暗黑模式基础设施)
*
* 三档偏好模型:`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;
}
}