Two real tokens that are unreadable together, like muted text on the muted fill, in light or dark. Also a colour role that no pair measures, which was never passing, only never asked.
Add "Colour contrast checks" from ntent to this repo.
Fetch https://ntent.app/r/f/check-contrast as plain text. Write it verbatim to scripts/check-contrast.mjs. Then make exactly these edits and no others: Point CONFIG.stylesheets at the stylesheet this repo ships, then make CONFIG.pairs the foreground and background pairs your UI actually draws. The defaults are shadcn’s role names. Allowlist a colour role only with the reason it has no pair. Then run node scripts/check-contrast.mjs once and record every failing pair as a CONFIG.debt entry: the pair, the mode, today’s date as recorded, the measured ratio as ratio, and a reason saying what it costs a user. Set no until date. The install lands green, and the debt prints on every run until someone fixes the colour. It needs a generated stylesheet. If this repo does not have it, stop and tell me.
Then check it: Set --muted-foreground to a grey too light for --muted. The check names the pair, the mode, the ratio against its 4.5 floor and both colours. Add a colour role to the stylesheet with no pair and it is reported as unpaired. Record the failure in CONFIG.debt and it passes, printing the debt; set its until date to yesterday and it fails again.Reads https://ntent.app/r/f/check-contrast
Read the code
check-contrast.mjsscripts/check-contrast.mjs
1123 lines
#!/usr/bin/env node
/**
* Contrast gate: every foreground/background role pair the UI draws clears its
* floor, in every mode the stylesheet defines.
*
* LEGIBILITY IS A PROPERTY OF A PAIR, AND NO OTHER CHECK HERE CAN SEE ONE.
* check-tokens asks whether a VALUE is allowed. Two legal tokens used together
* can still be unreadable: a white label on a light-red destructive button is
* `text-destructive-foreground` on `bg-destructive`, both real tokens, and every
* other gate passes it. In the project this came from, a destructive button
* built that way sat at 1.94:1 in dark mode and was found by eye. So the pairs
* are declared once, in CONFIG.pairs, and measured against the stylesheet you
* actually ship.
*
* THE LIST IS HAND-MAINTAINED, AND THAT IS ITS WEAKNESS. A new surface with no
* row here is not covered. Role coverage closes that partway: every colour
* custom property that looks like a role has to appear in a pair, as either
* side, or be named in CONFIG.allowlist with the reason it has no pair. It
* cannot know whether a pair is the RIGHT one, only that a role was not
* silently forgotten. The audit that motivated it found 57 of roughly 106
* roles had never been measured at all.
*
* Floors, and only two of them:
*
* 4.5 WCAG 1.4.3. Anything read as text, at any size you do not control.
* 3 WCAG 1.4.11. A focus indicator, the boundary that is the only way to
* see a control, or a mark that carries meaning. Also 1.4.3's floor for
* text that is only ever large (24px, or 18.66px bold), which is a
* promise about every place the role is used, so make it sparingly.
*
* A decorative border is exempt from 1.4.11, which is why `border` is not
* paired by default. A text field's border is not decorative when it is the
* only thing showing where the field is, which is why `input` is.
*
* KNOWN DEBT IS A REGISTER, NOT AN OFF SWITCH. A pair that fails today and
* whose fix is a design decision goes in CONFIG.debt with what it costs a
* user and the date it was recorded. It prints on EVERY run, pass or fail. It
* goes live again past its `until` date, or if it measures worse than a
* recorded `ratio`. When it starts passing, the run says to delete it.
*
* EXIT CODES, and the line between 1 and 2 is the design.
*
* 0 every declared pair clears its floor, or is recorded debt.
* 1 something is wrong, and the commit should not go through:
* with --check, a pair below its floor, a colour role in no pair, or a
* value it cannot measure (a var() with nothing behind it, a bare
* channel triple nothing says how to read);
* in every mode, a config or stylesheet that cannot mean what it says:
* an allowlist entry with no reason, a malformed debt entry, a pair
* naming a role that is missing and not optional, no light block, no
* --base role, an unknown argument. These print CONFIG, STYLESHEET or
* USAGE and refuse to run at all.
* 2 nothing to measure here: no stylesheet found, or every pair was
* optional and skipped. The hook treats this as a note, so a repo that
* installed the check before it has a stylesheet can still commit.
*
* Exit 2 used to cover the broken cases too, and the hook let them through:
* an allowlist entry with no reason committed with a note beside it, which is
* the one thing an allowlist that demands reasons exists to stop. So 2 now
* means only "there is nothing here yet", and a run that measured nothing
* says so rather than printing a pass.
*
* WHAT THIS DOES NOT PROVE. That anything on screen is legible. It proves the
* DECLARED token pairs clear their floors, which is the part you can check
* offline on every commit. It cannot see a component that puts
* `text-muted-foreground` on `bg-accent` when no pair says it does, text over
* an image or a gradient, opacity applied by a class (`text-primary/70`), a
* ramp step used directly as text, or a literal like shadcn's own
* `text-white` on `bg-destructive`. What is actually composited on screen is
* a rendered check's job: it measures pixels and knows no roles. This knows
* roles and measures no pixels. You want both, and this is the cheap one.
*
* Blind spots worth knowing by name:
* - A custom property is resolved on the element that declares it. That is
* right for shadcn, which puts `.dark` on <html> beside `:root`. Put
* `.dark` on <body> instead and a `var()` declared on :root has already
* been computed with the light value, which this does not model.
* - An oklch() outside sRGB is clipped channel by channel, which is what an
* sRGB screen shows. A P3 screen shows more of it, so the ratio there can
* differ slightly.
* - @theme blocks are not read. In a shadcn stylesheet they only alias the
* :root roles for Tailwind, and the roles are what the pairs name.
* - Rules inside any media query other than prefers-color-scheme are ignored.
*
* Usage:
* node scripts/check-contrast.mjs report: exit 0 on findings, 1 if it cannot run
* node scripts/check-contrast.mjs --check exit 1 on a failing pair or an unpaired role
* node scripts/check-contrast.mjs --list every measurement, worst first
* node scripts/check-contrast.mjs --css path/to/tokens.css
*/
import { readFileSync, existsSync, realpathSync } from 'node:fs'
import { resolve, relative, extname } from 'node:path'
import { fileURLToPath } from 'node:url'
// ---------------------------------------------------------------- CONFIG ---
/** WCAG 1.4.3: text. */
const TEXT = 4.5
/** WCAG 1.4.11: a focus ring, a control's only boundary, a meaningful mark. */
const UI = 3
export const CONFIG = {
// The generated stylesheet, in preference order. The same list check-tokens
// reads, so the two checks cannot be looking at different files. First one
// that exists wins, and --css overrides all of them.
stylesheets: ['packages/tokens/dist/tokens.css', 'src/styles/tokens.css', 'src/app/globals.css', 'app/globals.css'],
// Which blocks hold which mode. A rule belongs to a mode when one of its
// selectors is in that mode's list, compared with whitespace, quotes and any
// :not(...) normalised away. A mode with `over` starts from that mode's
// values and overrides them, which is how the cascade treats `.dark` on the
// same element as `:root`. A rule inside `@media (<media>)` whose selector
// belongs to the base mode counts for this one, which is how
// `@media (prefers-color-scheme: dark) { :root { ... } }` is read.
// `canvas` is what the page itself sits on, for a page colour with alpha.
modes: [
{
name: 'light',
selectors: [':root', 'html', ':host', '.light', '[data-theme="light"]', ':root[data-theme="light"]', 'html[data-theme="light"]', ':root.light', 'html.light'],
media: 'prefers-color-scheme: light',
canvas: '#ffffff',
},
{
name: 'dark',
over: 'light',
selectors: ['.dark', ':root.dark', 'html.dark', '[data-theme="dark"]', ':root[data-theme="dark"]', 'html[data-theme="dark"]'],
media: 'prefers-color-scheme: dark',
canvas: '#000000',
},
],
// Modes that must be present or there is nothing to check. Dark is not
// required: a product with one mode is a real product. A missing dark block
// is printed as not checked rather than silently skipped.
requireModes: ['light'],
// The page. A translucent background role is composited over this before
// its foreground is measured on it.
base: 'background',
// How a bare channel triple is read, as in `--primary: 222.2 47.4% 11.2%`
// for `hsl(var(--primary))`. 'auto' reads the wrapper off the stylesheet
// where it can (`rgb(var(--paper))`), and otherwise reads `H S% L%` as hsl
// and refuses anything else, because `1 0 0` is white as oklch and nearly
// black as rgb. Set 'rgb', 'hsl', 'oklch' or 'oklab' to say which.
bareChannels: 'auto',
// A colour property whose name matches this is a raw ramp step, not a role,
// and is left out of role coverage: brand-500 is a palette entry, primary is
// a promise about where it is drawn. null treats every colour as a role.
rampPattern: /-\d{2,3}$/,
// The pairs. shadcn's role names, because that is what a TweakCN stylesheet
// defines. `optional` pairs are skipped, by name, where the stylesheet does
// not define a role; every other pair naming a missing role stops the run.
pairs: [
// Body copy on every ground it meets.
{ fg: 'foreground', bg: 'background', floor: TEXT },
{ fg: 'card-foreground', bg: 'card', floor: TEXT },
{ fg: 'popover-foreground', bg: 'popover', floor: TEXT },
// Muted copy is still copy: descriptions, helper text, the empty state.
// CardDescription puts it on the card, not the page.
{ fg: 'muted-foreground', bg: 'background', floor: TEXT },
{ fg: 'muted-foreground', bg: 'muted', floor: TEXT },
{ fg: 'muted-foreground', bg: 'card', floor: TEXT },
// Labels on filled keys.
{ fg: 'primary-foreground', bg: 'primary', floor: TEXT },
{ fg: 'secondary-foreground', bg: 'secondary', floor: TEXT },
{ fg: 'accent-foreground', bg: 'accent', floor: TEXT, note: 'hovered menu item, ghost button' },
// shadcn v4 dropped this role and draws text-white on bg-destructive,
// which is a literal no token check can see. Optional for that reason.
{ fg: 'destructive-foreground', bg: 'destructive', floor: TEXT, optional: true },
// Coloured text. A pair that holds a word is a text pair.
{ fg: 'primary', bg: 'background', floor: TEXT, note: 'the link button variant' },
{ fg: 'destructive', bg: 'background', floor: TEXT, note: 'form validation message, destructive link' },
// Two pairs, one role. `destructive` is measured here as text and above as a
// fill. Where your stylesheet has a separate status role for text and a
// solid role for fills (error and error-solid, say), declare the fill and
// its ink as their own pair too: a text-and-icon role is a pastel in dark
// mode and fails under white text.
// WCAG 1.4.11. `ring` is what shadcn draws as border-ring on focus; its
// halo is ring at 50% alpha, and the solid border is the indicator. `input`
// is a text field's border, and on a field with no fill it is the only
// thing showing where the field is.
{ fg: 'ring', bg: 'background', floor: UI, note: 'focus indicator' },
{ fg: 'input', bg: 'background', floor: UI, note: "a field's only visible boundary" },
// The sidebar block, where the stylesheet has one.
{ fg: 'sidebar-foreground', bg: 'sidebar', floor: TEXT, optional: true },
{ fg: 'sidebar-primary-foreground', bg: 'sidebar-primary', floor: TEXT, optional: true },
{ fg: 'sidebar-accent-foreground', bg: 'sidebar-accent', floor: TEXT, optional: true },
{ fg: 'sidebar-ring', bg: 'sidebar', floor: UI, optional: true, note: 'focus indicator' },
],
// Colour roles that genuinely have no pair, each with the reason. A key may
// end in * to cover a family. The reason is required, and an entry without
// one stops the run: the point of the list is that each exemption can be
// read and challenged, and one with no reason can be neither.
allowlist: {
border:
'dividers and card edges. A line that identifies nothing is decorative, and WCAG 1.4.11 does not reach it. ' +
"If a control's only boundary is drawn in border, pair it at 3:1 instead.",
'sidebar-border': 'the same decorative divider as border, inside the sidebar.',
'chart-*':
'series colours. 1.4.11 applies when colour is the only carrier of the data, and the pairs that matter then ' +
'are series against series and against the plot, which this list cannot express. Pair chart-N against card ' +
'at 3:1 if your charts carry meaning by colour alone.',
'shadow-color': 'tints a shadow, which is depth. It is never the only way to find or read anything.',
},
// Pairs that fail today, kept on purpose because the fix is a design
// decision. Printed on every run. On install, run this once and record
// every pair that fails on the theme as it stands, with no until: the
// install lands green and the printing is the pressure. Each entry:
// pair 'fg on bg', naming a pair in CONFIG.pairs
// mode the mode it fails in
// reason what a user loses while it fails, in one sentence
// recorded YYYY-MM-DD
// until optional YYYY-MM-DD. Past it, the failure is live again
// ratio optional, as measured when recorded. Worse than it is live again
debt: [
// {
// pair: 'input on background',
// mode: 'light',
// reason: 'an empty text field on the page has no visible edge, so a form reads as loose labels.',
// recorded: '2026-09-25',
// ratio: 1.26,
// },
],
}
// ---------------------------------------------------------------- COLOUR ---
/**
* A colour as gamma-encoded sRGB channels, 0 to 1, and an alpha. Channels are
* left unclipped here so the caller can say when something was out of gamut.
*/
const rgba = (r, g, b, alpha = 1) => ({ r, g, b, alpha: Math.min(1, Math.max(0, alpha)) })
const HEX = /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i
const FUNCTION = /^(rgba?|hsla?|oklch|oklab)\(\s*([^()]*)\)$/i
const NUMBER = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?/i
const BARE = /^[+-]?[\d.]+(?:%|deg|rad|grad|turn)?(?:\s+[+-]?[\d.]+(?:%|deg|rad|grad|turn)?){2}(?:\s*\/\s*[\d.]+%?)?$/i
const BARE_HSL = /^[+-]?[\d.]+(?:deg)?\s+[\d.]+%\s+[\d.]+%(?:\s*\/\s*[\d.]+%?)?$/
const NAMED = {
transparent: rgba(0, 0, 0, 0),
white: rgba(1, 1, 1),
black: rgba(0, 0, 0),
}
/** A number, a percentage (scaled so 100% is `hundred`), or `none`. */
const component = (token, hundred) => {
if (token === undefined) return null
if (token.toLowerCase() === 'none') return 0
const m = NUMBER.exec(token)
if (!m) return null
const unit = token.slice(m[0].length).toLowerCase()
const n = Number.parseFloat(m[0])
if (unit === '%') return (n / 100) * hundred
return unit === '' ? n : null
}
const angle = (token) => {
if (token === undefined) return null
if (token.toLowerCase() === 'none') return 0
const m = NUMBER.exec(token)
if (!m) return null
const n = Number.parseFloat(m[0])
const unit = token.slice(m[0].length).toLowerCase()
const perDegree = { '': 1, deg: 1, grad: 0.9, rad: 180 / Math.PI, turn: 360 }[unit]
return perDegree === undefined ? null : n * perDegree
}
const alphaOf = (token) => (token === undefined ? 1 : component(token, 1))
/** HSL to sRGB, s and l in 0 to 1. The CSS Color 4 formulation. */
const hslToRgb = (h, s, l) => {
const hue = ((h % 360) + 360) % 360
const f = (n) => {
const k = (n + hue / 30) % 12
const a = s * Math.min(l, 1 - l)
return l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1))
}
return [f(0), f(8), f(4)]
}
const linearToGamma = (c) => {
const sign = c < 0 ? -1 : 1
const x = Math.abs(c)
return sign * (x <= 0.0031308 ? 12.92 * x : 1.055 * x ** (1 / 2.4) - 0.055)
}
/** OKLab to gamma-encoded sRGB, with Ottosson's matrices. */
const oklabToRgb = (L, a, b) => {
const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3
const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3
const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3
return [
4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
].map(linearToGamma)
}
/**
* Parse a CSS colour, or return null. Covers the syntaxes a shadcn or TweakCN
* stylesheet actually uses: hex in all four lengths, rgb() and hsl() in comma
* and space syntax with `/ alpha`, oklch(), oklab(), and a bare channel triple
* read as `bareAs`. Anything else, color-mix() and currentColor included, is
* null, and the caller turns null into "cannot measure", never a pass.
*/
export const parseColor = (input, { bareAs = null } = {}) => {
let value = String(input).trim().replace(/\s*!important$/i, '')
const lower = value.toLowerCase()
if (NAMED[lower]) return { ...NAMED[lower] }
const hex = HEX.exec(value)
if (hex) {
let h = hex[1]
if (h.length <= 4) h = [...h].map((c) => c + c).join('')
const byte = (i) => Number.parseInt(h.slice(i, i + 2), 16) / 255
return rgba(byte(0), byte(2), byte(4), h.length === 8 ? byte(6) : 1)
}
if (BARE.test(value)) {
const as = bareAs ?? (BARE_HSL.test(value) ? 'hsl' : null)
if (!as) return null
value = `${as}(${value})`
}
const fn = FUNCTION.exec(value)
if (!fn) return null
const name = fn[1].toLowerCase()
let body = fn[2].trim()
let alphaToken
if (body.includes('/')) {
const [channels, a] = body.split('/')
body = channels
alphaToken = a.trim()
}
const parts = body.split(/[\s,]+/).filter(Boolean)
if (alphaToken === undefined && parts.length === 4) alphaToken = parts.pop()
if (parts.length !== 3) return null
const alpha = alphaOf(alphaToken)
if (alpha === null) return null
if (name === 'rgb' || name === 'rgba') {
const [r, g, b] = parts.map((p) => component(p, 255))
if ([r, g, b].some((c) => c === null)) return null
return rgba(r / 255, g / 255, b / 255, alpha)
}
if (name === 'hsl' || name === 'hsla') {
const h = angle(parts[0])
// Modern syntax allows s and l without the %, meaning the same number.
const s = component(parts[1].endsWith('%') ? parts[1] : `${parts[1]}%`, 1)
const l = component(parts[2].endsWith('%') ? parts[2] : `${parts[2]}%`, 1)
if ([h, s, l].some((c) => c === null)) return null
return rgba(...hslToRgb(h, s, l), alpha)
}
// oklch and oklab: L is 0 to 1 or a percentage; chroma and a/b have 100% = 0.4.
const L = component(parts[0], 1)
if (L === null) return null
if (name === 'oklch') {
const C = component(parts[1], 0.4)
const H = angle(parts[2])
if (C === null || H === null) return null
const rad = (H * Math.PI) / 180
return rgba(...oklabToRgb(L, C * Math.cos(rad), C * Math.sin(rad)), alpha)
}
const a = component(parts[1], 0.4)
const b = component(parts[2], 0.4)
if (a === null || b === null) return null
return rgba(...oklabToRgb(L, a, b), alpha)
}
const clip = (c) => Math.min(1, Math.max(0, c))
/** True when a channel had to be clipped to fit sRGB. */
export const outOfGamut = (c) => [c.r, c.g, c.b].some((v) => v < -1e-4 || v > 1 + 1e-4)
/**
* Paint `top` over an opaque `under`, per channel in gamma-encoded sRGB, which
* is what a browser does. Contrast is only defined between opaque colours, so
* a translucent role has to be flattened onto what it actually sits on first.
*
* The result is rounded to 8 bits a channel, because that is what the screen
* shows and what the report prints. Measuring 127.5 while printing #808080
* gives a ratio nobody can reproduce from the hex beside it.
*/
export const composite = (top, under) => {
const a = top.alpha
const mix = (t, u) => Math.round((clip(t) * a + clip(u) * (1 - a)) * 255) / 255
return rgba(mix(top.r, under.r), mix(top.g, under.g), mix(top.b, under.b), 1)
}
/** WCAG 2.x relative luminance. */
export const luminance = (c) => {
const lin = (v) => {
const x = clip(v)
return x <= 0.04045 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4
}
return 0.2126 * lin(c.r) + 0.7152 * lin(c.g) + 0.0722 * lin(c.b)
}
/** WCAG 2.x contrast ratio between two opaque colours, 1 to 21. */
export const contrastRatio = (a, b) => {
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x)
return (hi + 0.05) / (lo + 0.05)
}
export const toHex = (c) =>
'#' + [c.r, c.g, c.b].map((v) => Math.round(clip(v) * 255).toString(16).padStart(2, '0')).join('')
// ------------------------------------------------------------ STYLESHEET ---
/**
* The stylesheet with its comments taken out, and nothing inside a string or a
* url() touched. A regex for /* ... *\/ cannot tell a comment from the glob in
* `@source "../**\/*.tsx"`: the `/*` there opened a "comment" that ran to the
* next `*\/` in the file and swallowed every rule between.
*/
export const stripComments = (css) => {
let out = ''
let i = 0
while (i < css.length) {
const ch = css[i]
if (ch === '"' || ch === "'") {
let j = i + 1
while (j < css.length && css[j] !== ch && css[j] !== '\n') j += css[j] === '\\' ? 2 : 1
out += css.slice(i, j + 1)
i = j + 1
} else if (ch === '/' && css[i + 1] === '*') {
const end = css.indexOf('*/', i + 2)
i = end === -1 ? css.length : end + 2
} else {
out += ch
i++
}
}
return out
}
/**
* Every style rule in the file with the media conditions around it and its
* custom-property declarations, in source order. @layer and @supports are
* transparent; @theme, @keyframes and every other at-rule are skipped whole.
* Nested style rules are skipped too: a mode block does not live inside one.
*/
export const parseRules = (css) => {
const text = stripComments(css)
const rules = []
let i = 0
const skipQuoted = (from) => {
const q = text[from]
let j = from + 1
while (j < text.length && text[j] !== q) j += text[j] === '\\' ? 2 : 1
return j + 1
}
const skipParens = (from) => {
let depth = 0
let j = from
for (; j < text.length; j++) {
if (text[j] === '"' || text[j] === "'") {
j = skipQuoted(j) - 1
continue
}
if (text[j] === '(') depth++
else if (text[j] === ')' && --depth === 0) return j + 1
}
return j
}
const declare = (raw, into) => {
const m = /^\s*--([A-Za-z0-9_-]+)\s*:([\s\S]*)$/.exec(raw)
if (m) into.push([m[1], m[2].trim().replace(/\s*!important$/i, '')])
}
const childOf = (prelude, ctx) => {
if (ctx.kind === 'skip' || ctx.kind === 'style') return { kind: 'skip' }
if (prelude.startsWith('@')) {
const at = /^@([a-z-]+)/i.exec(prelude)?.[1].toLowerCase()
if (at === 'media') return { kind: 'group', media: [...ctx.media, prelude.slice(6).trim()] }
if (at === 'layer' || at === 'supports') return { kind: 'group', media: ctx.media }
return { kind: 'skip' }
}
return { kind: 'style', selectors: splitTopLevel(prelude), media: ctx.media }
}
const block = (ctx) => {
const decls = []
let buf = ''
while (i < text.length) {
const ch = text[i]
if (ch === '"' || ch === "'") {
const end = skipQuoted(i)
buf += text.slice(i, end)
i = end
} else if (ch === '(') {
const end = skipParens(i)
buf += text.slice(i, end)
i = end
} else if (ch === '{') {
i++
const child = childOf(buf.trim(), ctx)
buf = ''
const inner = block(child)
if (child.kind === 'style') rules.push({ selectors: child.selectors, media: child.media, decls: inner })
} else if (ch === '}') {
i++
declare(buf, decls)
return decls
} else if (ch === ';') {
i++
declare(buf, decls)
buf = ''
} else {
buf += ch
i++
}
}
declare(buf, decls)
return decls
}
block({ kind: 'top', media: [] })
return rules
}
/** Split on commas that are not inside brackets, parentheses or quotes. */
const splitTopLevel = (s) => {
const out = []
let depth = 0
let quote = null
let cur = ''
for (const ch of s) {
if (quote) {
if (ch === quote) quote = null
} else if (ch === '"' || ch === "'") quote = ch
else if (ch === '(' || ch === '[') depth++
else if (ch === ')' || ch === ']') depth--
else if (ch === ',' && depth === 0) {
out.push(cur.trim())
cur = ''
continue
}
cur += ch
}
if (cur.trim()) out.push(cur.trim())
return out
}
/** A selector compared the way a person reads it. */
export const normaliseSelector = (s) => {
// Quotes dropped altogether: a minifier writes [data-theme=dark], and the
// config says [data-theme="dark"]. They are the same selector.
let out = s.trim().replace(/['"]/g, '')
// :not(...) with its own parentheses, so `:root:not([data-theme])` is :root.
for (let guard = 0; guard < 10 && /:not\(/i.test(out); guard++) {
const start = out.search(/:not\(/i)
let depth = 0
let j = start + 4
for (; j < out.length; j++) {
if (out[j] === '(') depth++
else if (out[j] === ')' && --depth === 0) break
}
out = out.slice(0, start) + out.slice(j + 1)
}
return out
.replace(/\s*([=[\]])\s*/g, '$1')
.replace(/\s+/g, ' ')
.toLowerCase()
}
const squash = (s) => s.replace(/\s+/g, '').toLowerCase()
/**
* Custom properties per mode. Each mode's map already includes what it
* inherits from its `over` mode, so a lookup never has to walk the chain.
* `own` is what each mode's blocks declared themselves, which is how a mode
* with no block at all is told apart from one that happens to agree.
*/
export const readModes = (css, modes) => {
const rules = parseRules(css)
const selectorsOf = new Map(modes.map((m) => [m.name, new Set(m.selectors.map(normaliseSelector))]))
const own = new Map(modes.map((m) => [m.name, new Map()]))
let ignored = 0
for (const rule of rules) {
const selectors = rule.selectors.map(normaliseSelector)
let targets = []
if (rule.media.length === 0) {
targets = modes.filter((m) => selectors.some((s) => selectorsOf.get(m.name).has(s)))
} else {
// Every condition has to be this mode's colour scheme. A :root inside
// `(min-width: 768px)` is not a mode block, and reading it as one would
// measure a value most viewports never get.
const byMedia = modes.find((m) => m.media && rule.media.every((q) => squash(q).includes(squash(m.media))))
if (byMedia) {
const accepted = new Set([...selectorsOf.get(byMedia.name), ...(byMedia.over ? selectorsOf.get(byMedia.over) : [])])
if (selectors.some((s) => accepted.has(s))) targets = [byMedia]
} else if (rule.decls.length) {
ignored++
}
}
for (const m of targets) for (const [name, value] of rule.decls) own.get(m.name).set(name, value)
}
const merged = new Map()
const build = (name, seen = new Set()) => {
if (merged.has(name)) return merged.get(name)
if (seen.has(name)) throw new Refusal('CONFIG', `CONFIG.modes: "${name}" inherits from itself.`)
seen.add(name)
const def = modes.find((m) => m.name === name)
if (!def) throw new Refusal('CONFIG', `CONFIG.modes: a mode inherits from "${name}", which is not a mode.`)
const parent = def.over ? build(def.over, seen) : new Map()
const map = new Map([...parent, ...own.get(name)])
merged.set(name, map)
return map
}
for (const m of modes) build(m.name)
return { merged, own, ignored }
}
/**
* Which colour function each custom property is wrapped in, where the
* stylesheet says: `rgb(var(--paper))` means --paper is rgb channels. Read off
* the whole file, @theme included, because that is usually where it is said.
*/
export const wrappersIn = (css) => {
const found = new Map()
for (const m of css.matchAll(/\b(rgba?|hsla?|oklch|oklab)\(\s*var\(\s*--([A-Za-z0-9_-]+)\s*[,)]/gi)) {
const fn = m[1].toLowerCase()
found.set(m[2], fn === 'rgba' ? 'rgb' : fn === 'hsla' ? 'hsl' : fn)
}
return found
}
/**
* Substitute every var() in a value against one mode, fallbacks included.
* Returns the substituted text, or an error naming the first var() that
* resolves to nothing. A cycle is an error rather than a hang.
*/
export const resolveValue = (value, vars, seen = new Set()) => {
let out = ''
let i = 0
while (i < value.length) {
const start = value.indexOf('var(', i)
if (start === -1) {
out += value.slice(i)
break
}
out += value.slice(i, start)
let depth = 0
let j = start + 3
for (; j < value.length; j++) {
if (value[j] === '(') depth++
else if (value[j] === ')' && --depth === 0) break
}
const inner = value.slice(start + 4, j)
const comma = splitTopLevel(inner)
const name = comma[0].trim().replace(/^--/, '')
const fallback = inner.includes(',') ? inner.slice(inner.indexOf(',') + 1).trim() : null
let sub
if (vars.has(name) && !seen.has(name)) {
const r = resolveValue(vars.get(name), vars, new Set([...seen, name]))
if (r.error) return r
sub = r.value
} else if (seen.has(name)) {
return { error: `var(--${name}) refers to itself` }
} else if (fallback !== null) {
const r = resolveValue(fallback, vars, seen)
if (r.error) return r
sub = r.value
} else {
return { error: `var(--${name}) is not defined and has no fallback` }
}
out += sub
i = j + 1
}
return { value: out.trim() }
}
// ------------------------------------------------------------------ MAIN ---
/**
* The run stops. `kind` is what the first word of the message says is wrong,
* and `code` is the exit: 1 for a config, stylesheet or command line that
* cannot mean what it says, 2 only for "there is nothing here to measure".
*/
class Refusal extends Error {
constructor(kind, message, code = 1) {
super(message)
this.kind = kind
this.code = code
}
}
const NOTHING = 'NOTHING TO MEASURE'
const DATE = /^\d{4}-\d{2}-\d{2}$/
/** Reason text, wrapped so a long one stays readable in a terminal. */
const wrap = (text, width = 68) => {
const lines = []
let line = ''
for (const word of String(text).split(/\s+/)) {
if (line && line.length + word.length + 1 > width) {
lines.push(line)
line = word
} else line = line ? `${line} ${word}` : word
}
if (line) lines.push(line)
return lines
}
/**
* Two decimals, unless two decimals would print a failing ratio as its own
* floor. 4.497 is a fail, and "4.50:1, floor 4.5, FAIL" reads as a bug in the
* checker.
*/
const showRatio = (ratio, floor) => {
const two = ratio.toFixed(2)
return ratio < floor && Number(two) >= floor ? ratio.toFixed(3) : two
}
const pairKey = (p) => `${p.fg} on ${p.bg}`
const globMatch = (pattern, name) =>
pattern.endsWith('*') ? name.startsWith(pattern.slice(0, -1)) : pattern === name
/** Refuse to run on a config that cannot mean what it says. */
const validateConfig = (config) => {
const modeNames = new Set(config.modes.map((m) => m.name))
if (!Array.isArray(config.pairs)) throw new Refusal('CONFIG', 'CONFIG.pairs has to be a list.')
for (const p of config.pairs) {
if (!p || typeof p.fg !== 'string' || typeof p.bg !== 'string' || typeof p.floor !== 'number' || !(p.floor > 1 && p.floor <= 21)) {
throw new Refusal('CONFIG', `CONFIG.pairs has an entry that is not { fg, bg, floor }: ${JSON.stringify(p)}.`)
}
}
for (const [role, reason] of Object.entries(config.allowlist ?? {})) {
if (typeof reason !== 'string' || !reason.trim()) {
throw new Refusal(
'CONFIG',
`allowlist entry "${role}" has no reason.\n` +
' An exemption nobody can read is one nobody can challenge. Say why this role has no\n' +
' pair, or delete the entry and pair it.',
)
}
}
const declared = new Set(config.pairs.map(pairKey))
for (const d of config.debt ?? []) {
const name = d?.pair ?? JSON.stringify(d)
if (!d || typeof d.reason !== 'string' || !d.reason.trim()) {
throw new Refusal('CONFIG', `debt entry "${name}" has no reason. Write what a user loses while it fails.`)
}
if (!declared.has(d.pair)) {
throw new Refusal('CONFIG', `debt entry "${name}" names no pair in CONFIG.pairs. Write it as 'fg on bg'.`)
}
if (!modeNames.has(d.mode)) {
throw new Refusal('CONFIG', `debt entry "${name}" has mode "${d.mode}". It has to be one of: ${[...modeNames].join(', ')}.`)
}
// Dates compare as strings, so the format is load-bearing: 'soon' would
// sort after every real date and the entry would never expire.
if (!DATE.test(d.recorded ?? '')) {
throw new Refusal('CONFIG', `debt entry "${name}" has recorded: "${d.recorded}". It has to be YYYY-MM-DD.`)
}
if (d.until !== undefined && !DATE.test(d.until)) {
throw new Refusal('CONFIG', `debt entry "${name}" has until: "${d.until}". It has to be YYYY-MM-DD, or absent.`)
}
if (d.ratio !== undefined && typeof d.ratio !== 'number') {
throw new Refusal('CONFIG', `debt entry "${name}" has ratio: ${JSON.stringify(d.ratio)}. It has to be a number, or absent.`)
}
}
}
/**
* The whole check, as a function returning its exit code. The CLI below is a
* thin wrapper, so the tests can hand it a config and a date.
*/
export const runContrast = ({
argv = [],
cwd = process.cwd(),
config = CONFIG,
today = new Date().toISOString().slice(0, 10),
log = console.log,
} = {}) => {
try {
return check({ argv: [...argv], cwd, config, today, log })
} catch (e) {
if (!(e instanceof Refusal)) throw e
log(`check-contrast: ${e.kind}: ${e.message}`)
return e.code
}
}
const check = ({ argv, cwd, config, today, log }) => {
// Relative where that is shorter to read, absolute for a file outside cwd.
const rel = (f) => {
const r = relative(cwd, f)
return !r || r.startsWith('..') ? f : r
}
// Boolean and value flags pulled out separately, for the reason check-tokens
// gives: a helper that guesses eats the next argument. Anything left over is
// a mistake, and a typo of --check that ran as a report would pass a commit.
const takeFlag = (name) => {
const i = argv.indexOf(name)
if (i === -1) return false
argv.splice(i, 1)
return true
}
const takeValue = (name) => {
const i = argv.indexOf(name)
if (i === -1) return null
const value = argv[i + 1]
if (value === undefined || value.startsWith('--')) throw new Refusal('USAGE', `${name} needs a value.`)
argv.splice(i, 2)
return value
}
const cssFlag = takeValue('--css')
const strict = takeFlag('--check')
const listAll = takeFlag('--list')
if (argv.length) throw new Refusal('USAGE', `unknown argument${argv.length > 1 ? 's' : ''}: ${argv.join(' ')}`)
validateConfig(config)
const candidates = cssFlag ? [resolve(cwd, cssFlag)] : config.stylesheets.map((p) => resolve(cwd, p))
const cssPath = candidates.find((c) => existsSync(c))
if (!cssPath) {
throw new Refusal(
NOTHING,
'no stylesheet to check, so nothing was checked.\n' +
` Looked for: ${cssFlag ?? config.stylesheets.join(', ')}\n` +
' Build your tokens, point CONFIG.stylesheets at the file you ship, or pass --css <path>.',
2,
)
}
// Sass source is not CSS: `#{$brand}`, nesting and @use change what a custom
// property holds, and reading it as CSS gives a confident wrong ratio. Say so
// and point at the compiled file, which is what the browser gets anyway.
if (['.scss', '.sass', '.less'].includes(extname(cssPath).toLowerCase())) {
throw new Refusal(
NOTHING,
`${rel(cssPath)} is ${extname(cssPath).slice(1).toUpperCase()}, and SCSS is not read: variables, nesting and interpolation change what a colour is.\n` +
' Point CONFIG.stylesheets, or --css, at the compiled .css file.',
2,
)
}
const css = readFileSync(cssPath, 'utf8')
// A stock Tailwind v3 stylesheet is three @tailwind lines and no tokens. That
// is nothing to measure, the same as no stylesheet, and unlike a stylesheet
// whose tokens sit under a selector CONFIG.modes does not list.
if (parseRules(css).every((r) => !r.decls.length)) {
throw new Refusal(
NOTHING,
`${rel(cssPath)} defines no custom properties, so there are no colour tokens to measure.\n` +
' A stock Tailwind v3 stylesheet is only @tailwind directives. Contrast starts to mean something once\n' +
' the theme is written down as custom properties (shadcn writes --background, --foreground, ...).',
2,
)
}
const { merged, own, ignored } = readModes(css, config.modes)
for (const name of config.requireModes) {
if (!own.get(name)?.size) {
const def = config.modes.find((m) => m.name === name)
throw new Refusal(
'STYLESHEET',
`${rel(cssPath)} has no ${name} block, so there is nothing to measure it against.\n` +
` Looked for ${def ? def.selectors.join(', ') : `a mode called ${name}`}. Add the selector to CONFIG.modes.`,
)
}
}
const modes = config.modes.filter((m) => own.get(m.name).size > 0)
const absentModes = config.modes.filter((m) => own.get(m.name).size === 0).map((m) => m.name)
// Every role a pair names, against every role the stylesheet defines in any
// mode. A missing role on a pair that is not optional stops the run with the
// list: that pair is config describing a different stylesheet, and skipping
// it in silence is how a check reports clean on half its list.
const defined = new Set(modes.flatMap((m) => [...merged.get(m.name).keys()]))
const missing = []
const skippedPairs = []
const pairs = []
for (const p of config.pairs) {
const absent = [p.fg, p.bg].filter((r) => !defined.has(r))
if (!absent.length) pairs.push(p)
else if (p.optional) skippedPairs.push({ pair: p, absent })
else missing.push({ pair: p, absent })
}
if (!defined.has(config.base)) {
throw new Refusal(
'STYLESHEET',
`${rel(cssPath)} does not define --${config.base}, the page every translucent role is composited over.\n` +
' Set CONFIG.base to the role your page background uses.',
)
}
if (missing.length) {
const roles = [...new Set(missing.flatMap((m) => m.absent))]
throw new Refusal(
'CONFIG',
`${missing.length} pair(s) name a role ${rel(cssPath)} does not define: ${roles.map((r) => `--${r}`).join(', ')}.\n` +
missing.map((m) => ` ${pairKey(m.pair)}`).join('\n') +
'\n Fix the name in CONFIG.pairs, mark the pair optional: true if some stylesheets\n' +
' genuinely lack it, or delete it. Refusing to report clean on pairs I cannot see.',
)
}
// Bare channel triples: what they mean, per property, where the stylesheet
// says, and otherwise what CONFIG says.
const wrappers = wrappersIn(css)
const bareFor = (name) =>
config.bareChannels === 'auto' ? (wrappers.get(name) ?? null) : config.bareChannels || null
/** A role's colour in a mode, or the reason it has none. */
const colourOf = (name, vars) => {
if (!vars.has(name)) return { error: `--${name} is not defined` }
const r = resolveValue(vars.get(name), vars, new Set([name]))
if (r.error) return { raw: vars.get(name), error: r.error }
// A var() chain carries the channels, so the wrapper to read them with is
// the one the first property in the chain was given.
const chainWrapper = [...vars.get(name).matchAll(/var\(\s*--([A-Za-z0-9_-]+)/g)].map((m) => bareFor(m[1])).find(Boolean)
const colour = parseColor(r.value, { bareAs: bareFor(name) ?? chainWrapper ?? null })
if (!colour) {
const hint = BARE.test(r.value)
? 'a bare channel triple, and nothing says which function it is for. Set CONFIG.bareChannels'
: 'not a colour syntax this checker reads'
return { raw: r.value, error: `${r.value} is ${hint}` }
}
return { raw: r.value, colour }
}
// ------------------------------------------------------------ MEASURE ---
const measured = []
const unmeasurable = []
for (const mode of modes) {
const vars = merged.get(mode.name)
const canvas = parseColor(mode.canvas ?? '#ffffff')
const base = colourOf(config.base, vars)
if (base.error) {
unmeasurable.push({ mode: mode.name, what: `the page, --${config.base}`, error: base.error })
continue
}
const page = composite(base.colour, canvas)
for (const p of pairs) {
const fg = colourOf(p.fg, vars)
const bg = colourOf(p.bg, vars)
const broken = [fg, bg].find((c) => c.error)
if (broken) {
unmeasurable.push({ mode: mode.name, what: pairKey(p), error: broken.error })
continue
}
// The background sits on the page, and the foreground sits on that.
const ground = composite(bg.colour, page)
const ink = composite(fg.colour, ground)
const ratio = contrastRatio(ink, ground)
measured.push({
...p,
mode: mode.name,
ratio,
passes: ratio >= p.floor,
fgHex: toHex(ink),
bgHex: toHex(ground),
fgRaw: fg.raw,
bgRaw: bg.raw,
clipped: [fg, bg].filter((c) => outOfGamut(c.colour)).length > 0,
})
}
}
// ------------------------------------------------------------ COVERAGE ---
// A role is a colour custom property the ramp pattern does not claim. Read
// in every mode, so a role that only becomes a colour in dark still counts,
// and a bare triple nothing can decode still counts: it looks like a colour,
// and leaving it out is exactly the silent gap this exists to close.
const paired = new Set([config.base, ...config.pairs.flatMap((p) => [p.fg, p.bg])])
const allow = Object.keys(config.allowlist ?? {})
const roles = new Set()
for (const mode of modes) {
const vars = merged.get(mode.name)
for (const name of vars.keys()) {
if (config.rampPattern && config.rampPattern.test(name)) continue
const c = colourOf(name, vars)
if (c.colour || (c.raw && BARE.test(c.raw))) roles.add(name)
}
}
const gaps = [...roles].filter((r) => !paired.has(r) && !allow.some((a) => globMatch(a, r))).sort()
const allowed = allow.filter((a) => [...roles].some((r) => globMatch(a, r) && !paired.has(r)))
const staleAllow = allow.filter((a) => !allowed.includes(a))
// ---------------------------------------------------------------- DEBT ---
const round2 = (n) => Math.round(n * 100) / 100
const debt = (config.debt ?? []).map((entry) => {
const m = measured.find((x) => pairKey(x) === entry.pair && x.mode === entry.mode)
let status
if (!m) status = 'unmatched'
else if (m.passes) status = 'resolved'
else if (entry.until && today > entry.until) status = 'expired'
else if (entry.ratio !== undefined && round2(m.ratio) < round2(entry.ratio)) status = 'regressed'
else status = 'open'
return { entry, m, status }
})
const coveredByDebt = (m) => debt.some((d) => d.m === m && d.status === 'open')
const failures = measured.filter((m) => !m.passes && !coveredByDebt(m))
// -------------------------------------------------------------- REPORT ---
const cssLabel = rel(cssPath)
log(`check-contrast: ${cssLabel}`)
log(
` ${pairs.length} pair(s) x ${modes.length} mode(s) (${modes.map((m) => m.name).join(', ')}) = ${measured.length} measurement(s)`,
)
// Blind spots, named every run rather than skipped.
// One line per kind, so a clean commit stays short enough to read.
if (skippedPairs.length) {
log(` skipped, optional and a role is not defined here: ${skippedPairs.map((s) => pairKey(s.pair)).join(', ')}`)
}
for (const name of absentModes) log(` not checked: no ${name} block in the stylesheet.`)
if (ignored) log(` not read: ${ignored} rule(s) inside a media query that is not a colour scheme.`)
if (allowed.length) log(` allowlisted, no pair by design: ${allowed.join(', ')}`)
if (staleAllow.length) {
log(` [stale] allowlist entries matching no unpaired role here: ${staleAllow.join(', ')}. Delete them, so the list keeps meaning what it says.`)
}
if (listAll) {
log('')
for (const m of [...measured].sort((a, b) => a.ratio / a.floor - b.ratio / b.floor)) {
log(
` ${m.passes ? ' ' : 'FAIL'} ${m.mode.padEnd(6)} ${showRatio(m.ratio, m.floor).padStart(6)}:1 floor ${String(m.floor).padEnd(3)} ${pairKey(m)} [${m.fgHex} on ${m.bgHex}]`,
)
}
}
// Known debt prints before any finding, on every run, pass or fail. It must
// be impossible to run this and not see it.
if (debt.length) {
log(`\n Known debt, ${debt.length} recorded:\n`)
for (const { entry, m, status } of debt) {
const now = m ? `${showRatio(m.ratio, m.floor)}:1 (floor ${m.floor})` : 'not measured this run'
log(` [debt] ${entry.mode} ${entry.pair} ${now}, recorded ${entry.recorded}${entry.until ? `, until ${entry.until}` : ''}`)
for (const line of wrap(entry.reason)) log(` ${line}`)
if (status === 'resolved') log(' NOW PASSES. Delete this entry: the debt is paid.')
if (status === 'expired') {
log(` PAST ITS DATE (${entry.until}), so it is a live failure again. Fix it, or agree`)
log(' a new date and write down what changed. Moving the date alone is how debt')
log(' becomes permanent.')
}
if (status === 'regressed') {
log(` WORSE than the ${entry.ratio.toFixed(2)}:1 recorded, so it is a live failure again.`)
}
if (status === 'unmatched') {
log(` Matched nothing: the ${entry.mode} block or one of the roles is not here. Delete`)
log(' the entry if the pair is gone.')
}
log('')
}
}
if (failures.length) {
log(`\n ${failures.length} pair(s) below their floor:\n`)
for (const m of [...failures].sort((a, b) => a.ratio / a.floor - b.ratio / b.floor)) {
const expired = debt.find((d) => d.m === m && d.status !== 'open')
log(
` FAIL ${m.mode.padEnd(6)} ${pairKey(m)} ${showRatio(m.ratio, m.floor)}:1, floor ${m.floor}` +
(expired ? ` (debt ${expired.status})` : ''),
)
const src = (hex, raw) => (raw && raw.toLowerCase() !== hex ? `${hex} (${raw})` : hex)
log(` fg ${src(m.fgHex, m.fgRaw)}`)
log(` bg ${src(m.bgHex, m.bgRaw)}`)
if (m.clipped) log(' clipped to sRGB: a wide-gamut screen shows a slightly different ratio.')
if (m.note) log(` ${m.note}`)
}
log(
'\n Move a colour until the pair clears, correct the pair if the UI does not draw it,\n' +
' or record it in CONFIG.debt with what it costs a user and today\'s date.',
)
}
if (gaps.length) {
log(`\n ${gaps.length} colour role(s) in no pair and not allowlisted:\n`)
for (const g of gaps) log(` UNPAIRED --${g}`)
log(
'\n Add the pair the UI draws it in to CONFIG.pairs, or, if it genuinely has none, add it\n' +
' to CONFIG.allowlist with the reason. A role nobody measured was never passing.',
)
}
// A value it cannot read is a finding, not a note. The stylesheet says this
// role is a colour and nothing can say which one, so the pair was never
// measured, and letting that commit is a pass by omission.
if (unmeasurable.length) {
log(`\n ${unmeasurable.length} measurement(s) could not be taken:\n`)
for (const u of unmeasurable) log(` CANNOT MEASURE ${u.mode.padEnd(6)} ${u.what}: ${u.error}`)
log('\n Give the var() a definition or a fallback, or set CONFIG.bareChannels.')
}
const findings = failures.length + gaps.length + unmeasurable.length
const open = debt.filter((d) => d.status === 'open').length
if (findings) {
log(`\n contrast: ${findings} finding(s), ${open} recorded as debt.\n`)
return strict ? 1 : 0
}
if (!measured.length) {
log('\n contrast: measured nothing, every pair was optional and skipped. This is not a pass, it is an empty run.\n')
return 2
}
log(`\n contrast: ok. ${measured.length} measurement(s) clear their floor${open ? `, ${open} recorded as debt above` : ''}.`)
return 0
}
// ------------------------------------------------------------------- CLI ---
const invokedDirectly = (() => {
try {
return realpathSync(process.argv[1] ?? '') === realpathSync(fileURLToPath(import.meta.url))
} catch {
return false
}
})()
if (invokedDirectly) process.exit(runContrast({ argv: process.argv.slice(2) }))
How to tell it works: Set --muted-foreground to a grey too light for --muted. The check names the pair, the mode, the ratio against its 4.5 floor and both colours. Add a colour role to the stylesheet with no pair and it is reported as unpaired. Record the failure in CONFIG.debt and it passes, printing the debt; set its until date to yesterday and it fails again.