抓到的第一处 bug 不是算错,是印错:

前景 #006ffb   背景 #ffffff
对比度:4.5:1
AA——正文:✗ 未通过(4.5:1 < 4.5:1)

4.5:1 < 4.5:1 是假的。真实的比值是 4.4998880878,判定用的是这个数,所以那一行确实失败;但显示用的是 Math.round(ratio * 100) / 100,把它四舍五入成了 4.5。屏幕自己否定了自己。

这不只是难看。检查器是给设计师看的,他照着屏幕上的数字决定颜色。数字说 4.5、判定说 ✗,他接下来只会相信其中一个——而大概率是相信数字。

同类的问题还藏了两个,全都是「打印值过了线、判定值没过线」:

前景 精确比值 两位小数 判定 屏上会出现
#006ffb 4.4998880878 4.50 ✗ 低于 4.5 4.5:1 < 4.5:1
#0099ff 2.9997886802 3.00 ✗ 低于 3 3.0:1 < 3:1
#003cf8 6.9957893290 7.00 ✗ 低于 7 7:1 < 7:1

三个都踩在同一条线上:取整把比值推过了它正被判定的那条阈值。


1. 公式只有三行,坑在第四行

对比度的计算本身短得惊人:

const lum = ([r, g, b]: [number, number, number]): number => {
	const lin = (c: number): number => {
		const s = c / 255;
		return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
	};
	return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
};
const l1 = lum(fg);
const l2 = lum(bg);
const ratio = (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);

三行。相对亮度、加权、加 0.05 的地板。剩下全部麻烦都在「拿这个数字做什么」——下面四节就是四件各自独立的事:选哪个膝点常数、打印几位小数、阈值表列几行、预览图给不给读屏器。

#767676 在白底上的相对亮度是 0.1811642442,比值 4.5422249596。这个颜色就是 WCAG 文档里那张经典示例,它离 4.5 那条线有 0.042 的余量——不碰线,所以旧代码在它身上一点毛病都不露。要出问题,得专门找贴着线的颜色。


2. 膝点该写 0.04045:一个连续性实测

sRGB 的线性化有一个「膝点」:低于它走线性分支 s / 12.92,高于它走幂分支 ((s + 0.055) / 1.055) ** 2.4。网上流传的两个值是 0.040450.03928

本站原先写的是 0.03928。换个理由把它换成 0.04045,不用争哪个才是规范定的——只看一个可量化的事实:在膝点处,两个分支必须相等,函数才连续。

打探针在膝点正下方测:

膝点 0.04045:linear = 3.130804954e-3   power = 3.130807283e-3   相对差 7.44060e-7
膝点 0.03928:linear = 3.040247678e-3   power = 3.039492486e-3   相对差 2.48398e-4

0.04045 处两个分支相对差 7.44e-70.03928 处是 2.48e-4——差了大约 330 倍。0.04045 是更连续的那个选择。

这个论证不需要知道规范到底定的是哪一个:它只是在说,如果你要在两个流传值里挑一个,挑那个让函数在接缝处更连续的值。理由站得住,且与规范版本无关。


3. 在 8-bit 上这个争议根本不存在

上面的 330 倍差距听起来吓人,但在 #RRGGBB 的世界里它一次都不会发生。

争议窗口是 (0.03928, 0.04045]——只有落在这个区间里的通道值,两个常数才会把它分到不同分支。8-bit 通道取值是 k / 255k 是整数,所以窗口在 k 上的像域是:

0.03928 × 255 = 10.0164
0.04045 × 255 = 10.3148

(10.0164, 10.3148]没有一个整数

所以两个常数对每一个整数通道值都给出同一个分支选择。实测 k = 9、10、11

k =  9 → s = 0.035294  两个常数下取值完全相同
k = 10 → s = 0.039216  两个常数下取值完全相同
k = 11 → s = 0.043137  两个常数下取值完全相同

k=9 在两个膝点之下,都走线性;k=10 也在两个之下;k=11 在两个之上,都走幂分支。中间的缝里没有人。把本站三个颜色工具统一成 0.04045 之后,#767676 的亮度前后一个字都没变——不是「改小误差」,是「在 8-bit 上完全无差别」。

但这个结论有边界,必须写清楚: 一旦离开 8-bit,争议立刻复活。16-bit 通道、color-mix() 的中间值、Lab/OKLCH 往返转换的中间态,k 不再是 1/255 的整数倍,缝里就有值可落了。而且 7.44e-7 是地板不是零——即便选了更连续的那个常数,接缝处仍有相对误差,只是小到了 8-bit 颜色分辨不出来的程度。

这条注释现在就写在 color.ts 里,因为它最容易被人当成「随手改个魔法数字」而不写理由:

/** WCAG relative luminance (WCAG 2.x, sRGB). The knee is 0.04045, the same
	 *  value /color/wcag-contrast/ and color-gamut.ts use, so all three color tools
	 *  linearize identically. Both 0.04045 and 0.03928 circulate as the sRGB knee;
	 *  0.04045 is the better pick because there the two branches agree to 7.4e-7
	 *  relative, while at 0.03928 they jump by 2.5e-4. On 8-bit colors the choice is
	 *  invisible either way: the disputed window (0.03928, 0.04045] holds no multiple
	 *  of 1/255, so both knees classify every integer channel value the same. */

