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

@ -2,8 +2,37 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>AgentMail</title>
<script>
/*
* 首帧防闪屏。
*
* JS bundle 有几百 KB从 HTML 解析完到 React 挂载之间有一段空窗 ——
* 那段时间里页面是 body 的默认底色。深色偏好的用户会被闪一下白屏,
* 而且是每次刷新都闪。
*
* 这段脚本必须**内联且同步**:外链或 defer 都会晚于首次绘制。
* 逻辑与 themeStore 的 readStored/resolveTheme 一致,
* 改那边的键名或取值时这里要同步。
*/
(function () {
try {
var p = localStorage.getItem('agentmail.theme') || 'system';
var dark =
p === 'dark' ||
(p === 'system' &&
window.matchMedia &&
window.matchMedia('(prefers-color-scheme: dark)').matches);
if (dark) {
document.documentElement.classList.add('dark');
document.documentElement.style.colorScheme = 'dark';
}
} catch (e) {
/* localStorage 不可用(隐私模式)时退回浅色,不阻塞渲染 */
}
})();
</script>
</head>
<body class="bg-gray-50 text-gray-900 antialiased">
<div id="root"></div>

View File

@ -1,4 +1,4 @@
import { useEffect } from 'react';
import { Suspense, lazy, useEffect } from 'react';
import { connectSSE } from './api/sse';
import { useAuthStore } from './stores/authStore';
import { useMailStore } from './stores/mailStore';
@ -7,6 +7,7 @@ import { useContactStore } from './stores/contactStore';
import { useUIStore } from './stores/uiStore';
import Sidebar from './components/Sidebar';
import MailList from './components/MailList';
import PermissionList from './components/PermissionList';
import ContactPanel from './components/ContactPanel';
import MailView from './components/MailView';
import ComposePage from './components/ComposePage';
@ -14,6 +15,13 @@ import LoginPage from './components/LoginPage';
import SetupPage from './components/SetupPage';
import AccountPage from './components/AccountPage';
import AdminUsersPage from './components/AdminUsersPage';
// 日历懒加载。
//
// 它传递依赖 lunar-javascriptnode_modules 里 588KB—— 全量打进主 chunk
// 会让**登录页**也背上这份体积(实测主 bundle 447KB → 751KB
// gzip 133 → 239KB。农历表是一大张查找数据压不动也没必要提前拉
// 大多数会话根本不打开日历。
const CalendarView = lazy(() => import('./components/CalendarView'));
import NarrowNav from './components/NarrowNav';
import NarrowStack from './components/NarrowStack';
import { useIsNarrow } from './hooks/useIsNarrow';
@ -115,16 +123,24 @@ export default function App() {
<ComposePage />
) : viewMode === 'account' ? (
<AccountPage />
) : viewMode === 'calendar' ? (
// 日历自己在内部分两栏(网格 + 当日日程/编辑器),因此走 main 分支
// 而不是复用外层的 list/main —— 它的「列表」是网格,不是条目清单。
<Suspense fallback={<PaneLoading />}>
<CalendarView />
</Suspense>
) : viewMode === 'admin' && user?.role === 'admin' ? (
<AdminUsersPage />
) : (
<MailView />
);
// 列表(中栏):收发件箱联系人视图有
// 列表(中栏):收发件箱、授权、联系人视图有
const list =
viewMode === 'contacts' ? (
<ContactPanel />
) : viewMode === 'permissions' ? (
<PermissionList />
) : viewMode === 'inbox' || viewMode === 'sent' ? (
<MailList />
) : null;
@ -161,6 +177,15 @@ export default function App() {
);
}
/** 懒加载 chunk 到达前的占位。占满主区域,避免布局跳动。 */
function PaneLoading() {
return (
<div className="flex-1 min-w-0 flex items-center justify-center bg-gray-50 text-gray-400">
<p className="text-sm"></p>
</div>
);
}
/**
* 窄屏外壳:内容区 + 底部导航。
*

View File

@ -3,6 +3,7 @@ import { useAuthStore } from '../stores/authStore';
import * as api from '../api/client';
import { LockIcon, LogoutIcon } from './icons';
import KeyPanel from './KeyPanel';
import ThemePicker from './ThemePicker';
/** 当前用户个人中心:查看资料、修改密码、管理客户端连接密钥 */
export default function AccountPage() {
@ -211,6 +212,12 @@ export default function AccountPage() {
/>
</section>
{/* 外观。放在密钥之后、退出之前:它是一个高频且完全可逆的偏好,
与「账号自身」的密码/密钥属于不同性质,但同样是「我的设置」。 */}
<section className="border-t border-gray-200 pt-6">
<ThemePicker />
</section>
{/* 退出登录。
放在这里而不是导航里:它是一个低频且不可逆的动作,
与密码、密钥同属「账号自身」。窄屏下这也是唯一的退出口:

View File

@ -2,8 +2,18 @@ import { useUIStore, type ViewMode } from '../stores/uiStore';
import { useMailStore } from '../stores/mailStore';
import { useContactStore } from '../stores/contactStore';
import { useAuthStore } from '../stores/authStore';
import { InboxIcon, SentIcon, ContactsIcon, ComposeIcon, UsersIcon, PersonIcon } from './icons';
import {
InboxIcon,
SentIcon,
ContactsIcon,
ComposeIcon,
UsersIcon,
PersonIcon,
ShieldIcon,
CalendarIcon
} from './icons';
import { ConnectionIndicator } from './ConnectionIndicator';
import { countPendingPermissions, splitByPermission } from '../lib/mailGroups';
/**
* 窄屏底部导航。
@ -22,7 +32,9 @@ const items: {
adminOnly?: boolean;
}[] = [
{ short: '收件', mode: 'inbox', Icon: InboxIcon },
{ short: '授权', mode: 'permissions', Icon: ShieldIcon },
{ short: '发件', mode: 'sent', Icon: SentIcon },
{ short: '日历', mode: 'calendar', Icon: CalendarIcon },
{ short: '联系人', mode: 'contacts', Icon: ContactsIcon },
{ short: '管理', mode: 'admin', Icon: UsersIcon, adminOnly: true }
];
@ -35,7 +47,10 @@ export default function NarrowNav() {
const narrowPane = useUIStore(s => s.narrowPane);
const inbox = useMailStore(s => s.inbox);
const unread = inbox.filter(m => m.status === 'unread').length;
// 与 Sidebar 同一判据:权限请求归「授权」,不算进收件箱未读
const { normal, permissions } = splitByPermission(inbox);
const unread = normal.filter(m => m.status === 'unread').length;
const pendingPerms = countPendingPermissions(permissions);
const contacts = useContactStore(s => s.contacts);
const user = useAuthStore(s => s.user);
@ -45,7 +60,7 @@ export default function NarrowNav() {
return (
<nav
className="shrink-0 border-t border-slate-700 bg-slate-900 flex items-stretch"
className="shrink-0 border-t border-chrome-700 bg-chrome-900 flex items-stretch"
// 底部安全区iPhone 的手势条会盖住最后一排
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
>
@ -53,13 +68,20 @@ export default function NarrowNav() {
// 详情栏打开时不高亮任何导航项:此刻用户看的是某封邮件,
// 高亮「收件」会让人以为点它能回到列表(其实是同一项)
const active = viewMode === mode && !composing && narrowPane === 'list';
const badge = mode === 'inbox' ? unread : mode === 'contacts' ? contacts.length : 0;
const badge =
mode === 'inbox'
? unread
: mode === 'permissions'
? pendingPerms
: mode === 'contacts'
? contacts.length
: 0;
return (
<button
key={mode}
onClick={() => setViewMode(mode)}
className={`relative flex-1 py-2 flex flex-col items-center justify-center gap-0.5 transition-colors ${
active ? 'text-white' : 'text-slate-400 active:bg-slate-800'
active ? 'text-white' : 'text-chrome-400 active:bg-chrome-800'
}`}
>
<Icon />
@ -67,7 +89,11 @@ export default function NarrowNav() {
{badge > 0 && (
<span
className={`absolute top-1 right-[22%] min-w-[15px] h-[15px] px-1 rounded-full text-[9px] font-bold flex items-center justify-center ${
mode === 'inbox' ? 'bg-red-500 text-white' : 'bg-slate-600 text-slate-100'
mode === 'inbox'
? 'bg-red-500 text-white'
: mode === 'permissions'
? 'bg-orange-700 text-white'
: 'bg-chrome-600 text-chrome-100'
}`}
>
{badge > 99 ? '99+' : badge}
@ -81,7 +107,7 @@ export default function NarrowNav() {
<button
onClick={() => startCompose()}
className={`flex-1 py-2 flex flex-col items-center justify-center gap-0.5 ${
composing ? 'text-blue-300' : 'text-blue-400 active:bg-slate-800'
composing ? 'text-blue-300' : 'text-blue-400 active:bg-chrome-800'
}`}
>
<ComposeIcon />
@ -93,7 +119,7 @@ export default function NarrowNav() {
className={`flex-1 py-2 flex flex-col items-center justify-center gap-0.5 ${
viewMode === 'account' && !composing
? 'text-white'
: 'text-slate-400 active:bg-slate-800'
: 'text-chrome-400 active:bg-chrome-800'
}`}
>
<div className="relative">

View File

@ -8,9 +8,13 @@ import {
ContactsIcon,
ComposeIcon,
UsersIcon,
LogoutIcon
LogoutIcon,
ShieldIcon,
CalendarIcon
} from './icons';
import { ConnectionIndicator } from './ConnectionIndicator';
import { ThemeToggleButton } from './ThemePicker';
import { countPendingPermissions, splitByPermission } from '../lib/mailGroups';
const navItems: {
short: string;
@ -20,7 +24,12 @@ const navItems: {
adminOnly?: boolean;
}[] = [
{ short: '收件', title: '收件箱', mode: 'inbox', Icon: InboxIcon },
// 授权紧跟收件箱:它是收件箱的「要动手」那一半,放在联系人之后会让人找不到
{ short: '授权', title: '授权请求', mode: 'permissions', Icon: ShieldIcon },
{ short: '发件', title: '发件箱', mode: 'sent', Icon: SentIcon },
// 日历排在联系人之前:它是「我要安排什么」,与收发信同属日常动作;
// 联系人是「查谁在哪」,用得少
{ short: '日历', title: '日历', mode: 'calendar', Icon: CalendarIcon },
{ short: '联系', title: '联系人', mode: 'contacts', Icon: ContactsIcon },
{ short: '用户', title: '用户管理', mode: 'admin', Icon: UsersIcon, adminOnly: true }
];
@ -32,7 +41,11 @@ export default function Sidebar() {
const startCompose = useUIStore(s => s.startCompose);
const inbox = useMailStore(s => s.inbox);
const unread = inbox.filter(m => m.status === 'unread').length;
// 收件箱的未读数只算普通邮件:权限请求归「授权」那一项,
// 两处都数会让一个待批的 bash 在界面上显示成两件事
const { normal, permissions } = splitByPermission(inbox);
const unread = normal.filter(m => m.status === 'unread').length;
const pendingPerms = countPendingPermissions(permissions);
const contacts = useContactStore(s => s.contacts);
const user = useAuthStore(s => s.user);
@ -40,14 +53,21 @@ export default function Sidebar() {
const isAdmin = user?.role === 'admin';
return (
<div className="w-[60px] h-full shrink-0 flex flex-col items-center py-3 gap-1 bg-slate-900"
<div className="w-[60px] h-full shrink-0 flex flex-col items-center py-3 gap-1 bg-chrome-900"
style={{ paddingBottom: 'calc(0.75rem + env(safe-area-inset-bottom))' }}
>
{navItems
.filter(n => !n.adminOnly || isAdmin)
.map(({ short, title, mode, Icon }) => {
const active = viewMode === mode && !composing;
const badge = mode === 'inbox' ? unread : mode === 'contacts' ? contacts.length : 0;
const badge =
mode === 'inbox'
? unread
: mode === 'permissions'
? pendingPerms
: mode === 'contacts'
? contacts.length
: 0;
return (
<button
key={mode}
@ -55,8 +75,8 @@ export default function Sidebar() {
title={title}
className={`relative w-12 h-12 rounded-lg flex flex-col items-center justify-center gap-0.5 transition-colors ${
active
? 'bg-slate-700 text-white'
: 'text-slate-400 hover:bg-slate-800 hover:text-slate-100'
? 'bg-chrome-700 text-white'
: 'text-chrome-400 hover:bg-chrome-800 hover:text-chrome-100'
}`}
>
<Icon />
@ -64,7 +84,13 @@ export default function Sidebar() {
{badge > 0 && (
<span
className={`absolute top-0.5 right-1 min-w-[15px] h-[15px] px-1 rounded-full text-[9px] font-bold flex items-center justify-center ${
mode === 'inbox' ? 'bg-red-500 text-white' : 'bg-slate-600 text-slate-100'
mode === 'inbox'
? 'bg-red-500 text-white'
: mode === 'permissions'
? // 待决策的授权用橙色:它跟未读不是一类紧急 ——
// 未读是「有内容没看」,待决策是「有 Agent 卡在那儿等我」
'bg-orange-700 text-white'
: 'bg-chrome-600 text-chrome-100'
}`}
>
{badge > 99 ? '99+' : badge}
@ -87,14 +113,14 @@ export default function Sidebar() {
<span className="text-[9px] leading-none"></span>
</button>
<div className="mt-2 pt-2 w-full flex flex-col items-center gap-1 border-t border-slate-700">
<div className="mt-2 pt-2 w-full flex flex-col items-center gap-1 border-t border-chrome-700">
<button
onClick={() => setViewMode('account')}
title={`${user?.display_name || user?.username}(点击管理账号)`}
className={`relative w-9 h-9 rounded-full flex items-center justify-center text-[11px] font-semibold transition-colors ${
viewMode === 'account' && !composing
? 'bg-blue-500 text-white'
: 'bg-slate-700 text-slate-200 hover:bg-slate-600'
: 'bg-chrome-700 text-chrome-200 hover:bg-chrome-600'
}`}
>
{(user?.display_name || user?.username || '?').slice(0, 2)}
@ -103,10 +129,11 @@ export default function Sidebar() {
<ConnectionIndicator />
</span>
</button>
<ThemeToggleButton className="w-9 h-7 rounded flex items-center justify-center text-chrome-400 hover:text-white hover:bg-chrome-800" />
<button
onClick={logout}
title="退出登录"
className="w-9 h-7 rounded flex items-center justify-center text-slate-400 hover:text-white hover:bg-slate-800"
className="w-9 h-7 rounded flex items-center justify-center text-chrome-400 hover:text-white hover:bg-chrome-800"
>
<LogoutIcon className="w-3.5 h-3.5" />
</button>

View File

@ -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 (
<button
onClick={toggle}
title={
pref === 'system'
? `跟随系统(当前${dark ? '深色' : '浅色'})—— 点击固定为${dark ? '浅色' : '深色'}`
: `当前${dark ? '深色' : '浅色'} —— 点击切换`
}
aria-label="切换主题"
className={className}
>
{dark ? <MoonIcon className="w-3.5 h-3.5" /> : <SunIcon className="w-3.5 h-3.5" />}
</button>
);
}
/** 「我的」页用的三选一。 */
export default function ThemePicker() {
const pref = useThemeStore(s => s.pref);
const resolved = useThemeStore(s => s.resolved);
const setPref = useThemeStore(s => s.setPref);
return (
<div>
<div className="flex items-center gap-2 mb-2">
<h3 className="text-sm font-medium text-gray-900"></h3>
{pref === 'system' && (
<span className="text-xs text-gray-500">
{resolved === 'dark' ? '深色' : '浅色'}
</span>
)}
</div>
<div className="grid grid-cols-3 gap-2">
{OPTIONS.map(o => {
const active = pref === o.value;
return (
<button
key={o.value}
onClick={() => setPref(o.value)}
title={o.hint}
className={`px-3 py-2.5 rounded border text-xs flex flex-col items-center gap-1.5 transition-colors ${
active
? 'border-blue-500 bg-blue-50 text-blue-700'
: 'border-gray-300 bg-white text-gray-700 hover:bg-gray-50'
}`}
>
<o.Icon className="w-4 h-4" />
{o.label}
</button>
);
})}
</div>
</div>
);
}

View File

@ -330,3 +330,84 @@ export function CpuIcon({ className = 'w-4 h-4' }: P) {
</Svg>
);
}
export function CalendarIcon({ className = 'w-4 h-4' }: P) {
return (
<Svg className={className}>
<rect x="3" y="5" width="18" height="16" rx="2" />
<path d="M3 10h18M8 3v4M16 3v4" />
</Svg>
);
}
export function BellIcon({ className = 'w-4 h-4' }: P) {
return (
<Svg className={className}>
<path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9" />
<path d="M13.7 21a2 2 0 0 1-3.4 0" />
</Svg>
);
}
export function RepeatIcon({ className = 'w-4 h-4' }: P) {
return (
<Svg className={className}>
<path d="M17 2l4 4-4 4" />
<path d="M3 11v-1a4 4 0 0 1 4-4h14" />
<path d="M7 22l-4-4 4-4" />
<path d="M21 13v1a4 4 0 0 1-4 4H3" />
</Svg>
);
}
export function UploadIcon({ className = 'w-4 h-4' }: P) {
return (
<Svg className={className}>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<path d="M17 8l-5-5-5 5M12 3v12" />
</Svg>
);
}
export function PauseIcon({ className = 'w-4 h-4' }: P) {
return (
<Svg className={className}>
<rect x="6" y="4" width="4" height="16" />
<rect x="14" y="4" width="4" height="16" />
</Svg>
);
}
export function PlayIcon({ className = 'w-4 h-4' }: P) {
return (
<Svg className={className}>
<path d="M5 3l14 9-14 9V3z" />
</Svg>
);
}
export function SunIcon({ className = 'w-4 h-4' }: P) {
return (
<Svg className={className}>
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
</Svg>
);
}
export function MoonIcon({ className = 'w-4 h-4' }: P) {
return (
<Svg className={className}>
<path d="M21 12.8A8.5 8.5 0 1 1 11.2 3a6.6 6.6 0 0 0 9.8 9.8z" />
</Svg>
);
}
export function MonitorIcon({ className = 'w-4 h-4' }: P) {
return (
<Svg className={className}>
<rect x="2" y="4" width="20" height="13" rx="2" />
<path d="M8 21h8M12 17v4" />
</Svg>
);
}

View File

@ -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));
}
}

