- 新增 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/)
108 lines
3.8 KiB
TypeScript
108 lines
3.8 KiB
TypeScript
/**
|
||
* 主题偏好单一真源(暗黑模式基础设施)
|
||
*
|
||
* 三档偏好模型:`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;
|
||
}
|
||
}
|