4. 打印位数比判定式更危险

判定用未取整的 ratio,这件事是对的,不能动。所以问题只剩一个:摆在它旁边的那个数字,会不会取整跨线?

固定两位小数的解法不行——4.49994.5001 都印成 4.5,前者要判失败。正确做法是自适应:从两位起,只要打印值跟任何一条阈值处在判定式的同一侧,就继续加位:

// 判定用的是未取整的 ratio,所以旁边印的数字不能取整跨过
// 它正被判定的那条线——否则屏幕会读成 "4.5:1 < 4.5:1"。
// 两位是常态;只有当它落在某条阈值错误的这一侧时,才继续加位。
const thresholds = [4.5, 3, 7];
let r = ratio;
for (let dp = 2; dp <= 6; dp++) {
	const n = Math.round(ratio * 10 ** dp) / 10 ** dp;
	if (!thresholds.some((need) => (ratio >= need) !== (n >= need))) {
		r = n;
		break;
	}
}

thresholds 是三个不同的阈值值,不是五行——4.5 出现了两次(AA 正文与 AAA 大字号),去重后就这三个数。加位的条件是「存在某条阈值,使 ratio 和打印值落在判定式的两侧」,一旦打印值对每条阈值都跟 ratio 同侧,就停。

实测五个贴着线的颜色:

#767676 → 4.54      (两位即够,离线有 0.042)
#006ffb → 4.4999    (四位:两位是 4.50,越线)
#0099ff → 2.9998    (四位:两位是 3.00,越线)
#003cf8 → 6.996     (三位:两位是 7.00,越线)
#777777 → 4.48      (两位即够)

注意 #003cf8 只加到三位就停了——因为打印 6.996 已经在 7 的同一侧。位数不是固定的「安全裕量」,是恰好够用的最小值。上限 6 位是保底:在 6 位下仍跨线,说明比值离阈值不足 1e-6,那种情况打印什么都是误导,宁可保守到 6 位而不是无限加下去。


5. 阈值表要列满五行

WCAG 2.1 的正文字号判定只有两个数(4.5 和 7),容易写成两行。但 3:1 才是日常 UI 的主战场:焦点框、边框、图标、表单轮廓,都归 SC 1.4.11 的「界面组件」,要求 3:1。

两行的表会让设计师以为「对比度是正文字号的事」,把一个 3:1 都不到的焦点框放进来了。五行:

AA——正文                    4.5:1
AA——大字号(≥18.7px 粗体 / 24px)   3:1
AAA——正文                   7:1
AAA——大字号                  4.5:1
界面组件与焦点指示           3:1

大字号的口径:WCAG 的例外是 18pt 或 14pt 粗体。18pt = 24px14pt = 18.667px,取 18.7px 是为了让 CSS 里写得出。

#006ffb 在这个五行表里的结果很有意思:

4.4999:1
✗ AA 正文       4.4999:1 < 4.5:1
✓ AA 大字号     4.4999:1 ≥ 3:1
✗ AAA 正文      4.4999:1 < 7:1
✗ AAA 大字号    4.4999:1 < 4.5:1
✓ UI 组件       4.4999:1 ≥ 3:1

同一个颜色,两行 ✓、三行 ✗。写成两行的表里,这个颜色只会被判一次,而判失败的那一次——设计师会以为它不能用。


6. SVG 预览:标签说谎,以及一张没有名字的图

数字是抽象的,对比度的意义全在「这两个颜色放一起长什么样」。所以工具给了一段活的预览:真前景色配真背景色,上行大字、下行正文。

原来那段 SVG 有两个问题。

第一个是标签说谎。 标签写着 Large 24px bold textfont-size 却是 30

<text ... font-size="30" font-weight="700">Large 24px bold text</text>

标签说 24px,实际渲染 30px。这两行的用途是分别演示 3:1 和 4.5:1 两种情形,所以字号必须真的等于标签——演示用的假数据比没有演示更糟,因为它让读者相信眼睛看过了。改成 font-size="24"

第二个更隐蔽:那张图对读屏器不存在。 SVG 里没有 <title>role 也没设。对键盘和读屏用户来说,这块区域是一个空占位——而他们恰恰是最依赖对比度结论的一群人。

SVG 里没有文本节点可以挂 span 对,那就照搬全站的颜色预览做法:同一位置放两个,交给 CSS 各显一半:

const svg =
	`<svg viewBox="0 0 560 190" xmlns="http://www.w3.org/2000/svg" role="img">` +
	`<title class="i18n-en">Contrast preview of ${fgHex} on ${bgHex}, ratio ${r}:1. The top line is 24px bold, the 3:1 large-text case; the bottom line is 16px, the 4.5:1 case.</title>` +
	`<title class="i18n-zh">对比度预览:${fgHex} 在 ${bgHex} 上,比值 ${r}:1。上行为 24px 粗体(3:1 大字号情形),下行为 16px(4.5:1 正文情形)。</title>` +
	...

