+
diff --git a/web/src/components/ThemePicker.tsx b/web/src/components/ThemePicker.tsx
new file mode 100644
index 0000000..2e63e96
--- /dev/null
+++ b/web/src/components/ThemePicker.tsx
@@ -0,0 +1,83 @@
+import { useThemeStore, type ThemePref } from '../stores/themeStore';
+import { SunIcon, MoonIcon, MonitorIcon } from './icons';
+
+/**
+ * 主题切换。
+ *
+ * 两种形态共用一份状态:
+ * - `compact`(侧栏 / 底部导航):单按钮,点一下翻转
+ * - 默认(「我的」页):三选一,因为 `system` 只有在能明确选中时才有意义
+ *
+ * 单按钮不足以表达三态,但侧栏放不下三个选项;而只给单按钮的话
+ * 用户一旦点过就永久脱离了「跟随系统」—— 那是个回不去的单向门。
+ * 所以两个入口都提供,compact 用于快速切换,完整形态用于设定偏好。
+ */
+
+const OPTIONS: { value: ThemePref; label: string; hint: string; Icon: (p: { className?: string }) => JSX.Element }[] = [
+ { value: 'light', label: '浅色', hint: '始终使用浅色', Icon: SunIcon },
+ { value: 'dark', label: '深色', hint: '始终使用深色', Icon: MoonIcon },
+ { value: 'system', label: '跟随系统', hint: '随系统的深浅色设置切换', Icon: MonitorIcon }
+];
+
+/** 侧栏用的单按钮:点一下在浅/深之间翻转。 */
+export function ThemeToggleButton({ className = '' }: { className?: string }) {
+ const resolved = useThemeStore(s => s.resolved);
+ const pref = useThemeStore(s => s.pref);
+ const toggle = useThemeStore(s => s.toggle);
+
+ const dark = resolved === 'dark';
+ return (
+
+ );
+}
+
+/** 「我的」页用的三选一。 */
+export default function ThemePicker() {
+ const pref = useThemeStore(s => s.pref);
+ const resolved = useThemeStore(s => s.resolved);
+ const setPref = useThemeStore(s => s.setPref);
+
+ return (
+
+
+
外观
+ {pref === 'system' && (
+
+ 当前跟随系统:{resolved === 'dark' ? '深色' : '浅色'}
+
+ )}
+
+
+ {OPTIONS.map(o => {
+ const active = pref === o.value;
+ return (
+
+ );
+ })}
+
+
+ );
+}
diff --git a/web/src/components/icons.tsx b/web/src/components/icons.tsx
index 896d170..851daf9 100644
--- a/web/src/components/icons.tsx
+++ b/web/src/components/icons.tsx
@@ -330,3 +330,84 @@ export function CpuIcon({ className = 'w-4 h-4' }: P) {
);
}
+
+export function CalendarIcon({ className = 'w-4 h-4' }: P) {
+ return (
+
+ );
+}
+
+export function BellIcon({ className = 'w-4 h-4' }: P) {
+ return (
+
+ );
+}
+
+export function RepeatIcon({ className = 'w-4 h-4' }: P) {
+ return (
+
+ );
+}
+
+export function UploadIcon({ className = 'w-4 h-4' }: P) {
+ return (
+
+ );
+}
+
+export function PauseIcon({ className = 'w-4 h-4' }: P) {
+ return (
+
+ );
+}
+
+export function PlayIcon({ className = 'w-4 h-4' }: P) {
+ return (
+
+ );
+}
+
+export function SunIcon({ className = 'w-4 h-4' }: P) {
+ return (
+
+ );
+}
+
+export function MoonIcon({ className = 'w-4 h-4' }: P) {
+ return (
+
+ );
+}
+
+export function MonitorIcon({ className = 'w-4 h-4' }: P) {
+ return (
+
+ );
+}
diff --git a/web/src/index.css b/web/src/index.css
index 38be03f..beae01d 100644
--- a/web/src/index.css
+++ b/web/src/index.css
@@ -2,6 +2,129 @@
@tailwind components;
@tailwind utilities;
+/*
+ * ─── 主题色板 ───
+ *
+ * 全站颜色都经 tailwind.config.js 指向这些变量,因此深色模式**不需要**
+ * 在组件里写 dark: 前缀。逐处加前缀的方案在这里必然失败:约 700 处颜色
+ * 散在 21 个组件里,漏一处就是深色下的白底白字,而且只有肉眼能发现。
+ *
+ * 深色模式的做法是**反转灰阶**:white → 近黑、gray-900 → 近白。
+ * 这套代码里灰阶本身就是语义色阶(表面层次 / 分隔线 / 文字主次),
+ * 反转之后 `bg-white text-gray-900` 自动变成「深色卡片 + 浅色文字」。
+ * 新加的组件照常写浅色类名也自动适配。
+ *
+ * 值存 RGB 三元组而不是 #hex:代码里有 bg-blue-50/70 这类透明度修饰符,
+ * Tailwind 会生成 rgb(var(--x) / 0.7),而 rgb(#f9fafb / 0.7) 是无效 CSS
+ * —— 那些半透明高亮会静默失效(不报错,只是不透明)。
+ */
+:root {
+ /*
+ * 表面色(卡片 / 输入框底)。深色模式下变暗。
+ */
+ --c-white: 255 255 255;
+
+ /*
+ * 彩色按钮与深色框架上的文字。**不随主题反转。**
+ *
+ * 它与 --c-white 必须分开,因为 `white` 在这套代码里服务两种互相冲突的用途:
+ * - `bg-white` = 卡片表面 → 深色模式必须变暗
+ * - `text-white` = 按钮上文字 → 深色模式必须保持浅色
+ *
+ * 共用一个变量时后者跟着变暗,实测激活导航项的「收件」在 bg-chrome-700 上
+ * 只剩 1.34:1 —— 几乎看不见。见 tailwind.config.js 的 textColor 覆盖。
+ */
+ --c-on-accent: 255 255 255;
+
+ --c-gray-50: 249 250 251;
+ --c-gray-100: 243 244 246;
+ --c-gray-200: 229 231 235;
+ --c-gray-300: 209 213 219;
+ --c-gray-400: 156 163 175;
+ --c-gray-500: 107 114 128;
+ --c-gray-600: 75 85 99;
+ --c-gray-700: 55 65 81;
+ --c-gray-800: 31 41 55;
+ --c-gray-900: 17 24 39;
+ --c-gray-950: 3 7 18;
+
+ /*
+ * 应用框架(侧栏 / 底部导航)。
+ *
+ * 独立成一条色阶而不跟 gray 走:这两块**在浅色模式下本来就是深色的**
+ * (深色侧栏配浅色内容区是这套 UI 的原本设计)。并入反转的 gray 之后,
+ * 深色模式下 bg-slate-900 会变成近白色 —— 侧栏比内容区还亮,
+ * 整个层次翻过来(实测 rgb(243,245,248) vs 内容区 rgb(17,19,24))。
+ */
+ --c-chrome-100: 241 245 249;
+ --c-chrome-200: 226 232 240;
+ --c-chrome-400: 148 163 184;
+ --c-chrome-600: 71 85 105;
+ --c-chrome-700: 51 65 85;
+ --c-chrome-800: 30 41 59;
+ --c-chrome-900: 15 23 42;
+
+ color-scheme: light;
+}
+
+/*
+ * 深色模式。
+ *
+ * # 灰阶整体反转
+ *
+ * - `white`(卡片底)→ 近黑的深灰。**不用纯黑**:纯黑上的浅色文字
+ * 对比过强,长时间看更累,也看不出层次。
+ * - `gray-50`(页面底)→ 比卡片**更暗**。浅色下页面底比卡片浅,
+ * 深色下必须反过来,否则卡片会陷进背景失去边界。
+ * - `gray-200/300`(分隔线)→ 中低亮度灰。照搬浅色值会得到刺眼的白线。
+ * - `gray-400/500`(次要文字)→ **提亮**。深底上的浅色 gray-400 只有
+ * 约 2:1 对比度,远低于 WCAG AA 的 4.5:1 —— 看得见但读不动。
+ *
+ * # 强调色不反转
+ *
+ * blue/red/green/... 在 tailwind.config.js 里是**固定值**,不走变量。
+ * 按钮底色在深色模式下依然是 blue-600 那样的彩色,跟着变会让主按钮
+ * 在深色页面上失去「这是主操作」的视觉重量。
+ *
+ * # 框架色阶只微调
+ *
+ * 见下面 --c-chrome-* 的注释。
+ */
+.dark {
+ --c-white: 24 27 33;
+
+ /* 近白而非纯白:深色页面上纯白字偏刺眼。关键是它不跟着 --c-white 变暗。 */
+ --c-on-accent: 244 246 250;
+
+ --c-gray-50: 17 19 24;
+ --c-gray-100: 32 36 44;
+ --c-gray-200: 44 49 59;
+ --c-gray-300: 61 68 81;
+ --c-gray-400: 138 146 161;
+ --c-gray-500: 165 173 186;
+ --c-gray-600: 190 197 208;
+ --c-gray-700: 212 217 225;
+ --c-gray-800: 231 235 240;
+ --c-gray-900: 243 245 248;
+ --c-gray-950: 250 251 253;
+
+ /*
+ * 框架色阶**不反转,只微调**:比内容区(--c-gray-50 = 17 19 24)
+ * 再深一档,保持「框架比内容更沉」这个浅色下就有的关系。
+ * 文字档位相应提亮 —— 底色变深后原来的 chrome-400 只剩约 2.9:1。
+ */
+ --c-chrome-100: 236 240 246;
+ --c-chrome-200: 214 221 232;
+ --c-chrome-400: 148 158 175;
+ --c-chrome-600: 58 65 78;
+ --c-chrome-700: 44 50 61;
+ --c-chrome-800: 30 35 44;
+ --c-chrome-900: 12 14 18;
+
+ /* 让浏览器把滚动条、表单控件、autofill 一并切深色 */
+ color-scheme: dark;
+}
+
@layer base {
html, body, #root {
height: 100%;
@@ -9,6 +132,12 @@
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
+ /*
+ * 显式给 body 底色。移动端橡皮筋回弹时露出的是 body 背景 ——
+ * 不设的话深色模式下滑到边界会闪出一条白边。
+ */
+ background-color: rgb(var(--c-gray-50));
+ color: rgb(var(--c-gray-900));
}
}
diff --git a/web/src/main.tsx b/web/src/main.tsx
index 2339d59..d04ff09 100644
--- a/web/src/main.tsx
+++ b/web/src/main.tsx
@@ -1,8 +1,14 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
+import { initTheme } from './stores/themeStore';
import './index.css';
+// 必须在 render 之前:晚一步就会让深色偏好的用户看到一帧白色闪屏。
+// index.html 里还有一段更早的内联脚本处理「JS bundle 到达前」那段空窗,
+// 这里做的是把 store 状态与 DOM 对齐并订阅系统主题变化。
+initTheme();
+
ReactDOM.createRoot(document.getElementById('root')!).render(
diff --git a/web/src/stores/themeStore.ts b/web/src/stores/themeStore.ts
new file mode 100644
index 0000000..1b4a0bb
--- /dev/null
+++ b/web/src/stores/themeStore.ts
@@ -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。
+ *
+ * 类名挂在 `` 而不是 ``:tailwind 的 darkMode:'class' 默认
+ * 从根元素找,而且 `` 上的 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((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);
+}
diff --git a/web/tailwind.config.js b/web/tailwind.config.js
index 9200905..32f0011 100644
--- a/web/tailwind.config.js
+++ b/web/tailwind.config.js
@@ -1,8 +1,139 @@
/** @type {import('tailwindcss').Config} */
+
+/**
+ * 颜色走 CSS 变量而不是写死的十六进制。
+ *
+ * # 为什么不逐处加 dark: 前缀
+ *
+ * 全站约 700 处颜色用法散在 21 个组件里。逐个写 `bg-white dark:bg-gray-900`
+ * 有两个致命问题:漏一处就是深色下的白底白字(而且只有肉眼能发现),
+ * 以及此后每加一个组件都要记得写两遍 —— 那种约定活不过三次改动。
+ *
+ * # 为什么改调色板就够了
+ *
+ * 这套代码里灰阶**本身就是语义色阶**:
+ * - `white` / `gray-50` / `gray-100` = 表面层次(卡片 / 页面底 / 悬停)
+ * - `gray-200` / `gray-300` = 分隔线
+ * - `gray-900` → `gray-400` = 文字主次
+ *
+ * 深色模式要做的正是把这条色阶**反转**:white 变近黑、gray-900 变近白。
+ * 于是零组件改动就能整体切换,新组件照常写 `bg-white text-gray-900`
+ * 也自动适配 —— 不需要任何人记得任何约定。
+ *
+ * # 为什么是 `rgb(var(--x) / )` 而不是直接存颜色串
+ *
+ * 代码里有 `bg-blue-50/70`、`bg-gray-50/60` 这样的透明度修饰符。
+ * 变量若存 `#f9fafb`,Tailwind 生成的 `rgb(#f9fafb / 0.7)` 是无效 CSS,
+ * 那些半透明高亮会静默失效(不报错,只是不透明)。存 RGB 三元组才行。
+ */
+const withAlpha = (v) => `rgb(var(${v}) / )`;
+
+const grayScale = {
+ 50: withAlpha('--c-gray-50'),
+ 100: withAlpha('--c-gray-100'),
+ 200: withAlpha('--c-gray-200'),
+ 300: withAlpha('--c-gray-300'),
+ 400: withAlpha('--c-gray-400'),
+ 500: withAlpha('--c-gray-500'),
+ 600: withAlpha('--c-gray-600'),
+ 700: withAlpha('--c-gray-700'),
+ 800: withAlpha('--c-gray-800'),
+ 900: withAlpha('--c-gray-900'),
+ 950: withAlpha('--c-gray-950')
+};
+
+/** 强调色只需要三档:浅底(chip/提示条)、主色(按钮)、深色(hover/文字)。 */
+const accent = (name) => ({
+ 50: withAlpha(`--c-${name}-50`),
+ 100: withAlpha(`--c-${name}-100`),
+ 200: withAlpha(`--c-${name}-200`),
+ 300: withAlpha(`--c-${name}-300`),
+ 400: withAlpha(`--c-${name}-400`),
+ 500: withAlpha(`--c-${name}-500`),
+ 600: withAlpha(`--c-${name}-600`),
+ 700: withAlpha(`--c-${name}-700`),
+ 800: withAlpha(`--c-${name}-800`),
+ 900: withAlpha(`--c-${name}-900`)
+});
+
export default {
- content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
+ content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
+ // class 而不是 media:主题要能被人显式选择。跟系统走是**默认值**,
+ // 不是唯一选项 —— 白天开深色主题是常见偏好。
+ darkMode: 'class',
theme: {
- extend: {}
+ extend: {
+ /**
+ * textColor 单独覆盖 white。
+ *
+ * `--c-white` 服务两种**互相冲突**的用途:
+ * - `bg-white` = 卡片表面 → 深色模式必须变暗
+ * - `text-white` = 彩色按钮上的文字 → 深色模式必须**保持浅色**
+ *
+ * 只有一个变量时后者跟着变暗,白字落在 `bg-chrome-700` 的激活导航项上
+ * 只剩 1.34:1 —— 几乎不可见(实测发现)。按钮底色在深色模式下依然是
+ * blue-600 那样的彩色,上面的文字本来就该是白的。
+ *
+ * Tailwind 的 textColor 默认继承 colors,这里只改 white 一项,
+ * 其余(gray/blue/...)仍走反转的色阶。
+ */
+ textColor: {
+ white: withAlpha('--c-on-accent')
+ },
+ colors: {
+ white: withAlpha('--c-white'),
+ gray: grayScale,
+ // slate 在这套代码里只用于登录页与少数深色块,与 gray 同源即可 ——
+ // 保留两个名字是为了不改那些组件,但它们指向同一条色阶。
+ slate: grayScale,
+ /**
+ * chrome —— 应用框架(侧栏 / 底部导航)的专用色阶。
+ *
+ * 为什么不能跟 gray 走:这两块**在浅色模式下本来就是深色的**
+ * (深色侧栏配浅色内容区是这套 UI 的原本设计)。把它们并入反转的
+ * gray 之后,深色模式下 `bg-slate-900` 变成了近白色 —— 侧栏比内容区
+ * 还亮,整个层次翻了过来(实测 rgb(243,245,248),而内容区是 rgb(17,19,24))。
+ *
+ * 独立成一条色阶后:浅色模式下它是深色框架,深色模式下**微调即可**
+ * (比内容区略深一点,保持"框架比内容更沉"的关系),两种模式下
+ * 语义一致。
+ */
+ chrome: {
+ 100: withAlpha('--c-chrome-100'),
+ 200: withAlpha('--c-chrome-200'),
+ 400: withAlpha('--c-chrome-400'),
+ 600: withAlpha('--c-chrome-600'),
+ 700: withAlpha('--c-chrome-700'),
+ 800: withAlpha('--c-chrome-800'),
+ 900: withAlpha('--c-chrome-900')
+ },
+ /* accent 色走固定值,不随主题反转。深色模式下彩色按钮底色不变,
+ 变的是卡片/表面的深浅,所以白色文字的对比度始终稳定。 */
+ blue: { 50:'#eff6ff', 100:'#dbeafe', 200:'#bfdbfe', 300:'#93c5fd',
+ 400:'#60a5fa', 500:'#3b82f6', 600:'#2563eb', 700:'#1d4ed8',
+ 800:'#1e40af', 900:'#1e3a8a' },
+ red: { 50:'#fef2f2', 100:'#fee2e2', 200:'#fecaca', 300:'#fca5a5',
+ 400:'#f87171', 500:'#ef4444', 600:'#dc2626', 700:'#b91c1c',
+ 800:'#991b1b', 900:'#7f1d1d' },
+ green: { 50:'#f0fdf4', 100:'#dcfce7', 200:'#bbf7d0', 300:'#86efac',
+ 400:'#4ade80', 500:'#22c55e', 600:'#16a34a', 700:'#15803d',
+ 800:'#166534', 900:'#14532d' },
+ amber: { 50:'#fffbeb', 100:'#fef3c7', 200:'#fde68a', 300:'#fcd34d',
+ 400:'#fbbf24', 500:'#f59e0b', 600:'#d97706', 700:'#b45309',
+ 800:'#92400e', 900:'#78350f' },
+ orange: { 50:'#fff7ed', 100:'#ffedd5', 200:'#fed7aa', 300:'#fdba74',
+ 400:'#fb923c', 500:'#f97316', 600:'#ea580c', 700:'#c2410c',
+ 800:'#9a3412', 900:'#7c2d12' },
+ yellow: { 50:'#fefce8', 100:'#fef9c3', 200:'#fef08a', 300:'#fde047',
+ 400:'#facc15', 500:'#eab308', 600:'#ca8a04', 700:'#a16207',
+ 800:'#854d0e', 900:'#713f12' },
+ red: accent('red'),
+ green: accent('green'),
+ amber: accent('amber'),
+ orange: accent('orange'),
+ yellow: accent('yellow')
+ }
+ }
},
plugins: []
};
diff --git a/web/test/manual/theme-verify.mjs b/web/test/manual/theme-verify.mjs
new file mode 100644
index 0000000..080834f
--- /dev/null
+++ b/web/test/manual/theme-verify.mjs
@@ -0,0 +1,151 @@
+/**
+ * 深色主题手工验收。
+ *
+ * 需要共享 Chromium(CDP 9222)。结构性检查已在 test/theme.test.mjs 里,
+ * 这里验的是**真实渲染出来的对比度** —— 那是唯一能发现白底白字的判据。
+ *
+ * 用法:
+ * ADMIN_USER=jianf ADMIN_PW=... AGENTMAIL_URL=http://127.0.0.1:8180 \
+ * node web/test/manual/theme-verify.mjs
+ */
+import { openApp, WIDE } from './narrow-probe-helper.mjs';
+
+let pass = 0, fail = 0;
+const check = (name, ok, detail = '') => {
+ if (ok) { pass++; console.log(` 通过 ${name}`); }
+ else { fail++; console.log(` 失败 ${name}${detail ? ' — ' + detail : ''}`); }
+};
+
+/** 相对亮度(WCAG)。用于判断 body 底色是否真的变暗。 */
+function luminance([r, g, b]) {
+ const f = c => {
+ c /= 255;
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
+ };
+ return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
+}
+
+const parseRgb = s => {
+ const m = String(s).match(/(\d+),\s*(\d+),\s*(\d+)/);
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
+};
+
+const { browser, page, issues } = await openApp(WIDE);
+
+try {
+
+ for (const mode of ['light', 'dark']) {
+ console.log(`\n── ${mode} ──`);
+ await page.evaluate(m => {
+ localStorage.setItem('agentmail.theme', m);
+ document.documentElement.classList.toggle('dark', m === 'dark');
+ document.documentElement.style.colorScheme = m;
+ }, mode);
+ await page.waitForTimeout(400);
+
+ const body = parseRgb(await page.evaluate(() =>
+ getComputedStyle(document.body).backgroundColor));
+ check(`${mode}: body 底色可读取`, body !== null, String(body));
+
+ if (mode === 'dark') {
+ // 深色下 body 必须是暗的。这一条挂掉说明变量没生效
+ check('dark: body 底色确实是暗的', luminance(body) < 0.2,
+ `亮度 ${luminance(body).toFixed(3)}`);
+ }
+
+ // 遍历可见文本节点,算每个的前景/背景对比度。
+ // 4.5:1 是 WCAG AA 的正文标准;大字放宽到 3:1。
+ const bad = await page.evaluate(() => {
+ const out = [];
+ const els = document.querySelectorAll('button, a, h1, h2, h3, p, span, div, label, li');
+ const lum = ([r, g, b]) => {
+ const f = c => { c /= 255; return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; };
+ return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
+ };
+ const parse = s => {
+ const m = String(s).match(/(\d+),\s*(\d+),\s*(\d+)/);
+ return m ? [+m[1], +m[2], +m[3]] : null;
+ };
+ /** 往上找第一个不透明的背景。 */
+ const bgOf = el => {
+ let cur = el;
+ while (cur && cur !== document.documentElement) {
+ const cs = getComputedStyle(cur);
+ const c = parse(cs.backgroundColor);
+ const alpha = String(cs.backgroundColor).match(/rgba?\([^)]*,\s*([\d.]+)\)/);
+ if (c && (!alpha || Number(alpha[1]) > 0.85)) return c;
+ cur = cur.parentElement;
+ }
+ return parse(getComputedStyle(document.body).backgroundColor);
+ };
+ for (const el of els) {
+ // 只看直接含文本的元素
+ const text = [...el.childNodes]
+ .filter(n => n.nodeType === 3)
+ .map(n => n.textContent.trim())
+ .join('');
+ if (!text) continue;
+ const r = el.getBoundingClientRect();
+ if (r.width < 4 || r.height < 4) continue;
+ const cs = getComputedStyle(el);
+ if (cs.visibility === 'hidden' || cs.opacity === '0') continue;
+ const fg = parse(cs.color);
+ const bg = bgOf(el);
+ if (!fg || !bg) continue;
+ const la = lum(fg), lb = lum(bg);
+ const [hi, lo] = la > lb ? [la, lb] : [lb, la];
+ const ratio = (hi + 0.05) / (lo + 0.05);
+ const size = parseFloat(cs.fontSize);
+ const bold = Number(cs.fontWeight) >= 700;
+ const large = size >= 24 || (size >= 18.66 && bold);
+ const need = large ? 3 : 4.5;
+ if (ratio < need) {
+ out.push({
+ text: text.slice(0, 24),
+ ratio: Number(ratio.toFixed(2)),
+ need,
+ fg: cs.color,
+ bg: `rgb(${bg.join(',')})`
+ });
+ }
+ }
+ return out;
+ });
+
+ // 允许少量刻意的低对比装饰(占位符、禁用态)
+ const severe = bad.filter(x => x.ratio < 2.5);
+ check(`${mode}: 无严重低对比文本(< 2.5:1)`, severe.length === 0,
+ severe.slice(0, 4).map(x => `"${x.text}" ${x.ratio}`).join(' | '));
+ if (bad.length) {
+ console.log(` (${bad.length} 处低于 AA 阈值,最差 ${Math.min(...bad.map(x => x.ratio))}:1)`);
+ // 逐条列出来而不只报个数:不知道是哪一处就没法修
+ for (const x of bad.slice(0, 8)) {
+ console.log(` ${x.ratio}:1 (需 ${x.need}) "${x.text}" ${x.fg} on ${x.bg}`);
+ }
+ }
+
+ // 白底白字的典型形态:前景与背景几乎相同
+ const invisible = bad.filter(x => x.ratio < 1.3);
+ check(`${mode}: 没有不可见文本(< 1.3:1)`, invisible.length === 0,
+ invisible.slice(0, 3).map(x => `"${x.text}"`).join(' | '));
+
+ await page.screenshot({ path: `/tmp/theme-${mode}.png`, fullPage: false });
+ }
+
+ // 刷新后主题必须保持(localStorage + 内联脚本)
+ await page.evaluate(() => localStorage.setItem('agentmail.theme', 'dark'));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await page.waitForTimeout(600);
+ const stillDark = await page.evaluate(() =>
+ document.documentElement.classList.contains('dark'));
+ check('刷新后深色保持(内联脚本生效)', stillDark);
+
+ check('无 JS 运行时错误', issues.length === 0, issues.slice(0, 3).join(' | '));
+} finally {
+ await page.close();
+ await browser.close();
+}
+
+console.log(`\n主题验收:${pass} 通过${fail ? `,${fail} 失败` : ''}`);
+console.log('截图:/tmp/theme-light.png /tmp/theme-dark.png');
+process.exit(fail ? 1 : 0);
diff --git a/web/test/narrow-layout.test.mjs b/web/test/narrow-layout.test.mjs
index da7d4fc..8ce54b0 100644
--- a/web/test/narrow-layout.test.mjs
+++ b/web/test/narrow-layout.test.mjs
@@ -39,7 +39,7 @@ check(
// 2) 固定宽度的中间栏在窄屏必须让位。
// 断言的是「w-full + md: 前缀的固定宽度」这个形态,不是某个具体像素值 ——
// ContactPanel 的卡片视图用 400px,列表视图用 320px。
-for (const f of ['MailList', 'ContactPanel']) {
+for (const f of ['MailList', 'PermissionList', 'ContactPanel', 'CalendarView']) {
const src = read(`../src/components/${f}.tsx`);
const narrowFullWidth = src.includes('w-full');
// 固定宽度只能出现在 md: 断点后面;裸 w-[NNNpx] 会在 375px 屏上挤掉详情。
@@ -141,7 +141,7 @@ check('对话树窄屏有返回出口', thread.includes(' readFileSync(join(here, p), 'utf8');
+
+let pass = 0;
+let fail = 0;
+const check = (name, ok, detail = '') => {
+ if (ok) {
+ pass++;
+ console.log(` 通过 ${name}`);
+ } else {
+ fail++;
+ console.log(` 失败 ${name}${detail ? ' — ' + detail : ''}`);
+ }
+};
+
+const css = read('../src/index.css');
+const cfg = read('../tailwind.config.js');
+const html = read('../index.html');
+
+// 1) tailwind 必须走 class 策略。
+// media 策略下主题无法被人显式选择 —— 白天想开深色就做不到。
+check('darkMode 为 class 策略', /darkMode:\s*['"]class['"]/.test(cfg));
+
+// 2) 颜色必须经 CSS 变量。写死十六进制的话深色模式无从切换。
+check(
+ '调色板指向 CSS 变量',
+ cfg.includes('rgb(var(') && cfg.includes(''),
+ '缺少 rgb(var(--x) / ) 形态'
+);
+
+// 3) 变量值必须是 RGB 三元组而不是 #hex。
+// 代码里有 bg-blue-50/70 这类透明度修饰符,#hex 会生成无效 CSS,
+// 那些半透明高亮静默失效(不报错,只是不透明)。
+const varLines = css.match(/--c-[a-z]+-?\d*:\s*[^;]+;/g) || [];
+const hexVars = varLines.filter(l => l.includes('#'));
+check(
+ '色板变量存 RGB 三元组而非 #hex',
+ varLines.length >= 30 && hexVars.length === 0,
+ hexVars.length ? `${hexVars.length} 个变量是 hex:${hexVars[0]}` : `只找到 ${varLines.length} 个变量`
+);
+
+// 4) 必须有 .dark 覆盖块,且覆盖了同样多的变量。
+// 漏掉的那些会在深色下保持浅色值 —— 那正是白底白字的来源。
+const lightBlock = css.slice(css.indexOf(':root'), css.indexOf('.dark'));
+const darkBlock = css.slice(css.indexOf('.dark {'));
+const lightVars = new Set((lightBlock.match(/--c-[\w-]+(?=:)/g) || []));
+const darkVars = new Set((darkBlock.match(/--c-[\w-]+(?=:)/g) || []));
+const missing = [...lightVars].filter(v => !darkVars.has(v));
+check(
+ '.dark 覆盖了全部色板变量',
+ lightVars.size >= 15 && missing.length === 0,
+ missing.length ? `深色缺 ${missing.length} 个:${missing.slice(0, 5).join(', ')}` : `浅色只有 ${lightVars.size} 个`
+);
+
+// 5) 灰阶必须真的反转:深色的 white 要比 gray-900 暗。
+// 不反转的话组件里的 `bg-white text-gray-900` 在深色下依然是白底黑字。
+const lum = (block, name) => {
+ const m = block.match(new RegExp(`--c-${name}:\\s*(\\d+)\\s+(\\d+)\\s+(\\d+)`));
+ if (!m) return null;
+ return (Number(m[1]) + Number(m[2]) + Number(m[3])) / 3;
+};
+const darkWhite = lum(darkBlock, 'white');
+const darkG900 = lum(darkBlock, 'gray-900');
+check(
+ '深色下灰阶已反转(white 比 gray-900 暗)',
+ darkWhite !== null && darkG900 !== null && darkWhite < darkG900,
+ `white=${darkWhite} gray-900=${darkG900}`
+);
+
+// 6) 深色的页面底(gray-50)必须比卡片(white)更暗。
+// 浅色下页面底比卡片浅,深色下要反过来 —— 否则卡片陷进背景失去边界。
+const darkG50 = lum(darkBlock, 'gray-50');
+check(
+ '深色下页面底比卡片更暗',
+ darkG50 !== null && darkWhite !== null && darkG50 < darkWhite,
+ `gray-50=${darkG50} white=${darkWhite}`
+);
+
+// 7) 次要文字(gray-400/500)在深色下必须提亮。
+// 照搬浅色值只有约 2:1 对比度,远低于 WCAG AA 的 4.5:1 ——
+// 实际效果是「看得见但读不动」。
+const lightG400 = lum(lightBlock, 'gray-400');
+const darkG400 = lum(darkBlock, 'gray-400');
+check(
+ '深色下次要文字未沿用浅色值',
+ darkG400 !== null && lightG400 !== null && Math.abs(darkG400 - lightG400) > 5,
+ `light=${lightG400} dark=${darkG400}`
+);
+
+// 8) color-scheme 两处都要设。
+// 不设的话深色页面上会出现浅色滚动条与白底的 autofill 输入框。
+check(
+ ':root 与 .dark 都声明 color-scheme',
+ /color-scheme:\s*light/.test(lightBlock) && /color-scheme:\s*dark/.test(darkBlock)
+);
+
+// 9) index.html 必须有同步内联脚本消除首帧闪屏。
+// bundle 有几百 KB,从 HTML 解析完到 React 挂载之间页面是 body 默认色 ——
+// 深色用户每次刷新都被闪一下白屏。外链或 defer 都晚于首次绘制。
+check(
+ 'index.html 内联防闪屏脚本',
+ html.includes('agentmail.theme') &&
+ html.includes('prefers-color-scheme') &&
+ html.includes("classList.add('dark')") &&
+ !/