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
+289
View File
@@ -0,0 +1,289 @@
// 跟随系统自动切换暗黑模式 —— 运行时验证
//
// 验证「三档偏好 + 首帧无闪烁 + 实时跟随」在真实浏览器里的行为:
// 1) 首帧(domcontentloaded,早于 hydrationdata-theme 已由 <head> 内联脚本写对
// 2) 三档循环 system → light → dark → systemDOM 与 localStorage 同步
// 3) 仅「跟随系统」档实时响应 OS 偏好变化;显式选择不被系统覆盖
// 4) 旧存储值(light / dark)向后兼容,污染值回退跟随系统
// 5) CSS 变量确实翻转(不只看属性,看计算后的背景色)
// 6) hydration 成功、无控制台报错
//
// 环境约束(2026-09-02):URL 必须用 localhost127.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 });
// 关键:此时早于 hydrationdata-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-themeOS=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;
}