Files
MailUI4Agents/web/test/manual/accent-verify.mjs
JianFeeeee 390fef8941 fix(web): 补齐强调色的 CSS 变量 —— 红/绿/橙/黄按钮此前不可见但可点
## 症状

所有界面的「确认」类按钮看不见,但对应位置点击照样生效。
归档确认、删除、危险操作、状态徽标全部受影响;蓝色主按钮正常。

## 根因

`tailwind.config.js` 的 `colors` 里对 red/green/amber/orange/yellow
**同时写了两份定义**:先是固定 hex,紧接着又是 `accent('red')`。
JS 对象字面量重复键**后者胜出**,不报错、不警告 —— 读代码的人看到上面那份
hex 以为在用它,实际生效的是下面那份变量引用。

而 `index.css` 里当时只有 20 个变量(white / on-accent / gray / chrome),
没有任何 `--c-red-*`。CSS 里变量未定义会让**整条声明失效**:

    .bg-red-600 { background-color: rgb(var(--c-red-600) / 1) }   ← 整条被丢弃

于是 `bg-red-600` 退回透明,而 `text-white`(走 `--c-on-accent`,浅色下是纯白)
照常生效 → 白字落在白卡片上。按钮的盒子、padding、点击区域全都在。

实测部署产物里 32 个变量被引用但从未定义。blue 逃过一劫只因为它没有第二份
`accent('blue')` 定义,编译成了固定值。

## 修法(用户选 B:补齐变量,让强调色也参与主题)

`index.css` 新增 96 个变量,`tailwind.config.js` 去掉重复定义。