View File

@ -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(
<React.StrictMode>
<App />

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);
}

View File

@ -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) / <alpha-value>)` 而不是直接存颜色串
*
* 代码里有 `bg-blue-50/70`、`bg-gray-50/60` 这样的透明度修饰符。
* 变量若存 `#f9fafb`Tailwind 生成的 `rgb(#f9fafb / 0.7)` 是无效 CSS
* 那些半透明高亮会静默失效(不报错,只是不透明)。存 RGB 三元组才行。
*/
const withAlpha = (v) => `rgb(var(${v}) / <alpha-value>)`;
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: []
};

View File

@ -0,0 +1,151 @@
/**
* 深色主题手工验收。
*
* 需要共享 ChromiumCDP 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);

View File

@ -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('<BackButton'));
// 容器 795px「退出登录」按钮连同下面 65px 一起消失。
// 判据是「存在 overflow-y-auto」不是「当前正在滚动」——
// 内容暂时不够高时后者为假,但页面是健康的。
for (const f of ['AccountPage', 'AdminUsersPage', 'MailView', 'ComposePage', 'ThreadView', 'ContactPanel', 'MailList']) {
for (const f of ['AccountPage', 'AdminUsersPage', 'MailView', 'ComposePage', 'ThreadView', 'ContactPanel', 'MailList', 'PermissionList', 'CalendarView', 'CalendarEventEditor']) {
const src = read(`../src/components/${f}.tsx`);
check(`${f} 有纵向滚动容器`, src.includes('overflow-y-auto'));
}

226
web/test/theme.test.mjs Normal file
View File

@ -0,0 +1,226 @@
/**
* 深色主题的结构性检查。
*
* 判据全部是「源码里存在/不存在某种形态」,不需要浏览器 ——
* 真正的视觉验收靠手工脚本test/manual/theme-verify.mjs
*
* 这些检查存在的理由:深色模式的 bug 形态是**白底白字**
* 它不报错、不影响构建、只有肉眼能发现,而且往往只出现在某个不常开的页面。
*/
import { readFileSync, readdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const here = dirname(fileURLToPath(import.meta.url));
const read = p => 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('<alpha-value>'),
'缺少 rgb(var(--x) / <alpha-value>) 形态'
);
// 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')") &&
!/<script[^>]+(src=|defer)[^>]*>[\s\S]*?agentmail\.theme/.test(html),
'缺少同步内联脚本'
);
// 10) 内联脚本与 themeStore 必须用同一个 localStorage 键。
// 不一致的后果是首帧按 A 键渲染、React 挂载后按 B 键重渲染 —— 闪一下再变回去
const store = read('../src/stores/themeStore.ts');
const keyInStore = store.match(/STORAGE_KEY\s*=\s*'([^']+)'/);
check(
'内联脚本与 themeStore 共用同一 storage 键',
keyInStore !== null && html.includes(`'${keyInStore[1]}'`),
keyInStore ? `store 用 ${keyInStore[1]}` : '未找到 STORAGE_KEY'
);
// 11) body 必须有显式底色:移动端橡皮筋回弹露出的是 body 背景,
// 不设的话深色下滑到边界会闪出白边。
check(
'body 有显式主题底色',
/body\s*\{[^}]*background-color:\s*rgb\(var\(--c-/.test(css)
);
// 12) 组件里不该残留写死的十六进制颜色。
// 它们不经变量,深色模式下不会变 —— 而这类遗漏只有肉眼能发现。
const compDir = join(here, '../src/components');
const offenders = [];
for (const f of readdirSync(compDir).filter(x => x.endsWith('.tsx'))) {
const src = readFileSync(join(compDir, f), 'utf8');
// 只看 className 与 style 里的颜色SVG 的 currentColor 不算
const hits = (src.match(/#[0-9a-fA-F]{3,6}\b/g) || []);
if (hits.length) offenders.push(`${f}(${hits.join(',')})`);
}
check(
'组件里没有写死的十六进制颜色',
offenders.length === 0,
offenders.join(' ')
);
// 13) 主题三态system 不能被当成 light 的别名。
// 只给开关的话,白天设浅色之后晚上系统切深色应用不会跟着变
check(
'ThemePref 是三态且含 system',
/'light'\s*\|\s*'dark'\s*\|\s*'system'/.test(store) && store.includes("=== 'system'")
);
// 14) 只有 pref 为 system 时才跟随系统变化。
// 显式选了 light/dark 的人不该因为日落被切换主题
check(
'仅 system 偏好跟随系统变化',
/if\s*\(pref === 'system'\)/.test(store)
);
// 15) text-white 必须与 bg-white 用不同的变量。
// `white` 服务两种冲突用途:卡片表面(深色下变暗)与彩色按钮上的文字
// (深色下必须保持浅色)。共用一个变量时后者跟着变暗 —— 实测激活导航项的
// 「收件」在 bg-chrome-700 上只剩 1.34:1几乎看不见。
check(
'textColor.white 指向独立变量(不跟 bg-white 一起反转)',
/textColor:\s*\{[^}]*white:\s*withAlpha\('--c-on-accent'\)/.test(cfg) &&
/--c-on-accent:/.test(lightBlock) &&
/--c-on-accent:/.test(darkBlock)
);
// 16) --c-on-accent 在深色下必须仍然是浅色。
// 它变暗就是上一条描述的那个 bug。
const onAccentDark = lum(darkBlock, 'on-accent');
check(
'深色下 on-accent 仍是浅色',
onAccentDark !== null && onAccentDark > 200,
`on-accent 亮度 ${onAccentDark}`
);
// 17) 强调色blue/red/...)不走变量。
// 它们跟着反转会让主按钮在深色页面上失去「这是主操作」的视觉重量,
// 而且白字落在变暗的 blue-600 上对比度会掉到 3:1 以下。
check(
'强调色为固定值,不随主题反转',
!/blue:\s*accent\(/.test(cfg) && /blue:\s*\{\s*50:\s*'#/.test(cfg)
);
// 18) 应用框架(侧栏 / 底部导航)必须有独立色阶。
// 它在浅色模式下本来就是深色的 —— 并入反转的 gray 之后深色模式下会变成
// 近白色,比内容区还亮,整个层次翻过来(实测 rgb(243,245,248))。
check(
'框架有独立的 chrome 色阶',
/chrome:\s*\{/.test(cfg) &&
/--c-chrome-900:/.test(lightBlock) &&
/--c-chrome-900:/.test(darkBlock)
);
// 19) 深色下框架必须比内容区更沉(保持浅色下就有的层次关系)。
const chromeDark = lum(darkBlock, 'chrome-900');
check(
'深色下框架比内容区更暗',
chromeDark !== null && darkG50 !== null && chromeDark < darkG50,
`chrome-900=${chromeDark} gray-50=${darkG50}`
);
// 20) 侧栏与底部导航里不该残留 slate-*(那条色阶指向反转的 gray
const chromeFiles = ['Sidebar', 'NarrowNav'];
const slateLeft = [];
for (const f of chromeFiles) {
const src = readFileSync(join(here, `../src/components/${f}.tsx`), 'utf8');
const hits = src.match(/(?:bg|text|border|hover:bg|hover:text|active:bg|ring)-slate-\d+/g) || [];
if (hits.length) slateLeft.push(`${f}: ${hits.join(',')}`);
}
check('框架组件已全部改用 chrome 色阶', slateLeft.length === 0, slateLeft.join(' | '));
console.log(`\n主题:${pass} 通过${fail ? `${fail} 失败` : ''}`);
process.exit(fail ? 1 : 0);