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

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