不装库、不调 API:我从零实现 WCAG 对比度,还拿 W3C 官方真值逐条对拍
一键开通华为云码道 CodeArts 代码智能体: https://developer.huaweicloud.com/codeartsco.html?source=dmzntgwatomgit1&sourcead=dmzntgwatomgithd
作品介绍
一个纯前端、零第三方依赖的颜色工具。选一个前景色、一个背景色,它实时算出对比度,并告诉你这组配色在 WCAG 无障碍标准下过不过 AA / AAA;顺带把 HEX/RGB/HSL/HSV 互转、色阶(tints/shades)、互补/类似/三分配色全给你。
但取色器和对比度工具网上一抓一把(WebAIM Contrast Checker 之类),我做这个不是为了再造一个。我要的是:把这套色彩科学从零手写出来,然后拿 W3C 官方给的真值逐条对拍,把误差摊在桌面上。我算的#767676对白底对比度是 4.5422,W3C 文档里那个经典例子就是 4.54;我算的#777777是 4.4781,刚好掉到 AA 门槛 4.5 以下——一个十六进制数的差别,一个过一个不过,而这正是规范的原意。换句话说,别人给你一个数字,我给你这个数字是怎么来的、跟官方对不对得上、差多少。
68 项单元测试全绿,对比度对拍 W3C 误差 < 0.003,色彩空间转换对拍 CSS Color 规范样例值 10/10。仓库公开,欢迎 clone 下来npm run verify自己复现。
为什么非要从零写色彩科学
起因很小。有次我改前端,把一段辅助文字设成#777放白底上,看着挺淡但没多想。后来查无障碍规范才知道,#777对白底对比度 4.48,刚好没过 AA 的 4.5;而#767676是 4.54,过了。一个数字之差,合规与不合规就分家了。
我当时用的在线对比度工具只给我一个数,不告诉我这数怎么来的、准不准。我就想:WCAG 那套相对亮度、对比度公式,其实就几行——sRGB 分量线性化、加权和、(L1+0.05)/(L2+0.05)——我能不能自己实现,并且用 W3C 文档里给的那些 worked example 去验证我实现对了。
说白了,我不信任一个"只吐结果、不给过程"的黑盒。尤其无障碍合规这种事,差 0.02 就从"合规"变"违规",我更想知道这 0.02 是怎么算出来的、我复算一遍对不对得上。与其信 WebAIM,不如自己写一遍再拿它文档里的官方数字来验,两边对上我才踏实。
这跟这次码道比赛我想试的一条路一脉相承:让 AI 去啃"有客观真值可对拍"的活。颜色好不好看是主观的,但"#767676对白底到底是不是 4.54"是 W3C 白纸黑字写死的,是或否赖不掉。
先跑起来看
先看几组真实的配色体检。经典 AA 边界例#767676放白底:对比度 4.54,普通文本 AA 过、AAA 不过,大文本全过——判定跟 W3C 完全一致。
浅灰#aaaaaa放白底,对比度不够,普通文本 AA/AAA 双双飘红:
深蓝底配亮黄字,对比度拉满,四个徽章全绿:
然后是我最看重的对拍。跑npm run verify,对比度逐条跟 W3C 真值比、色彩转换跟 CSS Color 规范样例比:
sRGB 线性化:一个 0.03928 的阈值
WCAG 对比度的第一步是把 sRGB 分量"线性化"——人眼的感知是非线性的,显示器存的也是伽马编码值,算亮度前得先还原成线性光。码道第一轮就把这个写对了:
exportfunctionsrgbToLinear(c){c=c/255;if(c<=0.03928)returnc/12.92;returnMath.pow((c+0.055)/1.055,2.4);}那个0.03928是个有讲究的数——它是 WCAG 2.x 沿用的分段阈值。顺带一提,更新的 sRGB 规范里这个拐点其实是0.04045、线性段斜率是12.92,两者在极暗处有微小差异。WCAG 官方选的是 0.03928 这套,我就老老实实照 W3C 的来,因为我要对拍的正是 W3C 的数。这种"到底用哪个常数"的细节,正是手写和调库的区别——调库你压根不知道它内部用的哪套。
有了线性分量,相对亮度就是加权和:
exportfunctionluminance(r,g,b){constR=srgbToLinear(r),G=srgbToLinear(g),B=srgbToLinear(b);return0.2126*R+0.7152*G+0.0722*B;}0.2126/0.7152/0.0722 这三个系数是 ITU-R BT.709 规定的,人眼对绿最敏感、蓝最不敏感。为什么非要线性化、不能直接拿 0-255 的原始值加权?因为显示器存的 RGB 是伽马编码过的,跟真实光强不是线性关系——直接平均会把中间调的亮度算高,对比度就跟着错。我特意拿#808080(中灰)验过:线性化后它的相对亮度约 0.216,而不是直觉的 0.5,差着一大截。这个 0.216 也正是 WCAG 文档里给的标准值,对上了我才敢往下走。
WCAG 对比度与 AA/AAA
对比度公式一行,但有个细节:谁亮谁做分子。
exportfunctioncontrastRatio(fg,bg){constL1=luminance(fg.r,fg.g,fg.b);constL2=luminance(bg.r,bg.g,bg.b);constlighter=Math.max(L1,L2),darker=Math.min(L1,L2);return(lighter+0.05)/(darker+0.05);}判定阈值分普通文本和大文本两套:
exportfunctionwcagLevel(ratio,{large=false}={}){if(large)return{aa:ratio>=3,aaa:ratio>=4.5};return{aa:ratio>=4.5,aaa:ratio>=7};}#767676对白底 4.54,普通文本 AA(≥4.5)刚好过、AAA(≥7)不过;#777777是 4.48,AA 就挂了。这俩颜色肉眼几乎没差别,但一个合规一个不合规——这就是"眼见不一定为实、得算"的最好例子,我特意把它做成了页面上的"权威真值对拍"卡:
alpha 被忽略的坑
三轮专家评审里,技术专家揪出一个我没想到的洞:contrastRatio只取了 r/g/b,alpha 被解析出来了却没参与计算。也就是说rgba(255,0,0,0.5)这种半透明红,会被当成不透明纯红去算对比度,结论直接误导。
修法是对前景先按 alpha 合成到背景上(over 合成)再算:
exportfunctioncontrastRatio(fg,bg){constfgRgb=(fg.a!==undefined&&fg.a<1)?compositeOver(fg,bg):{r:fg.r,g:fg.g,b:fg.b};// ... 再用 fgRgb 算亮度}补完还加了测试:rgba(255,0,0,0)全透明合成到白底就该≈白、对比度≈1。这种"功能看着对、边界一测就漏"的坑,正是手写实现最容易翻车、也最需要第三方评审来挑的地方。
色彩空间互转 + 色阶配色
除了对比度,还有一堆颜色换算:HEX/RGB/HSL/HSV 互转,以及色阶和配色生成。tints/shades 是往白/黑方向等比插值,互补/类似/三分是色环上取角度:
// tints:向白插值;shades:向黑插值;complementary:色相 +180// analogous:±30;triadic:+120/+240页面里这些排成一排色块,带 hex 小标签,点一下能复制:
还有个"自动选可读文字色"按钮,给定背景色,它拿 bestTextOn 在黑/白里挑对比度更高的那个:
exportfunctionbestTextOn(bg){constwhite={r:255,g:255,b:255},black={r:0,g:0,b:0};returncontrastRatio(white,bg)>=contrastRatio(black,bg)?white:black;}从单栏到两栏:UI 也是一轮轮磨出来的
第三轮刚做出来的页面是单栏的——取色、对比度、色彩空间、色阶、配色从上往下堆一长条,右边大片留白,看着像没排完。而且那会儿还有个"格式错误"误报的 bug 混在里面。
第四轮我按 UI 评审的意见改成两栏:左栏放"取色 + 对比度达标 + 实时预览"这条主操作线,右栏放"色彩空间 + 色阶 + 配色"这些参考信息,窄屏再堆回单列并把预览置顶。这样"改一个色、右边立刻跟着变"的动线才顺。
色彩空间转换这块,HSL/HSV 的公式看着眼熟,真手写才知道坑在哪——比如纯灰(s=0)时色相 h 是未定义的,得约定成 0;hue 要环绕到 [0,360);#RGB三位的简写要每位复制成#RRGGBB。这些边角,调库的人根本不会去想,但对拍的时候一测一个准。
// rgbToHsl 关键分支:max===min 时(灰度)h 与 s 都归 0,避免除零// hue 环绕:h<0 加 360、h>=360 减 360// #abc 展开为 #aabbcc那个把合法 HEX 判成"格式错误"的 bug
第三轮做完页面,我本地一起服务就发现个哭笑不得的 bug:HEX 输入框里明明填的是合法的#FFFFFF、#17233B,下面却标红"格式错误"。值其实解析对了(预览、对比度、色阶全在正常算),就是校验函数误判。
这种 bug 最阴——功能没坏,但 UI 一直冲你喊"你输入错了",用户体验直接崩。我在 R4 里点名让码道修:合法#RGB/#RRGGBB(大小写都收)不许报错,只有真非法才提示。顺带发现parseHex对#gggggg这种是静默返回 NaN(不抛错),跟convert.hexToRgb的校验口径还不一致,也一并统一了。
可验证护城河:对拍 W3C + CSS 规范
这是整个项目的魂。verify/下两条线:
对比度对拍 W3C 权威值——#000000/白=21、#767676/白=4.54、#777777/白=4.48、#0000FF/白=8.59、#FF0000/白≈4.0,误差全在 0.003 以内,AA 判定跟 W3C 边界一致。
色彩转换对拍 CSS Color 规范样例值——这里还有一段整改。我最初让码道写的 roundtrip 是"hex→hsl→hex 往返一致",专家评审直接点破:你用同一对逆函数自己转回来当然一致,这是自证,不是对拍。于是改成拿 CSS Color 规范里给定的官方样例(#ff0000↔hsl(0,100%,50%)、#800000↔hsl(0,100%,25%)、#7f7f7f↔hsl(0,0%,49.8%) 等)去比,10/10 过。
#767676 vs #FFFFFF computed=4.5422 reference=4.54 err=0.0022 AA=true #777777 vs #FFFFFF computed=4.4781 reference=4.48 err=0.0019 AA=false ROUNDTRIP (CSS Color spec): 10/10三轮专家评审揪出的硬伤
R1-R4 做完我拉了 UI、技术、产品三个专家评审读代码和截图打分:技术 80、UI 76、产品 82。分不算高,但揪出来的都是真问题:
技术:
parse.js是唯一入口却零测试;roundtrip 是自证;alpha 被忽略;parseHex不校验。UI:AA/AAA 徽章没列头,读者分不清哪个是 AA 哪个是 AAA;两栏失衡;白底预览块"隐形"。
产品:把"权威真值对拍"从 README 搬进 UI 做成一等公民卡,评委一眼看到护城河。
这些我合成一轮全修了:补 parse 测试(测试数从 55 涨到 68)、roundtrip 改对拍 CSS 规范、alpha 合成、徽章加列头、对拍卡进 UI、两栏平衡。产品专家那句钩子我记着——别人给你对比度数字,我给你这个数字为什么可信。
码道 push 认证又横跳了一次
跟上一篇一样,工具本身的折腾也得记。color-lab 这个会话推完 R1-R4 后,到 R5(README)时 push 直接挂了:fatal: could not read Username for 'https://atomgit.com'。好在这回码道老实——我在提示词里写死了"认证失败就原样贴报错,别碰 credential.helper、别写 token、别 force push",它照做了,没像上次那样自作聪明往凭据文件里塞假 token 把沙箱搞坏。
解法还是那招:开一个全新会话(干净沙箱、新凭据),把 R5 的 README 和 R6 的专家评审修复合成一轮重推,一次成功。CodeArts 上下文涨满了会自动压缩,压回 30% 左右又能接着干。
这里我踩出一个可复用的经验,写下来给同样在码道上批量做项目的人:同一个会话里连着推好几轮,git 凭据很容易在某一刻失效,表现就是码道要么"push 成功"其实是谎报(远端根本没动),要么直接could not read Username。对策有两条——一是提示词里强制它 push 完再跑一次git ls-remote origin main把远端 SHA 打出来自证,逼它别拿"我执行了命令"糊弄成"我推成功了";二是凭据一死就果断开新会话重推,别在旧会话里跟它耗,新沙箱的凭据是干净的,一次就过。
公开仓库
仓库公开,从 R1 的颜色解析一路到 R5+R6 的评审修复,提交链清楚。README 里功能亮点、可验证护城河、复现命令、架构分层都写了。
提效数据
| 环节 | 轮次 | 测试数 | 关键产出 |
|---|---|---|---|
| 解析与转换 | R1 | 32 | hex/rgb/hsl/hsv + sRGB 线性化 + 相对亮度 |
| WCAG | R2 | 45 | 对比度 + AA/AAA + 对拍 W3C 真值 |
| 渲染与页面 | R3 | 55 | 色阶/配色 + 白色系 SPA + 零依赖服务器 |
| 修校验+取证 | R4 | 55 | 修 HEX 误报 + 往返取证 + 两栏 UI |
| 评审修复+README | R5+R6 | 68 | parse 测试 + CSS 规范对拍 + alpha 合成 + 对拍卡进 UI |
五维自检
交作业前我自己过了一遍,也给同样在用码道做"可验证型"项目的人一个参照:
原创度:色彩解析、sRGB 线性化、相对亮度、WCAG 对比度、HSL/HSV 转换、色阶配色全手写,不碰 chroma-js/tinycolor/any-color 任何一行。
可验证性:68 项单元测试锚定 W3C 与 CSS Color 规范的外部真值,
npm run verify把 computed/reference/err 逐条打出来,任何人 clone 都能复现。完成度:取色、双向 HEX 输入、实时预览、AA/AAA 判定、色彩空间、色阶、配色、自动可读色、JSON 导出,能当日常工具真用。
踩坑深度:0.03928 与 0.04045 的阈值之争、#767676 与 #777777 一个数之差卡 AA 边界、alpha 被漏算、合法 HEX 误判、roundtrip 自证——都是真撞上的。
诚实度:评审分不高(76~82),硬伤我全列全修;对拍口径从"自造往返"改成了"对 CSS 规范样例",不敢再拿自证冒充第三方。
本地怎么跑
gitclone https://atomgit.com/gcw_QMYlF6Ie/color-lab.gitcdcolor-labnpmtest# 68 项单元测试npmrun verify# 对比度对拍 W3C + 色彩转换对拍 CSS 规范样例npmrun serve# 打开 http://127.0.0.1:8099/ 看白色系页面写在最后
连着做几个"可验证"的小工具,我越来越确信一件事:AI 时代"能生成"真的不稀缺了,稀缺的是"能自证"。
这个颜色实验室,代码是码道从零写的(没装任何色彩库、没调任何 API),但我逼它把每一处计算都拿去跟 W3C 和 CSS 规范的官方真值对答案——#767676是不是 4.54、#777777是不是刚好挂、#ff0000的 HSL 是不是 (0,100%,50%)。它中途把 alpha 漏算了、把合法 HEX 误判了、把 roundtrip 写成了自证,全被测试和三轮评审逮出来修掉。
trust but verify。给个对比度数字谁都会,敢把这数字怎么来的、误差多少、跟规范对不对得上全摊在页面上,才是它跟"又一个取色器"拉开差距的地方。仓库在这,欢迎 clone 下来npm run verify打我脸:https://atomgit.com/gcw_QMYlF6Ie/color-lab