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) —— 层次关系两模式一致
This commit is contained in:
@ -48,7 +48,7 @@ const varLines = css.match(/--c-[a-z]+-?\d*:\s*[^;]+;/g) || [];
|
||||
const hexVars = varLines.filter(l => l.includes('#'));
|
||||
check(
|
||||
'色板变量存 RGB 三元组而非 #hex',
|
||||
varLines.length >= 30 && hexVars.length === 0,
|
||||
varLines.length >= 100 && hexVars.length === 0,
|
||||
hexVars.length ? `${hexVars.length} 个变量是 hex:${hexVars[0]}` : `只找到 ${varLines.length} 个变量`
|
||||
);
|
||||
|
||||
@ -186,15 +186,153 @@ check(
|
||||
`on-accent 亮度 ${onAccentDark}`
|
||||
);
|
||||
|
||||
// 17) 强调色(blue/red/...)不走变量。
|
||||
// 它们跟着反转会让主按钮在深色页面上失去「这是主操作」的视觉重量,
|
||||
// 而且白字落在变暗的 blue-600 上对比度会掉到 3:1 以下。
|
||||
// 17) 每个被 Tailwind 实际使用的 CSS 变量都必须在 index.css 有定义。
|
||||
//
|
||||
// 这一条守的是本项目最贵的一次视觉 bug:tailwind.config.js 里 `colors`
|
||||
// 同时写了固定 hex 与 accent() 两份 red/green/amber/orange/yellow ——
|
||||
// JS 对象字面量重复键**后者胜出**(不报错、不警告),而 index.css 里当时
|
||||
// 没有对应的变量。`rgb(var(--c-red-600) / 1)` 里变量未定义会让整条
|
||||
// background-color 声明失效,于是 bg-red-600 退回透明、text-white 的白字
|
||||
// 落在白卡片上:**按钮看不见但点得动**。32 个变量全部缺失,
|
||||
// 波及所有 red/green/amber/orange/yellow 的地方。
|
||||
//
|
||||
// 判据必须走 resolveConfig 而不是正则扫配置文本:真正出问题的那批变量名
|
||||
// 是 accent('red') 这类**模板拼出来的**,配置源码里根本没有 `--c-red-600`
|
||||
// 这个字面量 —— 扫文本会漏掉正是要防的那一类。
|
||||
const resolveConfig = (await import('tailwindcss/resolveConfig.js')).default;
|
||||
const resolved = resolveConfig((await import('../tailwind.config.js')).default);
|
||||
|
||||
const declared = new Set([...css.matchAll(/--[cs]-[a-z0-9-]+(?=\s*:)/g)].map(m => m[0]));
|
||||
const usedVars = new Set();
|
||||
const walk = (v) => {
|
||||
if (typeof v === 'string') {
|
||||
for (const m of v.matchAll(/var\((--[a-z0-9-]+)/g)) usedVars.add(m[1]);
|
||||
} else if (v && typeof v === 'object') {
|
||||
for (const k of Object.keys(v)) walk(v[k]);
|
||||
}
|
||||
};
|
||||
// 只看颜色相关的 theme 段:其余(spacing/fontSize/...)不走变量
|
||||
for (const key of ['colors', 'textColor', 'backgroundColor', 'borderColor', 'ringColor', 'divideColor']) {
|
||||
walk(resolved.theme[key]);
|
||||
}
|
||||
const undef = [...usedVars].filter(v => !declared.has(v));
|
||||
check(
|
||||
'强调色为固定值,不随主题反转',
|
||||
!/blue:\s*accent\(/.test(cfg) && /blue:\s*\{\s*50:\s*'#/.test(cfg)
|
||||
'Tailwind 实际使用的每个变量都在 index.css 有定义',
|
||||
usedVars.size >= 100 && undef.length === 0,
|
||||
undef.length
|
||||
? `${undef.length} 个未定义:${undef.slice(0, 6).join(', ')}`
|
||||
: `只解析出 ${usedVars.size} 个变量引用`
|
||||
);
|
||||
|
||||
// 18) 应用框架(侧栏 / 底部导航)必须有独立色阶。
|
||||
// 18) colors 里每个颜色名只能定义一次。
|
||||
// 重复键静默生效,是上一条那个 bug 的**成因**:读代码的人看到固定 hex
|
||||
// 以为在用它,实际生效的是下面那份 accent()。
|
||||
const colorsBlock = cfg.slice(cfg.indexOf('colors: {'));
|
||||
const dupNames = [];
|
||||
for (const name of ['blue', 'red', 'green', 'amber', 'orange', 'yellow', 'gray', 'chrome']) {
|
||||
const n = (colorsBlock.match(new RegExp(`^\\s{8}${name}:`, 'gm')) || []).length;
|
||||
if (n > 1) dupNames.push(`${name}×${n}`);
|
||||
}
|
||||
check('colors 里没有重复的颜色名', dupNames.length === 0, dupNames.join(' '));
|
||||
|
||||
// 19) 强调色的**表面段**(50–300)深色下必须变暗。
|
||||
// 照搬浅色值的话 red-50 (#fef2f2) 在深色页面上是一块近白亮斑 ——
|
||||
// 那是错误提示条的底,结果比正文还抢眼,上面的红字反而读不动。
|
||||
const surfaceOffenders = [];
|
||||
for (const name of ['blue', 'red', 'green', 'amber', 'orange', 'yellow']) {
|
||||
for (const shade of [50, 100, 200, 300]) {
|
||||
const l = lum(lightBlock, `${name}-${shade}`);
|
||||
const d = lum(darkBlock, `${name}-${shade}`);
|
||||
if (l === null || d === null) { surfaceOffenders.push(`${name}-${shade}:缺失`); continue; }
|
||||
if (d >= l) surfaceOffenders.push(`${name}-${shade}(${d}≥${l})`);
|
||||
}
|
||||
}
|
||||
check(
|
||||
'强调色表面段在深色下变暗',
|
||||
surfaceOffenders.length === 0,
|
||||
surfaceOffenders.slice(0, 6).join(' ')
|
||||
);
|
||||
|
||||
// 20) 强调色的**前景段**(400–900)在深色卡片上必须达到 WCAG AA 4.5:1。
|
||||
// 照搬浅色值时 red-700 只有 2.67:1、amber-900 只有 1.90:1 ——
|
||||
// 「看得见但读不动」跟看不见是同一类 bug。
|
||||
const rgbOf = (block, name) => {
|
||||
const m = block.match(new RegExp(`--c-${name}:\\s*(\\d+)\\s+(\\d+)\\s+(\\d+)`));
|
||||
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
|
||||
};
|
||||
const srgb = c => { c /= 255; return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; };
|
||||
const relLum = ([r, g, b]) => 0.2126 * srgb(r) + 0.7152 * srgb(g) + 0.0722 * srgb(b);
|
||||
const contrast = (a, b) => {
|
||||
const l1 = relLum(a), l2 = relLum(b);
|
||||
const [hi, lo] = l1 > l2 ? [l1, l2] : [l2, l1];
|
||||
return (hi + 0.05) / (lo + 0.05);
|
||||
};
|
||||
const darkCard = rgbOf(darkBlock, 'white');
|
||||
const lowContrast = [];
|
||||
for (const name of ['blue', 'red', 'green', 'amber', 'orange', 'yellow']) {
|
||||
for (const shade of [400, 500, 600, 700, 800, 900]) {
|
||||
const fg = rgbOf(darkBlock, `${name}-${shade}`);
|
||||
if (!fg) { lowContrast.push(`${name}-${shade}:缺失`); continue; }
|
||||
const r = contrast(fg, darkCard);
|
||||
if (r < 4.5) lowContrast.push(`${name}-${shade}:${r.toFixed(2)}`);
|
||||
}
|
||||
}
|
||||
check(
|
||||
'强调色前景段在深色卡片上达到 4.5:1',
|
||||
darkCard !== null && lowContrast.length === 0,
|
||||
lowContrast.slice(0, 6).join(' ')
|
||||
);
|
||||
|
||||
// 21) 实心按钮底走独立的 --s-* 且**两种模式同值**。
|
||||
//
|
||||
// accent 的 400–900 在深色下被提亮(为了 text-red-600 读得动),
|
||||
// 而 `bg-red-600 text-white` 的白字落在那个浅红上只有 1.6:1。
|
||||
// 一个名字服务两种语义必然坏掉一头 —— 与 text-white/bg-white 那次同理。
|
||||
check(
|
||||
'实心按钮底走 --s-* 并只覆盖 backgroundColor',
|
||||
/const solid = \(name\) =>/.test(cfg) &&
|
||||
/backgroundColor:\s*\{[^}]*red:\s*solid\('red'\)/s.test(cfg) &&
|
||||
/--s-red-600:/.test(css)
|
||||
);
|
||||
|
||||
// 22) --s-* 不能出现在 .dark 里:它必须两种模式同值。
|
||||
// 在 .dark 覆盖等于把「危险操作是红的」这件事也一起反转了。
|
||||
check(
|
||||
'--s-* 未被 .dark 覆盖(实心底不随主题变)',
|
||||
!/--s-[a-z]+-\d+:/.test(darkBlock)
|
||||
);
|
||||
|
||||
// 23) 白字落在实心按钮底上必须达到 4.5:1(两种模式同一组值,只需算一次)。
|
||||
const solidBlock = css.slice(css.indexOf(':root'), css.indexOf('.dark'));
|
||||
const sRgb = name => {
|
||||
const m = solidBlock.match(new RegExp(`--s-${name}:\\s*(\\d+)\\s+(\\d+)\\s+(\\d+)`));
|
||||
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
|
||||
};
|
||||
// 代码里真正出现过的「实心底 + 白字」组合
|
||||
const solidPairs = [
|
||||
['blue-600'], ['blue-700'], ['blue-500'],
|
||||
['red-500'], ['red-600'], ['red-700'],
|
||||
['green-600'], ['green-700'], ['orange-700']
|
||||
];
|
||||
const onAccentLight = rgbOf(lightBlock, 'on-accent') ||
|
||||
(lightBlock.match(/--c-on-accent:\s*(\d+)\s+(\d+)\s+(\d+)/) || []).slice(1).map(Number);
|
||||
const weakButtons = [];
|
||||
for (const [name] of solidPairs) {
|
||||
const bg = sRgb(name);
|
||||
if (!bg) { weakButtons.push(`${name}:缺失`); continue; }
|
||||
// 3:1 是 WCAG 对大号/粗体文字的下限。这些按钮文字是 11–14px 的 font-medium,
|
||||
// 严格说该要 4.5 —— 但 Tailwind 官方 600 档普遍在 3–4.5 之间(green-600 = 3.05),
|
||||
// 收紧到 4.5 就得偏离官方色值。取 3.0 作为门槛并把偏低的记在这里。
|
||||
const r = contrast(onAccentLight, bg);
|
||||
if (r < 3.0) weakButtons.push(`${name}:${r.toFixed(2)}`);
|
||||
}
|
||||
check(
|
||||
'白字在实心按钮底上达到 3:1',
|
||||
weakButtons.length === 0,
|
||||
weakButtons.join(' ')
|
||||
);
|
||||
|
||||
// 24) 应用框架(侧栏 / 底部导航)必须有独立色阶。
|
||||
// 它在浅色模式下本来就是深色的 —— 并入反转的 gray 之后深色模式下会变成
|
||||
// 近白色,比内容区还亮,整个层次翻过来(实测 rgb(243,245,248))。
|
||||
check(
|
||||
@ -204,7 +342,7 @@ check(
|
||||
/--c-chrome-900:/.test(darkBlock)
|
||||
);
|
||||
|
||||
// 19) 深色下框架必须比内容区更沉(保持浅色下就有的层次关系)。
|
||||
// 25) 深色下框架必须比内容区更沉(保持浅色下就有的层次关系)。
|
||||
const chromeDark = lum(darkBlock, 'chrome-900');
|
||||
check(
|
||||
'深色下框架比内容区更暗',
|
||||
@ -212,7 +350,7 @@ check(
|
||||
`chrome-900=${chromeDark} gray-50=${darkG50}`
|
||||
);
|
||||
|
||||
// 20) 侧栏与底部导航里不该残留 slate-*(那条色阶指向反转的 gray)。
|
||||
// 26) 侧栏与底部导航里不该残留 slate-*(那条色阶指向反转的 gray)。
|
||||
const chromeFiles = ['Sidebar', 'NarrowNav'];
|
||||
const slateLeft = [];
|
||||
for (const f of chromeFiles) {
|
||||
|
||||
Reference in New Issue
Block a user