Files
MailUI4Agents/web/tailwind.config.js
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

185 lines
8.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.

/** @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')
};
/**
* 强调色blue / red / green / amber / orange / yellow
*
* 这条色阶在这套代码里也是**语义色阶**,与灰阶同理:
* - `50` `300` = 表面chip 底、提示条底、徽标底、边框)
* - `400` `900` = 前景(文字、图标、实心按钮底)
*
* 深色模式下两段的走向**相反**:表面段要变暗(照搬浅色的近白值会在深色页面上
* 糊出一块刺眼亮斑),前景段要变亮(照搬浅色的 red-700 落在深色卡片上只有
* 2.67:1读不动。所以它必须走变量不能写死 —— 见 index.css 的两组定义。
*/
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`)
});
/**
* 实心按钮/徽标的底色 —— `--s-*`,两种模式下**同值**。
*
* 为什么不能跟 accent 的前景段走:那一段在深色下被提亮成了浅色
* red-600 → rgb(246,141,141)),而 `bg-red-600 text-white` 的白字落上去
* 只有 1.6:1。实心按钮的底色本来就该保持饱和 —— 深色模式下变的是页面,
* 不是「危险操作按钮是红的」这件事。
*
* 只覆盖 backgroundColor`text-red-600` / `border-red-600` 仍走 accent()。
* Tailwind 的 backgroundColor 默认继承 colors这里逐档覆写 400900
* 50300 是表面段,仍从 colors 继承 —— 它们在深色下就该变暗)。
*/
const solid = (name) => ({
400: withAlpha(`--s-${name}-400`),
500: withAlpha(`--s-${name}-500`),
600: withAlpha(`--s-${name}-600`),
700: withAlpha(`--s-${name}-700`),
800: withAlpha(`--s-${name}-800`),
900: withAlpha(`--s-${name}-900`)
});
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
// class 而不是 media主题要能被人显式选择。跟系统走是**默认值**
// 不是唯一选项 —— 白天开深色主题是常见偏好。
darkMode: 'class',
theme: {
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')
},
/**
* backgroundColor 单独覆盖强调色的 400900 档,让实心按钮底保持饱和。
*
* 每个强调色的 400900 在这套代码里承担**两个冲突用途**
* - `text-red-600` / `border-red-300` = 深色下必须**提亮**才读得动
* - `bg-red-600` + `text-white` = 深色下必须**保持饱和**
*
* 共用一档时后者必坏:深色下 red-600 被提亮到 rgb(246,141,141)
* 白字落上去只有 1.6:1 —— 与 text-white/bg-white 那次是同一类错误
* (一个名字服务两种语义)。
*
* 50300 不覆盖:那是表面段,深色下就该跟着变暗。
*/
backgroundColor: {
blue: solid('blue'),
red: solid('red'),
green: solid('green'),
amber: solid('amber'),
orange: solid('orange'),
yellow: solid('yellow')
},
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')
},
/*
* 强调色。表面段50300在深色下变暗、前景段400900变亮
* 两段走向相反 —— 详见 accent() 的注释与 index.css 的两组定义。
*
* 实心按钮底另走 --s-*(见上面的 backgroundColor
*
* 注意这里**只能有一份定义**JS 对象字面量的重复键后者胜出,
* 而那不会报错。上一版同时写了固定 hex 与 accent() 两份,
* accent() 覆盖了 hex 那份,但 index.css 里当时没有对应的 --c-red-* 等
* 变量 —— `rgb(var(--c-red-600) / 1)` 里的变量未定义使整条声明失效,
* 于是 bg-red-600 退回透明,白字落在白卡片上:按钮看不见但点得动。
*/
blue: accent('blue'),
red: accent('red'),
green: accent('green'),
amber: accent('amber'),
orange: accent('orange'),
yellow: accent('yellow')
}
}
},
plugins: []
};