check-contrast.mjs

Colour contrast checks

Tier 2designengineeringBlocks the commit

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.

CHECK-CONTRAST.MJSBLOCKS THE COMMIT STYLESHEET COLOUR PAIRS COMMIT UNREADABLE PAIR UNPAIRED ROLE
Add this to your project
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.

Included in