contrast-guard

August 31, 2026 · View on GitHub

🌸 Bloom for Typora
24 套主题
🌊 Bloom for DSH
4 套配色
🛡️ contrast-guard
配色护栏 · 当前

同一套莫兰迪设计语言:两个宿主的主题,加一个守住它们配色的工具。
One Morandi design language — two themes, and the tool that keeps their colors honest.

把配色对比度做成 CI 护栏。不达标时直接告诉你该改成多少,而不是只丢一个红叉。

支持 OKLCH,零依赖,检查核心 11.6KB。

✗ 1 组配色低于门槛(共检查 48 组)

  theme-src/root-petal.css  链接/主色
    #e63f9f on #fef6f9   3.55:1  门槛 4.5
    → 改成 L=58%(#d0268c,4.55:1,压暗 6 点)
      oklch(64% 0.22 350)

另有 measure 子命令做渲染后视觉计量——check 查 色值对不对,measure 查这一页用了几种字号、灰阶拉开几层、多少元素真的有动效。 它需要浏览器,但驱动是运行时探测的,check 仍然零依赖、CI 里裸跑

为什么造这个

现有的对比度工具都只回答「达标了吗」,而真正费时的是下一步——「那我改成多少?」

一句「3.02:1,不达标」之后,人还得自己试色:调暗一点?调多少?色相会不会偏?改完还要再算一遍。 一套主题几个色值还能忍,24 套主题就是几百次手工试错。

contrast-guard 直接把这一步做完:保持色相与彩度不变,只解出刚好达标的明度, 输出「L=58%,压暗 6 点」这种可以直接抄进代码的结论。

这个工具来自一次真实事故:typora-Bloom-theme (90★)的 24 套主题里,有 6 套的链接色低于 WCAG AA 且长期无人发现。 排查后是四层缺口叠加——校验清单漏了 accent 这一项、校验从没在 CI 里跑过、 新增 workflow 又被 .gitignore 静默吞掉。修完那次,就有了这个包。

对比

体积依赖OKLCH扫色板文件给建议值
contrast-guard11.6KB0
wcag-contrast24KB1
color-contrast-checker36KB0
a11y-color-contrast71KB0
chroma-js388KB0
culori1082KB0

体积按 check 路径的源码算(与表中同类库的功能维度对齐)。measure 是另一条 独立路径,只在你调用它时才加载,不影响 check 的体积与零依赖。

其它工具是「给两个颜色算比值」的库;真实场景是「一堆色板文件 + 一组规则 + 在 CI 里跑」。

为什么支持 OKLCH 重要:现代主题普遍用 OKLCH 定义色板(感知均匀、明暗切换不跳变), 但多数对比度工具只认 hex/rgb —— 于是用 OKLCH 写的色板正好落在检查的盲区里。

安装

npm i -D contrast-guard

使用

npx contrast-guard --init     # 生成配置模板
npx contrast-guard            # 检查,不达标退出码 1
npx contrast-guard --json     # 机器可读输出

contrast.config.json

{
  "files": ["theme-src/root-*.css", "src/tokens.js"],
  "pairs": [
    { "fg": "text",      "bg": "bg", "min": 7,   "label": "正文" },
    { "fg": "text-semi", "bg": "bg", "min": 4.5, "label": "次要文字" },
    { "fg": "accent",    "bg": "bg", "min": 4.5, "label": "链接/主色" }
  ]
}

pairs 要把每一处「会被读的前景色」都列上。 上面那次事故里, 文字类的三项全部宽裕达标(正文 12.9~16.1),唯独漏了 accent —— 而它是正文里唯一会被点击的东西。缺口总是落在清单没覆盖的那一格。

接进 CI

- run: npx contrast-guard

编程调用

import { check, solveLightness, contrast, parseColor } from 'contrast-guard'

const { results, failed } = check({
  files: ['theme-src/root-petal.css'],
  pairs: [{ fg: 'accent', bg: 'bg', min: 4.5 }],
})

// 也可以只用反推
const bg = parseColor('oklch(98% 0.01 350)')
solveLightness('oklch(64% 0.22 350)', bg, 4.5)
// → { L: 58, ratio: 4.55, hex: '#d0268c', delta: -6 }

支持的写法

色值oklch() · #rgb · #rrggbb · rgb() · rgba()(带 alpha 时按背景合成)

来源:CSS 自定义属性;JS/JSON 里的内联色值对象。 两者都支持是因为真实项目的色值常常分散在多处——上面那个主题就是 16 套写在 CSS、 另外 8 套内联在 JS 里,人改色值面对两个入口,而检查只看其中一个。

var(--x) 引用会自动解引用(最多 5 层)。

measure:渲染后视觉计量

check对不对(这个色值达不达标),measure多少(这一页实际用了 几种字号、灰阶拉开几层、多少元素真的有动效)。

需要它是因为:丑的每一处单看往往都"对"。每个色值都是合法 token、每个圆角都在 白名单里,但一屏 9 种字号、灰阶只拉开 2 层、间距 11 种——每条都过检,合起来就是丑。 二值门禁结构上抓不到这个。

contrast-guard measure https://linear.app --save linear   # 量参照站,存基线
contrast-guard measure http://localhost:5173 --vs linear  # 量自己的页面,逐项对比
contrast-guard measure --baselines                        # 列出已存基线
判据
  ⚠  动效几乎不存在:仅 12/6438 个元素带 transition(0.19%)。定义了 motion token ≠ 用上了
  ✓  字号分布集中:10 种,前 4 种覆盖 96%
  ⚠  背景灰阶 60 层,大量一次性色值绕过了 token(参照站量级约 20)

对比基线 linear
                         本次       linear
  动效覆盖率             0.2%         9.0%  ← 差距显著
  背景灰阶层               60           22

阈值由实测校准,不是拍脑袋。 校准时推翻了两个直觉判据:

  • 「字号种类越少越好」是错的——Linear 实测 14 种,比很多平庸站点还多。 稳健的判据是分布集中度(前 4 种覆盖率)。凭空定「≤5 种」,第一天就会把标杆站判成不及格。
  • 圆角/阴影/间距的种类数没有判别力——同一标杆站圆角 20 种、阴影 14 种同样高。 这类只作横向参考,不报警。

所以用法上有个硬要求:先量参照站存成基线,再量自己的页面。没有参照物就没有判据, 「丑」「高级一点」这类形容词对工具和 AI 都是零信息量,换成可观测的参照站才有意义。

基线里还可以补一段风格画像profile 字段:typography / color / layout / motion / signatureMoves),记录计量抓不到、只能看图得出的东西。数字回答「差多少」, 画像回答「差在哪种气质」——尤其是 signatureMoves,那是风格辨识度的来源。 重新计量不会覆盖已填的画像。

