**逐处加 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 项全过。
199 lines
6.9 KiB
CSS
199 lines
6.9 KiB
CSS
@tailwind base;
|
||
@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%;
|
||
}
|
||
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));
|
||
}
|
||
}
|
||
|
||
@layer utilities {
|
||
/*
|
||
* .tap —— 保证 44x44 的触摸命中区,但不改变视觉尺寸。
|
||
*
|
||
* 移动端 44x44 是通行下限(Apple HIG 与 Material 都取这个数),而这些图标/
|
||
* 小字按钮视觉上只有 15-24px 高 —— 实测「标记已读」48x16、「转发」42x16、
|
||
* 「抄送」20x15。直接加 padding 会把本来就挤的头部撑散,在 320px 屏上还会换行。
|
||
*
|
||
* 改用居中的透明伪元素扩大命中区:视觉一像素不动,手指够得到。
|
||
*
|
||
* 只在窄屏生效:桌面用鼠标,精度足够;而扩大后的命中区在密排的工具栏里
|
||
* 会互相重叠,点一个可能命中隔壁那个。
|
||
*/
|
||
@media (max-width: 767px) {
|
||
.tap {
|
||
position: relative;
|
||
}
|
||
.tap::after {
|
||
content: '';
|
||
position: absolute;
|
||
top: 50%;
|
||
left: 50%;
|
||
width: 100%;
|
||
height: 100%;
|
||
min-width: 44px;
|
||
min-height: 44px;
|
||
transform: translate(-50%, -50%);
|
||
}
|
||
}
|
||
|
||
/*
|
||
* .reveal —— 悬停才显形的次要动作(写信 / 归档)。
|
||
*
|
||
* 原先直接写 `opacity-0 group-hover:opacity-100`。**触摸设备没有 hover**,
|
||
* 于是这些按钮永远是透明的,却仍然接收点击 —— 实测在联系人列表上
|
||
* elementFromPoint 命中的就是那个看不见的「归档」。一个看不见却按得动的
|
||
* 破坏性按钮比没有按钮更糟:人以为自己点的是卡片,实际归档了一条会话。
|
||
*
|
||
* 因此默认可见,只在**真的支持悬停**的设备上才隐藏。判据用
|
||
* `(hover: hover) and (pointer: fine)`:单看 hover 会把带触摸板的平板算进去。
|
||
*/
|
||
.reveal {
|
||
opacity: 1;
|
||
}
|
||
@media (hover: hover) and (pointer: fine) {
|
||
.reveal {
|
||
opacity: 0;
|
||
transition: opacity 150ms;
|
||
}
|
||
.group:hover .reveal,
|
||
.reveal:focus-within {
|
||
opacity: 1;
|
||
}
|
||
}
|
||
}
|