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-20 11:50:58 +08:00
parent 5159862e96
commit 0bd1e9a7e2
9 changed files with 962 additions and 71 deletions
+13 -2
View File
@@ -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`):