浏览器驱动(零依赖边界)

measure 需要一个浏览器,但驱动是运行时探测的,不在 dependencies 里

驱动说明
ego-browser优先。复用已登录状态,能量需要登录才看得到的页面
playwrightnpm i -D playwright && npx playwright install chromium

两个都没装时 measure 会明确报错并给出安装指引,check 不受任何影响—— 它完全不加载这条路径的代码,仍然零依赖、CI 里裸跑。

运行时

面向 Node ≥ 18。纯 ESM,check 路径零依赖,因此 Bun 也能直接跑 (实测 bun 1.3 通过全部测试)。measure 额外需要一个浏览器驱动,见上。

之所以以 Node 为目标而不是 Bun:这个包是被别的项目依赖的 CI 护栏, 不该要求使用方切换运行时;GitHub Actions 默认带 Node,用 Bun 得多一步 setup-bun

配套:同一条前端质量链路

vite-plugin-agent-eyes 是一条链路上的两环,拦的失败模式不重叠——每一环拦的都是前一环拦不住的东西

时机工具拦什么漏掉会怎样
写代码当下agent-eyes agentSizeWatch文件越堆越长CSS 屎山,改一处牵一片
git commitagent-eyes agentGuardcssVarsvar(--从未声明的变量)整条声明失效:z-index 退回 auto、圆角归零,tsc / ESLint / build 全部照过
CIcontrast-guard check变量存在,但色值对比度不达标文字看不清,a11y 不过
页面跑起来contrast-guard measure值都对,但用得太碎每处单看都"对",合起来就是丑
出问题时agent-eyes 运行时日志 / 截图运行时错误、登录态、API 失败agent 只能靠猜代码

递进关系很清楚:变量不存在(agent-eyes)→ 变量存在但值不合格(check)→ 值都合格但用得失控(measure)。三种都不报错、都能过构建,只是坏的方式不同。

agent-eyes 的 Roadmap 里提到「DOM 快照不含 computed styles,视觉相关问题仍依赖 CDP 截图」—— measure 抓的正是渲染后的 computed style 统计,补的就是这块。

License

MIT