feat(web): 深色主题 —— 反转灰阶而非逐处 dark: 前缀

**逐处加 dark: 前缀的方案在这里必然失败**:约 700 处颜色散在 21 个组件里,
漏一处就是深色下的白底白字,而它不报错、不影响构建、只有肉眼能发现,
且往往只出现在某个不常开的页面。此后每加一个组件都要记得写两遍,
那种约定活不过三次改动。

改法是把颜色下沉到 CSS 变量,深色模式**反转灰阶**。这套代码的灰阶本身
就是语义色阶(white/gray-50 = 表面层次,gray-200/300 = 分隔线,
gray-900→400 = 文字主次),反转之后 `bg-white text-gray-900` 自动变成
深色卡片 + 浅色文字。零组件改动,新组件照常写浅色类名也自动适配。

变量存 **RGB 三元组**而非 #hex:代码里有 bg-blue-50/70 这类透明度修饰符,
Tailwind 生成 rgb(var(--x) / 0.7),而 rgb(#f9fafb / 0.7) 是无效 CSS ——
那些半透明高亮会静默失效(不报错,只是不透明)。

---

实测撞了三个必须分离的语义,每一个共用变量就坏:

**1. text-white 不能跟 bg-white 走。**
`white` 服务两种冲突用途:卡片表面(深色下要变暗)与彩色按钮上的文字
(深色下必须保持浅色)。共用时后者跟着变暗 —— 激活导航项的「收件」在
bg-chrome-700 上只剩 **1.34:1**,几乎消失。拆出 --c-on-accent。

**2. 侧栏与底部导航不能跟 gray 走。**
它们在浅色模式下**本来就是深色的**(深色侧栏配浅色内容区是原本设计)。
并入反转灰阶后深色模式下变成近白色(实测 rgb(243,245,248)),比内容区
(rgb(17,19,24))还亮,整个层次翻过来。独立成 chrome 色阶,深色下只微调、
保持「框架比内容更沉」。

**3. 强调色不能反转。**
blue/red 跟着变会让主按钮在深色页面上失去「这是主操作」的视觉重量,
而且白字落在变暗的 blue-600 上对比度掉到 3:1 以下。改成固定值。

---

顺带修的三处真实对比度不足(实测量出来的,不是猜的):
- 待决策橙徽标:orange-500 上白字 2.80:1 → orange-700 5.18:1
  (保留橙色语义,不能改成灰 —— 它与未读的红色是两种紧急)
- 空状态文案:gray-400 2.43:1 → gray-500。这类文字是**页面上唯一的内容**,
  不是次要装饰,读不动等于页面空白
- 列表头计数:同上

---

主题是**三态**而非开关:system 不是 light 的别名 —— 只给开关的话,
白天设浅色之后晚上系统切深色应用不会跟着变。且只有 pref 为 system 时
才跟随系统,显式选了的人不该因为日落被切换。

index.html 加同步内联脚本消除首帧闪屏:bundle 有 430KB,从 HTML 解析完到
React 挂载之间页面是 body 默认色,深色用户每次刷新都被闪一下白屏。
外链或 defer 都晚于首次绘制。它与 themeStore 共用同一个 localStorage 键
(不一致会导致首帧按 A 键渲染、挂载后按 B 键重渲染,闪一下再变回去)。

body 显式设底色:移动端橡皮筋回弹露出的是 body 背景。

入口两处:侧栏单按钮快速翻转,「我的」页三选一设定偏好。单按钮不足以
表达三态,但只给单按钮的话用户一旦点过就永久脱离「跟随系统」——
那是个回不去的单向门。

测试:test/theme.test.mjs 20 条结构性断言(已进 npm test),
test/manual/theme-verify.mjs 真实渲染对比度验收(遍历可见文本节点算 WCAG
比值,往上找第一个不透明背景)。两种模式各 4 项全过。
This commit is contained in:
2026-09-04 06:30:26 +08:00
parent 7f28552440
commit d74f356f41
14 changed files with 1060 additions and 25 deletions

View File

@ -0,0 +1,114 @@
import { create } from 'zustand';
/**
* 主题偏好。
*
* 三态而不是「开/关」:`system` 是有意义的第三个值,不是 light 的别名。
* 只给开关的话,用户在白天设成浅色之后,晚上系统切深色时应用不会跟着变 ——
* 而那恰恰是大多数人想要的默认行为。
*/
export type ThemePref = 'light' | 'dark' | 'system';
/** 实际生效的主题(system 解析之后的结果)。 */
export type ResolvedTheme = 'light' | 'dark';
const STORAGE_KEY = 'agentmail.theme';
const DARK_QUERY = '(prefers-color-scheme: dark)';
function readStored(): ThemePref {
try {
const v = localStorage.getItem(STORAGE_KEY);
if (v === 'light' || v === 'dark' || v === 'system') return v;
} catch {
// 隐私模式下 localStorage 抛异常。跟随系统是最安全的退路 ——
// 硬编码 light 会让深色偏好的用户每次开页面都被闪一下白屏
}
return 'system';
}
function systemPrefersDark(): boolean {
if (typeof window === 'undefined' || !window.matchMedia) return false;
return window.matchMedia(DARK_QUERY).matches;
}
export function resolveTheme(pref: ThemePref): ResolvedTheme {
if (pref === 'system') return systemPrefersDark() ? 'dark' : 'light';
return pref;
}
/**
* 把主题写进 DOM。
*
* 类名挂在 `<html>` 而不是 `<body>`:tailwind 的 darkMode:'class' 默认
* 从根元素找,而且 `<html>` 上的 background-color 才管得到 overscroll
* 露出的那一片。
*/
function apply(resolved: ResolvedTheme) {
if (typeof document === 'undefined') return;
const root = document.documentElement;
root.classList.toggle('dark', resolved === 'dark');
// 让浏览器把滚动条、表单控件、autofill 背景一并切换。
// 不设的话深色页面上会出现一条浅色滚动条与白底的自动填充输入框。
root.style.colorScheme = resolved;
}
interface ThemeState {
pref: ThemePref;
resolved: ResolvedTheme;
setPref: (p: ThemePref) => void;
/** 在 light / dark 间直接翻转(顶栏那个按钮用)。 */
toggle: () => void;
}
export const useThemeStore = create<ThemeState>((set, get) => ({
pref: readStored(),
resolved: resolveTheme(readStored()),
setPref: p => {
const resolved = resolveTheme(p);
apply(resolved);
try {
localStorage.setItem(STORAGE_KEY, p);
} catch {
// 存不下不影响本次会话
}
set({ pref: p, resolved });
},
/**
* 翻转。
*
* 从 `system` 翻转时落到「与当前生效值相反」的显式值,而不是回到
* system —— 人点这个按钮的意图是「现在换个样子」,把它变成
* system→light(可能毫无变化)会让按钮看起来坏了。
*/
toggle: () => {
const next: ThemePref = get().resolved === 'dark' ? 'light' : 'dark';
get().setPref(next);
}
}));
/**
* 启动时立刻套用主题,并订阅系统变化。
*
* 在 main.tsx 里于 render 之前调用:晚一步就会让深色偏好的用户
* 看到一帧白色闪屏。
*
* 返回取消订阅函数(实际不会用到 —— 应用生命周期内一直需要监听)。
*/
export function initTheme(): () => void {
const store = useThemeStore.getState();
apply(store.resolved);
if (typeof window === 'undefined' || !window.matchMedia) return () => {};
const mq = window.matchMedia(DARK_QUERY);
const onChange = () => {
// 只有 pref 为 system 时才跟随系统。显式选了 light/dark 的人
// 不该因为日落而被切换主题。
const { pref, setPref } = useThemeStore.getState();
if (pref === 'system') setPref('system');
};
mq.addEventListener('change', onChange);
return () => mq.removeEventListener('change', onChange);
}