Files
MailUI4Agents/web/test/manual
JianFeeeee d74f356f41 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 项全过。
2026-09-04 06:30:26 +08:00
..

手工浏览器实测脚本

不进 npm test —— 它们需要一个跑着的 Chromium 与一个活的 Gateway。 日常回归靠 ../narrow-layout.test.mjs(读源码验形态,无外部依赖)。

为什么两套都要

结构性断言守住「代码写成了什么形态」,量不出「按钮实际多大、点下去命中谁」。

窄屏那轮修复里最严重的一个 bug 是抽屉式侧栏(fixed ... z-50 铺满视口高度) 把底部导航最左那一项盖住 —— 按钮在那里、尺寸也够、md:hidden 之类的规则也 没写错,只有 elementFromPoint 才能发现它命中的是抽屉里的 SVG

用法

# 窄屏390pxiPhone 14 Pro+ 320pxiPhone SE
ADMIN_PW=<密码> npm run test:narrow

# 宽屏回归:窄屏修复不能把桌面改坏
ADMIN_PW=<密码> npm run test:wide

环境变量:

变量 默认 说明
ADMIN_PW 无(必填) 管理员密码
ADMIN_USER admin 登录用户名
AGENTMAIL_URL https://mail.jianfgit.xyz 目标地址
CDP_URL http://127.0.0.1:9222 浏览器 CDP 端点
PLAYWRIGHT /usr/lib/node_modules/playwright/index.mjs playwright 入口

浏览器用的是本机 systemd 托管的共享 Chromiumhomeagent-browser.service 通过 CDP 连上去开自己的标签页,用完关掉。没有它时先 systemctl start homeagent-browser

文件

文件 作用
narrow-probe-helper.mjs 连浏览器、登录、量盒子/溢出/命中区/命中测试
narrow-verify.mjs 窄屏 13 项验收
wide-regression.mjs 宽屏 5 项回归

narrow-probe-helper.mjs 里两个函数值得单独知道:

  • tapTargets(page, labels) —— 量 .tap 按钮的真实命中区(::after 伪元素的尺寸)。.tap 刻意不改变视觉尺寸,所以只看 boundingBox 会误判成偏小
  • hitTest(page, selector) —— 每个元素点下去是否命中自己。遮挡类 bug 只能这样查

已知限制

headless Chromium 报告 hover: none,因此 .reveal(只在支持悬停的设备上隐藏) 在这里永远是可见的 —— 脚本只能验「触摸设备上可见」这一半, 「鼠标设备上隐藏」那一半靠 ../narrow-layout.test.mjs 检查 CSS 规则存在。

没有像素级视觉比对:字体差异下极脆,维护成本高于收益。