/** * 主题偏好单一真源(暗黑模式基础设施) * * 三档偏好模型:`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 = { 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 = { system: '跟随系统', light: '浅色', dark: '深色', }; export const RESOLVED_THEME_LABELS: Record = { 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; } }