Files
zhangxiang 0bd1e9a7e2 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-20 11:50:58 +08:00

290 lines
12 KiB
JavaScript
Raw Permalink 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.
// 跟随系统自动切换暗黑模式 —— 运行时验证
//
// 验证「三档偏好 + 首帧无闪烁 + 实时跟随」在真实浏览器里的行为:
// 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;
}