**强调色是两段语义色阶,深色下走向相反**:
- `50`–`300` = 表面(chip 底、提示条底、边框)→ 深色下**变暗**。
  照搬浅色值的话 red-50 (#fef2f2) 在深色页面上是一块近白亮斑 ——
  那是错误提示条的底,结果比正文还抢眼,上面的红字反而读不动。
- `400`–`900` = 前景(文字、图标)→ 深色下**变亮**。
  照搬时 red-700 只有 2.67:1、amber-900 只有 1.90:1。现在每档 ≥4.5
  (最低 red-400 = 5.93)。

**实心按钮底另立一组 `--s-*`,两种模式同值。**
那六档在深色下被提亮是为了 `text-red-600` 读得动,而 `bg-red-600 text-white`
的白字落在提亮后的浅红上只有 1.6:1。一个名字服务两种语义必然坏掉一头 ——
与此前 text-white/bg-white 那次同理。只覆盖 `backgroundColor`,
`text-*`/`border-*`/`ring-*` 仍走 `accent()`。

顺带把浅色 red-600 从官方的 220 38 38 压到 213 37 37:官方值落在 red-50 上
只有 4.41:1,而 `bg-red-50 text-red-600` 正是错误提示条。

## 防复发

`test/theme.test.mjs` 20 → 26 条,新增 6 条针对这次的:
- **Tailwind 实际使用的每个变量都在 index.css 有定义**。判据走 resolveConfig
  而不是正则扫配置文本:出问题的变量名是 `accent('red')` 模板拼出来的,
  源码里没有 `--c-red-600` 这个字面量,扫文本会漏掉正是要防的那一类
- colors 里没有重复的颜色名(这次 bug 的成因)
- 表面段深色下变暗 / 前景段在深色卡片上 ≥4.5:1(逐档断言,72 项)
- 实心底走 `--s-*` 且未被 `.dark` 覆盖
- 白字在实心底上 ≥3:1

新增 `test/manual/accent-verify.mjs`:真浏览器渲染 17 组配色 × 两模式,
读 `getComputedStyle` 量实际值,**把「背景透明」单独判为失败**(那正是本次
bug 的指纹)。只以 `hover:` 变体出现的档不能放进探针 —— Tailwind 不生成
未使用的基础类,探它必然透明,是假阳性。

## 验收

- theme.test.mjs 26 条全过;web 160 例;tsc 无错
- accent-verify 两模式各 17 项全过(浅色最低 4.51、深色最低 3.05)
- theme-verify 9 项全过;wide-regression 5 项全过
- 截图逐像素核对:浅色侧栏 (15,23,42) / 卡片 (255,255,255) / 页面底 (249,250,251);
  深色 (12,14,18) / (24,27,33) / (17,19,24) —— 层次关系两模式一致
2026-09-04 11:57:42 +08:00

85 lines
4.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 强调色可见性验收:真实渲染 + 取实际计算色 + 算 WCAG 对比度。
*
* 结构性断言test/theme.test.mjs只能保证变量存在、档位达标
* 「按钮到底看得见吗」必须在真浏览器里量 —— 上一次那个 bug 正是
* 所有静态检查都过、只有肉眼能发现。
*/
import { openApp, WIDE } from './narrow-probe-helper.mjs';
const srgb = c => { c /= 255; return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; };
const L = ([r, g, b]) => 0.2126 * srgb(r) + 0.7152 * srgb(g) + 0.0722 * srgb(b);
const ratio = (a, b) => { const l1 = L(a), l2 = L(b); const [h, o] = l1 > l2 ? [l1, l2] : [l2, l1]; return (h + 0.05) / (o + 0.05); };
const parse = s => (s.match(/\d+/g) || []).slice(0, 3).map(Number);
// 把一批强调色类名注入页面,量它们的实际计算值
// hover 档bg-red-700 / bg-blue-700不在这里源码里它们只以 `hover:` 变体
// 出现Tailwind 因此不生成基础类 —— 探它会得到透明背景,那是探针的假阳性
// 而不是真 bug。它们由 theme.test.mjs 的档位断言覆盖(--s-* 两模式同值)。
const PROBES = [
// [类名组合, 说明, 期望最低对比度]
['bg-red-600 text-white', '危险实心按钮(确认归档 / 删除)', 3.0],
['bg-red-500 text-white', '未读徽标', 3.0],
['bg-green-600 text-white', '同意按钮', 3.0],
['bg-orange-700 text-white', '待决策徽标', 4.5],
['bg-blue-600 text-white', '主按钮', 4.5],
['bg-red-50 text-red-600', '错误提示条', 4.5],
['bg-red-50 text-red-700', '危险区块文字', 4.5],
['bg-green-50 text-green-700', '成功提示条', 4.5],
['bg-orange-100 text-orange-700', '橙色 chip', 4.5],
['bg-amber-100 text-amber-700', '警告 chip', 4.5],
['bg-amber-50 text-amber-800', '警告条', 4.5],
['bg-yellow-100 text-yellow-700', '黄色 chip', 4.5],
['bg-blue-50 text-blue-700', '信息条', 4.5],
['bg-red-100 text-red-700', '红色 chip', 4.5],
['bg-orange-200 text-orange-800', '深橙 chip', 4.5],
['bg-white text-gray-900', '卡片正文', 4.5],
['bg-white text-gray-500', '卡片次要文字', 4.5],
];
const { browser, page } = await openApp(WIDE);
let fail = 0;
for (const mode of ['light', 'dark']) {
await page.evaluate(m => {
document.documentElement.classList.toggle('dark', m === 'dark');
let host = document.getElementById('__probe');
if (host) host.remove();
host = document.createElement('div');
host.id = '__probe';
host.style.position = 'fixed';
host.style.top = '0';
host.style.left = '0';
host.style.zIndex = '99999';
document.body.appendChild(host);
}, mode);
console.log(`\n─── ${mode === 'dark' ? '深色' : '浅色'} ───`);
for (const [cls, label, min] of PROBES) {
const got = await page.evaluate(c => {
const host = document.getElementById('__probe');
host.innerHTML = `<span id="__p" class="${c}">测试</span>`;
const el = document.getElementById('__p');
const s = getComputedStyle(el);
return { bg: s.backgroundColor, fg: s.color };
}, cls);
// 透明背景 = 声明失效(正是上次那个 bug 的指纹)
const transparent = /rgba\(0,\s*0,\s*0,\s*0\)|transparent/.test(got.bg);
if (transparent) {
console.log(` 失败 ${label} — 背景透明(${cls} 的 background-color 声明失效)`);
fail++;
continue;
}
const r = ratio(parse(got.fg), parse(got.bg));
const ok = r >= min;
if (!ok) fail++;
console.log(` ${ok ? '通过' : '失败'} ${label.padEnd(22)} ${r.toFixed(2)}:1 (需 ${min}) ${got.bg} / ${got.fg}`);
}
}
await page.evaluate(() => document.getElementById('__probe')?.remove());
await browser.close();
console.log(fail ? `\n!! ${fail} 项不达标` : '\n全部达标');
process.exit(fail ? 1 : 0);