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:
@@ -100,7 +100,18 @@ src/app/
|
||||
When replacing a page or component with a new version, move the old one to an `_archive/` subdirectory (e.g., `src/app/(marketing)/_archive/` for old homepage iterations). The archive is excluded from TypeScript compilation via `tsconfig.json`.
|
||||
|
||||
### Dark Mode
|
||||
Dark mode uses the `data-theme="dark"` HTML attribute (not Tailwind's `dark:` class). An inline `<script>` in the root layout sets the attribute before paint to prevent flash of unstyled content (FOUC). The Tailwind config uses `darkMode: 'class'` as the mechanism, driven by the `data-theme` attribute via CSS selectors.
|
||||
Dark mode uses the `data-theme="dark"` HTML attribute (not Tailwind's `dark:` class). Tailwind is configured with `darkMode: ['variant', '[data-theme="dark"] &']` — use the **variant** form, not `['selector', ...]`, because the selector form is accepted by the config but JIT emits zero `dark:` rules under Turbopack + Next.js 16.
|
||||
|
||||
**Three-state preference model** (`src/lib/theme.ts` is the single source of truth):
|
||||
|
||||
| Stored value (`localStorage['novalon-theme']`) | Meaning |
|
||||
|---|---|
|
||||
| `'light'` / `'dark'` | Explicit user choice — always wins over the OS |
|
||||
| `'system'` / absent | Follow OS `prefers-color-scheme` (**default for new visitors**) |
|
||||
|
||||
- `src/app/layout.tsx` has an inline `<script>` in `<head>` that resolves the preference and sets `data-theme` **before first paint** (FOUC prevention). It must stay semantically in sync with `src/lib/theme.ts` — change both together.
|
||||
- `src/components/theme/theme-toggle.tsx` cycles `跟随系统 → 浅色 → 深色`. It listens to `matchMedia('(prefers-color-scheme: dark)')` for live OS changes (only effective in the `system` state) and to `storage` events for cross-tab sync.
|
||||
- `html[data-theme]` is an **output** of preference resolution — never read it back as the user's preference, or the `system` state collapses into `dark` on dark-OS machines and the cycle deadlocks.
|
||||
|
||||
### HSI Information Architecture
|
||||
The site follows a **Hub-Spoke-Independent** model (see `CONTEXT.md` and ADR-0002):
|
||||
@@ -135,7 +146,7 @@ All visual tokens are defined as **CSS custom properties** in `src/app/globals.c
|
||||
- **Typography**: `--font-size-*`, `--line-height-*`, `--letter-spacing-*` all mapped to Tailwind's `fontSize`/`lineHeight`/`letterSpacing` scales
|
||||
- **Spacing, radius, shadows** all tokenized through CSS variables
|
||||
- **Transitions**: `--transition-fast/normal/slow`, `--ease-ink` (cubic-bezier), `--ease-sharp`, `--ease-spring-soft`
|
||||
- **Dark mode**: `data-theme="dark"` attribute (set via inline script before paint to prevent flash)
|
||||
- **Dark mode**: `data-theme="dark"` attribute (set via inline script before paint to prevent flash); flipped by the `[data-theme='dark']` override block in `globals.css`, which only re-maps CSS variables — components do not restyle per element
|
||||
|
||||
### Design DNA Framework
|
||||
The site follows a three-dimensional design system (see `CONTEXT.md`):
|
||||
|
||||
@@ -140,30 +140,34 @@ test.describe('L2: 组件视觉状态测试', () => {
|
||||
test.describe('L2: 主题切换视觉测试', () => {
|
||||
test.setTimeout(45000);
|
||||
|
||||
test('浅色主题 - 首页视觉', async ({ page }) => {
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
await waitForPageStable(page);
|
||||
// 主题由「系统偏好」驱动(默认跟随系统,见 src/lib/theme.ts),因此用 Playwright 的
|
||||
// colorScheme 上下文选项模拟 OS 偏好,而不是事后 setAttribute:
|
||||
// 挂载后的 ThemeToggle 会把 html[data-theme] 重置为解析结果,事后改属性存在竞态
|
||||
// (hydration 晚于 setAttribute 时会被覆盖,深色快照拍成浅色)。
|
||||
test.describe('系统偏好浅色', () => {
|
||||
test.use({ colorScheme: 'light' });
|
||||
|
||||
const htmlEl = page.locator('html');
|
||||
const currentTheme = await htmlEl.getAttribute('data-theme') || 'light';
|
||||
test('浅色主题 - 首页视觉', async ({ page }) => {
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
await waitForPageStable(page);
|
||||
|
||||
if (currentTheme !== 'light') {
|
||||
await htmlEl.evaluate(el => el.setAttribute('data-theme', 'light'));
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
|
||||
|
||||
await expect(page.locator('main').first()).toHaveScreenshot('theme-light-main.png');
|
||||
await expect(page.locator('main').first()).toHaveScreenshot('theme-light-main.png');
|
||||
});
|
||||
});
|
||||
|
||||
test('深色主题 - 首页视觉', async ({ page }) => {
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
await waitForPageStable(page);
|
||||
test.describe('系统偏好深色', () => {
|
||||
test.use({ colorScheme: 'dark' });
|
||||
|
||||
const htmlEl = page.locator('html');
|
||||
await htmlEl.evaluate(el => el.setAttribute('data-theme', 'dark'));
|
||||
await page.waitForTimeout(500);
|
||||
test('深色主题 - 首页视觉', async ({ page }) => {
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
await waitForPageStable(page);
|
||||
|
||||
await expect(page.locator('main').first()).toHaveScreenshot('theme-dark-main.png');
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
|
||||
|
||||
await expect(page.locator('main').first()).toHaveScreenshot('theme-dark-main.png');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
// 跟随系统自动切换暗黑模式 —— 运行时验证
|
||||
//
|
||||
// 验证「三档偏好 + 首帧无闪烁 + 实时跟随」在真实浏览器里的行为:
|
||||
// 1) 首帧(domcontentloaded,早于 hydration)data-theme 已由 <head> 内联脚本写对
|
||||
// 2) 三档循环 system → light → dark → system,DOM 与 localStorage 同步
|
||||
// 3) 仅「跟随系统」档实时响应 OS 偏好变化;显式选择不被系统覆盖
|
||||
// 4) 旧存储值(light / dark)向后兼容,污染值回退跟随系统
|
||||
// 5) CSS 变量确实翻转(不只看属性,看计算后的背景色)
|
||||
// 6) hydration 成功、无控制台报错
|
||||
//
|
||||
// 环境约束(2026-09-02):URL 必须用 localhost,127.0.0.1 会被 Next dev 的
|
||||
// allowedDevOrigins 403 → chunk 加载失败 → hydration 从不发生。
|
||||
//
|
||||
// 用法:node scripts/audit/theme-system-verify.mjs
|
||||
// 前置:dev server 已在 http://localhost:3000 运行
|
||||
|
||||
import { chromium } from 'playwright';
|
||||
import { writeFileSync, mkdirSync } from 'node:fs';
|
||||
|
||||
const BASE = 'http://localhost:3000';
|
||||
const OUT = 'dogfood-output/theme-system-verify';
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
|
||||
const browser = await chromium.launch({ args: ['--no-proxy-server'] });
|
||||
|
||||
/** 开一个带指定系统偏好的页面;storedPreference 为 null 时不预置 localStorage */
|
||||
async function openPage(colorScheme, storedPreference) {
|
||||
const ctx = await browser.newContext({ colorScheme, viewport: { width: 1440, height: 900 } });
|
||||
const page = await ctx.newPage();
|
||||
if (storedPreference) {
|
||||
await page.addInitScript((value) => {
|
||||
try {
|
||||
localStorage.setItem('novalon-theme', value);
|
||||
} catch {
|
||||
/* 隐私模式兜底 */
|
||||
}
|
||||
}, storedPreference);
|
||||
}
|
||||
const errors = [];
|
||||
page.on('console', (m) => {
|
||||
if (m.type() === 'error') errors.push(m.text());
|
||||
});
|
||||
page.on('pageerror', (e) => errors.push('PAGEERROR: ' + e.message));
|
||||
return { ctx, page, errors };
|
||||
}
|
||||
|
||||
/** 读取主题相关探针 */
|
||||
async function probe(page) {
|
||||
return page.evaluate(() => {
|
||||
const html = document.documentElement;
|
||||
const svg = document.querySelector('[data-testid="theme-toggle"] svg');
|
||||
return {
|
||||
dataTheme: html.getAttribute('data-theme'),
|
||||
preference: document
|
||||
.querySelector('[data-testid="theme-toggle"]')
|
||||
?.getAttribute('data-theme-preference') ?? null,
|
||||
// class 形如 "lucide lucide-sun w-4 h-4",只取 lucide-* 图标名
|
||||
icon: svg
|
||||
? ((svg.getAttribute('class') || '').match(/lucide-[a-z-]+/) || [null])[0]
|
||||
: null,
|
||||
bodyBg: getComputedStyle(document.body).backgroundColor,
|
||||
stored: (() => {
|
||||
try {
|
||||
return localStorage.getItem('novalon-theme');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})(),
|
||||
label: document
|
||||
.querySelector('[data-testid="theme-toggle"]')
|
||||
?.getAttribute('aria-label') ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function waitHydrated(page) {
|
||||
try {
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const els = Array.from(document.querySelectorAll('div[style*="opacity"]'));
|
||||
return els.some((el) => getComputedStyle(el).opacity === '1');
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待 ThemeToggle 完成客户端解析(挂载后 effect 写入 data-theme-preference 并渲染图标)。
|
||||
* 首屏可能是 dev server 冷编译, hydration 会比后续导航慢得多,
|
||||
* 因此这里直接等「组件状态就绪」这一语义信号,而不是等通用的动效探针。
|
||||
*/
|
||||
async function waitToggleReady(page) {
|
||||
try {
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document
|
||||
.querySelector('[data-testid="theme-toggle"]')
|
||||
?.getAttribute('data-theme-preference') != null,
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const results = [];
|
||||
const failures = [];
|
||||
/** 记录两个场景下的 body 背景色,用于断言 CSS 变量真的翻转了 */
|
||||
const bodyBgByScheme = {};
|
||||
|
||||
function record(name, expected, actual, extra = {}) {
|
||||
const pass = JSON.stringify(expected) === JSON.stringify(actual);
|
||||
if (!pass) failures.push({ name, expected, actual });
|
||||
results.push({ name, pass, expected, actual, ...extra });
|
||||
console.log(`${pass ? 'PASS' : 'FAIL'} ${name}\n expected=${JSON.stringify(expected)}\n actual =${JSON.stringify(actual)}`);
|
||||
}
|
||||
|
||||
// ── 场景 1/2:默认跟随系统(未做过显式选择) ─────────────────────────────
|
||||
for (const scheme of ['dark', 'light']) {
|
||||
const { ctx, page, errors } = await openPage(scheme, null);
|
||||
await page.goto(BASE + '/', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
|
||||
// 关键:此时早于 hydration,data-theme 若已正确 ⇒ 内联首帧脚本生效,无闪烁
|
||||
const firstPaint = await probe(page);
|
||||
|
||||
const hydrated = await waitHydrated(page);
|
||||
const toggleReady = await waitToggleReady(page);
|
||||
const after = await probe(page);
|
||||
await page.screenshot({ path: `${OUT}/default-system-${scheme}.png` });
|
||||
|
||||
record(`默认跟随系统(OS=${scheme})hydration 完成`, true, hydrated);
|
||||
record(`默认跟随系统(OS=${scheme})切换器状态就绪`, true, toggleReady);
|
||||
record(`默认跟随系统(OS=${scheme})首帧 data-theme`, scheme, firstPaint.dataTheme);
|
||||
record(`默认跟随系统(OS=${scheme})不写 localStorage`, null, firstPaint.stored);
|
||||
record(`默认跟随系统(OS=${scheme})偏好档位`, 'system', after.preference);
|
||||
record(`默认跟随系统(OS=${scheme})图标`, 'lucide-monitor', after.icon);
|
||||
record(`默认跟随系统(OS=${scheme})hydration 后一致`, scheme, after.dataTheme);
|
||||
|
||||
bodyBgByScheme[scheme] = after.bodyBg;
|
||||
results.push({ name: `OS=${scheme} body 背景色`, pass: true, bodyBg: after.bodyBg, errors });
|
||||
if (errors.length) failures.push({ name: `OS=${scheme} 控制台报错`, errors });
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
// CSS 变量确实翻转:默认跟随系统时,OS 深浅两种偏好应产出不同的 body 背景色
|
||||
record(
|
||||
'深/浅 body 背景色不同(CSS 变量确实翻转)',
|
||||
true,
|
||||
Boolean(bodyBgByScheme.dark && bodyBgByScheme.light && bodyBgByScheme.dark !== bodyBgByScheme.light),
|
||||
{ dark: bodyBgByScheme.dark, light: bodyBgByScheme.light },
|
||||
);
|
||||
|
||||
// ── 场景 3/4:旧存储值向后兼容(显式选择压过系统偏好) ───────────────────
|
||||
for (const [stored, scheme] of [['dark', 'light'], ['light', 'dark']]) {
|
||||
const { ctx, page, errors } = await openPage(scheme, stored);
|
||||
await page.goto(BASE + '/', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
const first = await probe(page);
|
||||
await waitHydrated(page);
|
||||
await waitToggleReady(page);
|
||||
const after = await probe(page);
|
||||
|
||||
record(`旧存储值 ${stored} 压过 OS=${scheme}`, stored, first.dataTheme);
|
||||
record(`旧存储值 ${stored} 偏好档位`, stored, after.preference);
|
||||
if (errors.length) failures.push({ name: `旧存储值 ${stored} 控制台报错`, errors });
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
// ── 场景 5:污染值回退跟随系统 ──────────────────────────────────────────
|
||||
{
|
||||
const { ctx, page, errors } = await openPage('dark', 'not-a-theme');
|
||||
await page.goto(BASE + '/', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
await waitHydrated(page);
|
||||
await waitToggleReady(page);
|
||||
const after = await probe(page);
|
||||
record('污染存储值回退跟随系统(OS=dark)', 'dark', after.dataTheme);
|
||||
record('污染存储值档位为 system', 'system', after.preference);
|
||||
if (errors.length) failures.push({ name: '污染值控制台报错', errors });
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
// ── 场景 6:三档循环切换 ────────────────────────────────────────────────
|
||||
{
|
||||
const { ctx, page, errors } = await openPage('dark', null);
|
||||
await page.goto(BASE + '/', { waitUntil: 'networkidle', timeout: 30000 });
|
||||
await waitHydrated(page);
|
||||
await waitToggleReady(page);
|
||||
|
||||
const toggle = page.locator('[data-testid="theme-toggle"]');
|
||||
// 起点:档位 system + OS=dark ⇒ data-theme=dark。显式档压过系统偏好,
|
||||
// 故 light 档在 OS=dark 下仍解析为 light。
|
||||
const sequence = [
|
||||
{ pref: 'light', theme: 'light', icon: 'lucide-sun' },
|
||||
{ pref: 'dark', theme: 'dark', icon: 'lucide-moon' },
|
||||
{ pref: 'system', theme: 'dark', icon: 'lucide-monitor' },
|
||||
];
|
||||
for (const [i, step] of sequence.entries()) {
|
||||
await toggle.click();
|
||||
await page.waitForTimeout(150);
|
||||
const p = await probe(page);
|
||||
record(`循环第 ${i + 1} 次点击 → 档位`, step.pref, p.preference);
|
||||
record(`循环第 ${i + 1} 次点击 → data-theme(OS=dark)`, step.theme, p.dataTheme);
|
||||
record(`循环第 ${i + 1} 次点击 → 图标`, step.icon, p.icon);
|
||||
}
|
||||
await page.screenshot({ path: `${OUT}/cycle-clicks.png` });
|
||||
if (errors.length) failures.push({ name: '循环切换控制台报错', errors });
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
// ── 场景 7:实时跟随(system 档,OS 在运行中切换) ──────────────────────
|
||||
{
|
||||
const { ctx, page, errors } = await openPage('dark', null);
|
||||
await page.goto(BASE + '/', { waitUntil: 'networkidle', timeout: 30000 });
|
||||
await waitHydrated(page);
|
||||
await waitToggleReady(page);
|
||||
|
||||
record('实时跟随便前(OS=dark)', 'dark', (await probe(page)).dataTheme);
|
||||
await page.emulateMedia({ colorScheme: 'light' });
|
||||
await page.waitForTimeout(300);
|
||||
record('实时跟随:OS 切浅色后不刷新即跟随', 'light', (await probe(page)).dataTheme);
|
||||
|
||||
await page.emulateMedia({ colorScheme: 'dark' });
|
||||
await page.waitForTimeout(300);
|
||||
record('实时跟随:OS 切回深色后跟随', 'dark', (await probe(page)).dataTheme);
|
||||
await page.screenshot({ path: `${OUT}/live-follow.png` });
|
||||
if (errors.length) failures.push({ name: '实时跟随控制台报错', errors });
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
// ── 场景 8:已显式选择则不被系统覆盖 ────────────────────────────────────
|
||||
{
|
||||
const { ctx, page, errors } = await openPage('dark', 'light');
|
||||
await page.goto(BASE + '/', { waitUntil: 'networkidle', timeout: 30000 });
|
||||
await waitHydrated(page);
|
||||
await waitToggleReady(page);
|
||||
|
||||
await page.emulateMedia({ colorScheme: 'light' });
|
||||
await page.waitForTimeout(300);
|
||||
record('显式浅色:OS 切浅色仍为浅色', 'light', (await probe(page)).dataTheme);
|
||||
|
||||
await page.emulateMedia({ colorScheme: 'dark' });
|
||||
await page.waitForTimeout(300);
|
||||
record('显式浅色:OS 切深色不被覆盖', 'light', (await probe(page)).dataTheme);
|
||||
if (errors.length) failures.push({ name: '显式覆盖控制台报错', errors });
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
// ── 场景 9:多路由冒烟(OS=dark 未选过 ⇒ 每个路由首帧/挂载后都应为 dark) ──
|
||||
// 覆盖四层叙事模型的主要页面形态(Hub / Spoke / 详情 / 独立页),确认
|
||||
// 内联脚本 + ThemeToggle 在整站路由上行为一致、hydration 无报错。
|
||||
{
|
||||
const routes = ['/products', '/solutions', '/services', '/about', '/contact', '/methodology'];
|
||||
for (const route of routes) {
|
||||
const { ctx, page, errors } = await openPage('dark', null);
|
||||
await page.goto(BASE + route, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
const firstPaint = await probe(page);
|
||||
const toggleReady = await waitToggleReady(page);
|
||||
const after = await probe(page);
|
||||
|
||||
record(`${route} 首帧 data-theme`, 'dark', firstPaint.dataTheme);
|
||||
record(`${route} 切换器状态就绪`, true, toggleReady);
|
||||
record(`${route} 挂载后偏好档位`, 'system', after.preference);
|
||||
if (errors.length) failures.push({ name: `${route} 控制台报错`, errors });
|
||||
await ctx.close();
|
||||
}
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
|
||||
const summary = {
|
||||
通过: results.filter((r) => r.pass !== false).length,
|
||||
断言总数: results.filter((r) => r.pass !== undefined).length,
|
||||
失败: failures,
|
||||
明细: results,
|
||||
};
|
||||
writeFileSync(`${OUT}/theme-system-verify.json`, JSON.stringify(summary, null, 2));
|
||||
|
||||
console.log('\n──────── 汇总 ────────');
|
||||
console.log(`断言 ${summary.断言总数} 条,失败 ${failures.length} 条`);
|
||||
console.log(`产物:${OUT}/(json + 截图)`);
|
||||
if (failures.length) {
|
||||
console.log('\n失败明细:');
|
||||
failures.forEach((f) => console.log(JSON.stringify(f, null, 2)));
|
||||
process.exitCode = 1;
|
||||
}
|
||||
+6
-3
@@ -170,11 +170,14 @@ export default async function RootLayout({
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<head>
|
||||
{/* 主题防 FOUC(暗黑模式 Phase 1 基础设施):首帧前同步读取 localStorage 设置 data-theme,
|
||||
无存储值时保持浅色(默认)。当前无切换 UI,仅作挂载点,未来切换器写入 novalon-theme 即可生效。 */}
|
||||
{/* 主题防 FOUC:首帧前同步解析偏好并写入 data-theme,避免刷新闪白/闪黑。
|
||||
三档语义(与 src/lib/theme.ts 严格一致,改动时两处必须同步):
|
||||
'light' | 'dark' → 用户显式选择,直接生效
|
||||
'system' | 无值 → 跟随操作系统 prefers-color-scheme
|
||||
显式选择优先于系统偏好;localStorage 不可用(隐私模式)时兜底浅色。 */}
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `(function(){try{var t=localStorage.getItem('novalon-theme');if(t==='dark'||t==='light'){document.documentElement.setAttribute('data-theme',t);}}catch(e){}})();`,
|
||||
__html: `(function(){try{var p=localStorage.getItem('novalon-theme');if(p!=='light'&&p!=='dark'&&p!=='system'){p='system';}var t=p;if(p==='system'){t=(window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches)?'dark':'light';}document.documentElement.setAttribute('data-theme',t);}catch(e){try{document.documentElement.setAttribute('data-theme','light');}catch(e2){}}})();`,
|
||||
}}
|
||||
/>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
|
||||
@@ -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" />
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import {
|
||||
THEME_STORAGE_KEY,
|
||||
DEFAULT_PREFERENCE,
|
||||
THEME_CYCLE,
|
||||
SYSTEM_PREFERENCE_QUERY,
|
||||
normalizePreference,
|
||||
getSystemTheme,
|
||||
resolveTheme,
|
||||
nextPreference,
|
||||
applyTheme,
|
||||
persistPreference,
|
||||
readStoredPreference,
|
||||
} from '@/lib/theme';
|
||||
|
||||
/**
|
||||
* jsdom 不实现 matchMedia,jest.setup.js 提供了 matches:false 的全局桩。
|
||||
* 该桩是 writable 但非 configurable(Object.defineProperty 会抛
|
||||
* "Cannot redefine property"),因此这里用直接赋值覆盖,不用 defineProperty。
|
||||
*/
|
||||
function mockMatchMedia(matches: boolean) {
|
||||
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
|
||||
matches,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: jest.fn(),
|
||||
removeListener: jest.fn(),
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
}));
|
||||
}
|
||||
|
||||
/** 模拟 matchMedia 不可用/抛错的受限环境(老浏览器、隐私策略) */
|
||||
function breakMatchMedia(mode: 'missing' | 'throws') {
|
||||
window.matchMedia =
|
||||
mode === 'missing'
|
||||
? (undefined as unknown as typeof window.matchMedia)
|
||||
: (jest.fn().mockImplementation(() => {
|
||||
throw new Error('blocked');
|
||||
}) as unknown as typeof window.matchMedia);
|
||||
}
|
||||
|
||||
describe('theme 偏好模块', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
mockMatchMedia(false);
|
||||
});
|
||||
|
||||
describe('normalizePreference', () => {
|
||||
it('接受三档合法偏好', () => {
|
||||
expect(normalizePreference('system')).toBe('system');
|
||||
expect(normalizePreference('light')).toBe('light');
|
||||
expect(normalizePreference('dark')).toBe('dark');
|
||||
});
|
||||
|
||||
it('拒绝非法值与空值,交由调用方回退默认', () => {
|
||||
expect(normalizePreference(null)).toBeNull();
|
||||
expect(normalizePreference(undefined)).toBeNull();
|
||||
expect(normalizePreference('')).toBeNull();
|
||||
expect(normalizePreference('DARK')).toBeNull();
|
||||
expect(normalizePreference('auto')).toBeNull();
|
||||
expect(normalizePreference(1)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSystemTheme', () => {
|
||||
it('系统偏好深色时返回 dark', () => {
|
||||
mockMatchMedia(true);
|
||||
expect(getSystemTheme()).toBe('dark');
|
||||
});
|
||||
|
||||
it('系统偏好浅色时返回 light', () => {
|
||||
mockMatchMedia(false);
|
||||
expect(getSystemTheme()).toBe('light');
|
||||
});
|
||||
|
||||
it('查询串必须是 prefers-color-scheme: dark', () => {
|
||||
getSystemTheme();
|
||||
expect(window.matchMedia).toHaveBeenCalledWith(SYSTEM_PREFERENCE_QUERY);
|
||||
expect(SYSTEM_PREFERENCE_QUERY).toBe('(prefers-color-scheme: dark)');
|
||||
});
|
||||
|
||||
it('matchMedia 缺失(老浏览器)时回退浅色而不抛错', () => {
|
||||
breakMatchMedia('missing');
|
||||
expect(() => getSystemTheme()).not.toThrow();
|
||||
expect(getSystemTheme()).toBe('light');
|
||||
});
|
||||
|
||||
it('matchMedia 抛错(受限环境)时回退浅色', () => {
|
||||
breakMatchMedia('throws');
|
||||
expect(getSystemTheme()).toBe('light');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveTheme', () => {
|
||||
it('system 档透传系统偏好', () => {
|
||||
expect(resolveTheme('system', 'dark')).toBe('dark');
|
||||
expect(resolveTheme('system', 'light')).toBe('light');
|
||||
});
|
||||
|
||||
it('显式档覆盖系统偏好(双向都要成立)', () => {
|
||||
expect(resolveTheme('light', 'dark')).toBe('light');
|
||||
expect(resolveTheme('dark', 'light')).toBe('dark');
|
||||
});
|
||||
|
||||
it('未传系统偏好时现读 matchMedia', () => {
|
||||
mockMatchMedia(true);
|
||||
expect(resolveTheme('system')).toBe('dark');
|
||||
});
|
||||
});
|
||||
|
||||
describe('nextPreference', () => {
|
||||
it('按 system → light → dark → system 循环', () => {
|
||||
expect(nextPreference('system')).toBe('light');
|
||||
expect(nextPreference('light')).toBe('dark');
|
||||
expect(nextPreference('dark')).toBe('system');
|
||||
});
|
||||
|
||||
it('循环顺序常量与循环函数一致(防止两处定义漂移)', () => {
|
||||
expect([...THEME_CYCLE]).toEqual(['system', 'light', 'dark']);
|
||||
THEME_CYCLE.forEach((pref, index) => {
|
||||
const expectedNext = THEME_CYCLE[(index + 1) % THEME_CYCLE.length];
|
||||
expect(nextPreference(pref)).toBe(expectedNext);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('存储读写', () => {
|
||||
it('persistPreference 写入约定 key', () => {
|
||||
persistPreference('dark');
|
||||
expect(window.localStorage.getItem(THEME_STORAGE_KEY)).toBe('dark');
|
||||
expect(THEME_STORAGE_KEY).toBe('novalon-theme');
|
||||
});
|
||||
|
||||
it('readStoredPreference 读出合法偏好', () => {
|
||||
window.localStorage.setItem(THEME_STORAGE_KEY, 'system');
|
||||
expect(readStoredPreference()).toBe('system');
|
||||
});
|
||||
|
||||
it('无存储值时返回 null,由调用方回退默认偏好', () => {
|
||||
expect(readStoredPreference()).toBeNull();
|
||||
expect(DEFAULT_PREFERENCE).toBe('system');
|
||||
});
|
||||
|
||||
it('存储值被污染时返回 null 而非抛错', () => {
|
||||
window.localStorage.setItem(THEME_STORAGE_KEY, '{"bad":1}');
|
||||
expect(readStoredPreference()).toBeNull();
|
||||
});
|
||||
|
||||
it('localStorage 不可用(隐私模式)时读写均静默降级', () => {
|
||||
// jsdom 的 window.localStorage 是 Proxy,jest.spyOn 拿不到 mockImplementation,
|
||||
// 故直接替换 Storage.prototype 上的方法再还原。
|
||||
const proto = Object.getPrototypeOf(window.localStorage) as Storage;
|
||||
const originalGet = proto.getItem;
|
||||
const originalSet = proto.setItem;
|
||||
proto.getItem = () => {
|
||||
throw new Error('denied');
|
||||
};
|
||||
proto.setItem = () => {
|
||||
throw new Error('denied');
|
||||
};
|
||||
|
||||
try {
|
||||
expect(() => persistPreference('dark')).not.toThrow();
|
||||
expect(readStoredPreference()).toBeNull();
|
||||
} finally {
|
||||
proto.getItem = originalGet;
|
||||
proto.setItem = originalSet;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyTheme', () => {
|
||||
it('把解析结果写到 html[data-theme]', () => {
|
||||
applyTheme('dark');
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
|
||||
applyTheme('light');
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBe('light');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user