role="img" 把整个 SVG 变成一个叶子节点——里面的 <text> 对读屏器不可见,<title> 就承载了全部信息。而 <title> 也要成对,因为 display:none 一样会把 <title> 从可访问性树里剪掉:只写英文 <title>,中文视图里这张图就是没名字的。

<title> 里写的不只是颜色,还有比值和每一行在演示什么。一个对比度检查器的预览图如果只说了「这是个色块」,它没有帮上任何忙。


7. 拒绝路径也要双语

—(颜色须为十六进制,如 #767676)

#76767(五位)、zzzrgb(118,118,118) 三种输入走的是同一条拒绝分支,都只出一行、不出预览图。这个工具只接受十六进制,不接受 rgb() / hsl()——不是漏了,是刻意收窄:WCAG 的公式定义在 8-bit sRGB 通道上,接受 CSS 颜色函数就引入了 rgb() 里的十进制/百分比写法、hsl() 的色彩空间与 color-mix() 的中间值,而那一层正是第 3 节里争议复活的地方。宁可明确地拒绝,也不要含糊地接受一半。

not.toBeVisible() 断言过不了——零尺寸元素的可见性判定不稳——所以 E2E 断言的是错误横幅的文本被清空,不是元素消失:

await expect(page.locator('.t-error')).toHaveText('');

8. 两个检查器一致到什么程度

全站有三个地方算 sRGB 相对亮度:独立的 /color/wcag-contrast//color/color-converter/ 里的对比度卡、color-gamut.ts 的色域切片。三处现在用同一个膝点(0.04045)、同一组五个阈值同一个判定式(都用未取整的 ratio)。

但要说「两个检查器完全一致」,曾经是谎话。真的有一处差异,而且我一开始判错了它的性质:

  • wcag-contrast 的对比度行用自适应位数#006ffb4.4999:1
  • color-converter 的预览写死 ratio.toFixed(2),所以同一个颜色在那里印 4.50:1,而判定列同时显示 ✗。

同一个颜色,两个工具印两个不同的数字,其中一个自相矛盾:预览说 4.50,判定说「不到 4.5」。

当时我把这个归成「刻意的取舍」——预览卡是附属功能,一行比五行合适,自适应位数在那里收益太小。写完这句话我才意识到这个判断是错的。收益确实是小的:一个附属卡上少一位小数。代价是同一个页面里并排摆着一个错数字和它对的一个判定,而且没有任何地方告诉读者那个预览不是精确值。这不叫取舍——取舍是在两个都对的方案之间挑一个,而这是一个错的地方被留下来了。它是漏改。

所以改了:预览现在走和独立检查器同一个自适应位数循环,#006ffb 在两个工具里都印 4.4999:1。三处 sRGB 亮度实现,同一个膝点、同一组五个阈值、同一个判定式、同一个打印精度。第 1 节那种自相矛盾的输出在三个工具里都不再可能出现,因为它只在「打印值越过判定阈值、判定值没有越过」时才发生——现在打印精度被这个条件约束住了。

这一节留下来的不是那个 bug,是我给漏改写的一句辩护。


9. 这套做法能覆盖什么,覆盖不了什么

能覆盖的:8-bit sRGB 十六进制的对比度,精确到能判断一个颜色在不在阈值的哪一侧;五个 WCAG 2.1 判定;一个对读屏器可见、标签与渲染字号一致的预览。

覆盖不了的

  • 非 8-bit 与色彩空间转换。16-bit 通道、color-mix()、OKLCH/Lab 往返,k 不再是 1/255 的整数倍,第 2 节那个 330 倍差距重新出现。工具明确只收十六进制,拒绝而不是含糊。
  • 文本渲染的实际可读性。对比度是公式,不是感知。灰底小字号即使过了 4.5:1,字重、字距、抗锯齿的实际观感不在公式里。预览图给你看的只是颜色,不是排版。
  • 大字号的“大”。18.7px 粗体这条线来自 14pt,但「粗体」在 CSS 里没有统一阈值(font-weight: 700?还是 600?浏览器各自的字形合成规则会介入)。工具用标签写清了口径,判定式只认比值。
  • 两个检查器的显示位数。第 8 节那个残留差异。
  • WCAG 规范版本沿革。本文只写了本地实测能证实的事实——两个常数各自的连续性差多少、争议窗口为何在 8-bit 上不可达。哪个常数才是规范钦定的,我没有联网核对,也没有必要:第 2 节的论证不依赖这个答案。

对比度检查器是那种「看起来很对」的工具:公式三行,判定五行,颜色在屏幕上摆着。所有 bug 都长在「数字和判定之间的那层取整」以及「写给谁看的那层标注」上。

4.5:1 < 4.5:1 换成 4.4999:1 < 4.5:1,屏幕上就不再自相矛盾了。设计师看到 4.4999,会知道那是真的没过线,也知道把蓝色往暗调一格就能过。