SLOPSHOPPER

contrast-check

Checks the contrast of a text colour on a background (WCAG 2.x AA and AAA) and suggests the nearest shade that passes. Pure arithmetic; reads no files.

newpanecommand
★ 1v0.1.0MITupdated 2026-10-14samuelbfernandes/moddeck/mods/contrast-check
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · contrast-check
│ ┃ contrast-check ✕ › fix the failing auth test and add an audit log call │ ┃ Use /contrast-check <text colour> │ ┃ [background], each as #rgb, #rrggbb or ⏺ Read(src/auth.ts) │ ┃ rgb(r, g, b). The background defaults to ⎿ Read 6 lines │ ┃ #ffffff. ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /contrast-check │ ⎿ contrast-check: contrast-check · Use /contrast-check <text colou │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · contrast-check
Use /contrast-check <text colour> [background], each as #rgb, #rrggbb or rgb(r, g, b). The background defaults to #ffffff.
README

contrast-check

Install: /plugin install contrast-check --marketplace samuelbfernandes/moddeck

Checks whether a text colour is readable on a background, by the WCAG 2.x contrast rules, and suggests a fix.

What it does

  • /contrast-check <fg> [bg] reads each colour as #rgb, #rrggbb or rgb(r, g, b). The background defaults to #ffffff.
  • Shows both colours as hex, rgb and hsl, with a swatch of each and a line of sample text.
  • Shows the contrast ratio (two decimals, rounded down, so a ratio shown as 4.50 really passes 4.5) and pass or fail for AA normal text (4.5:1), AA large text (3:1), AAA normal text (7:1) and AAA large text (4.5:1).
  • When AA normal text fails, finds the nearest lighter or darker shade of the text colour (same hue and saturation, lightness moved in 0.5 % steps) that passes, and shows it with its ratio.
  • The answer opens in a pane. Where nothing draws panes (a headless run), the whole answer comes back as text instead. Either way the command's one-line answer is what the model sees.
  • Pure arithmetic: no files, no programs, no network. It hooks no tool call.

Use

/contrast-check #777 grey text on white /contrast-check #1a2b3c #f5f5f5 /contrast-check rgb(200, 30, 30) #000

Turn off

Close the pane, or /plugin disable contrast-check. Nothing runs until you call it.

State for the moddeck side bar

{ plugin: 'contrast-check', key: 'report' }: the last answer (fg, bg, ratio, ratioText, checks, fix), or null. Nothing is kept in $.store.

Limits

  • WCAG 2.x only (the relative-luminance formula with the 0.04045 sRGB threshold), not APCA or WCAG 3 drafts.
  • No named colours (red), no alpha, no hsl() input.
  • "Large text" is the WCAG size (18 pt, or 14 pt bold); the mod cannot know your font size.
  • Swatches use the elements' background colour, which every surface takes; how a terminal paints a hex colour depends on its colour support. Tested with a mocked engine on all four element tables, not by eye in a live session.

Credit

Adapted from contrast-check in awesome-claude-code-mods (MIT, © 2026 Yash Thakker and contributors). Rewritten for moddeck: TypeScript in the house style, colours given as command arguments instead of fields in the pane, rgb() and #rgb input besides #rrggbb, hsl output, all four WCAG AA/AAA checks, ratios rounded down, swatches, the nearest passing shade, a text answer where no pane is drawn, and tests. The upstream licence is in LICENSE.

Source 3 files
hooks/register.tsx 90 lines
1// contrast-check: `/contrast-check <fg> [bg]` shows both colours as hex, rgb and hsl, their WCAG 2.x contrast ratio, pass or fail
2// for AA and AAA (normal and large text), swatches, and, when AA normal text fails, the nearest lighter or darker shade
3// of the text colour that passes. Pure arithmetic: no files, no programs, no network.
4// FAIL OPEN: it hooks no tool call and refuses nothing; a bad colour gets a usage line.
5import { atom, read, update } from 'claude-code'
6import type { EngineInterface, Register } from 'claude-code'
7
8import type { ColorLabReport } from '../types'
9import { analyse, parseColor, reportText, splitArgs } from './lib'
10
11type Dollar = EngineInterface
12
13const COMMAND = 'contrast-check'
14const PANE = 'contrast-check'
15const USAGE = 'Use /contrast-check <text colour> [background], each as #rgb, #rrggbb or rgb(r, g, b). The background defaults to #ffffff.'
16
17const report = atom({ plugin: 'contrast-check', key: 'report' } as const, null)
18
19async function run($: Dollar, args: string): Promise<string> {
20  const words = splitArgs(args)
21  if (words.length === 0 || words.length > 2) return `contrast-check · ${USAGE}`
22  const fg = parseColor(words[0]!)
23  const bg = parseColor(words[1] ?? '#ffffff')
24  if (fg === undefined || bg === undefined) {
25    const bad = (fg === undefined ? words[0] : words[1]) ?? ''
26
27    return `contrast-check · “${bad.slice(0, 40)}” is not a colour I can read.\n${USAGE}`
28  }
29  const r: ColorLabReport = analyse(fg, bg)
30  await update($, report, () => r)
31  const surfaces = await $.session.surfaces().catch(() => [])
32  if (surfaces.length === 0) return reportText(r) // nothing draws a pane: the whole answer as text
33  const opened = await $.ui.open({ id: PANE, title: 'Colour', columns: 56 })
34  if (!opened.isPlaced) return reportText(r)
35  const aa = r.checks[0]!
36
37  return `contrast-check · ${r.fg.hex} on ${r.bg.hex}: ${r.ratioText}:1 · AA normal text ${aa.isPass ? 'passes' : 'fails'}${r.fix ? ` · try ${r.fix.hex}` : ''}`
38}
39
40export const register: Register = on => {
41  on('session.start', async ($, e, next) => {
42    try {
43      await $.command.register({ name: COMMAND, description: 'Contrast of a text colour on a background (WCAG AA/AAA)', argumentHint: '<fg> [bg]' })
44    } catch {
45      // fail-open: the session goes on without the command
46    }
47
48    return next(e)
49  })
50
51  on('command.run', { command: COMMAND }, async ($, e) => ({ text: await run($, e.args) })).catch(() => ({
52    text: 'contrast-check · something went wrong',
53  }))
54
55  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
56    const { Box, Text } = $.ui.resolve(e)
57    const r = await read($, report)
58    if (r === null) return <Text dimColor>{USAGE}</Text>
59    const row = (label: string, c: { hex: string; rgb: string; hsl: string }, key: string) => (
60      <Box key={key}>
61        <Text backgroundColor={c.hex}>{'    '}</Text>
62        <Text> {label} {c.hex} · {c.rgb} · {c.hsl}</Text>
63      </Box>
64    )
65
66    return (
67      <Box flexDirection="column">
68        {row('text      ', r.fg, 'fg')}
69        {row('background', r.bg, 'bg')}
70        <Text key="sample" color={r.fg.hex} backgroundColor={r.bg.hex}>
71          {' The quick brown fox, 0123456789 '}
72        </Text>
73        <Text key="ratio" bold>Contrast {r.ratioText}:1</Text>
74        {r.checks.map(c => (
75          <Text key={`check:${c.name}`} color={c.isPass ? 'green' : 'red'}>
76            {c.isPass ? '✓ pass' : '✗ fail'} {c.name} (needs {c.need}:1)
77          </Text>
78        ))}
79        {r.fix !== null && (
80          <Box key="fix">
81            <Text backgroundColor={r.fix.hex}>{'    '}</Text>
82            <Text> nearest {r.fix.direction} shade that passes AA normal: {r.fix.hex} ({r.fix.ratioText}:1)</Text>
83          </Box>
84        )}
85        <Text dimColor>WCAG 2.x ratio, two decimals rounded down.</Text>
86      </Box>
87    )
88  })
89}
90
hooks/lib.ts 153 lines
1// contrast-check: pure colour arithmetic (no engine access). WCAG 2.x contrast, as in
2// https://www.w3.org/TR/WCAG22/#dfn-contrast-ratio and #dfn-relative-luminance.
3import type { ColorLabColor, ColorLabReport } from '../types'
4
5export type Rgb = { r: number; g: number; b: number }
6
7const hex2 = (n: number): string => n.toString(16).padStart(2, '0')
8
9export const toHex = ({ r, g, b }: Rgb): string => `#${hex2(r)}${hex2(g)}${hex2(b)}`
10
11/** Reads #rgb, #rrggbb or rgb(r, g, b) (0-255 each); undefined for anything else. */
12export function parseColor(raw: string): Rgb | undefined {
13  const text = raw.trim().toLowerCase()
14  let m = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/.exec(text)
15  if (m) return { r: parseInt(m[1]! + m[1]!, 16), g: parseInt(m[2]! + m[2]!, 16), b: parseInt(m[3]! + m[3]!, 16) }
16  m = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/.exec(text)
17  if (m) return { r: parseInt(m[1]!, 16), g: parseInt(m[2]!, 16), b: parseInt(m[3]!, 16) }
18  m = /^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/.exec(text)
19  if (m) {
20    const [r, g, b] = [Number(m[1]), Number(m[2]), Number(m[3])]
21    if (r <= 255 && g <= 255 && b <= 255) return { r, g, b }
22  }
23
24  return undefined
25}
26
27/** Splits the command's arguments into colour words; `rgb( 1, 2, 3 )` stays one word whatever its spaces. */
28export function splitArgs(args: string): string[] {
29  return args.match(/rgb\s*\([^)]*\)|\S+/gi)?.map(word => word.replace(/^rgb\s*\(/i, 'rgb(')) ?? []
30}
31
32/** Hue in degrees (0-360), saturation and lightness as 0-1, not rounded. */
33export function hslOf({ r, g, b }: Rgb): { h: number; s: number; l: number } {
34  const [rr, gg, bb] = [r / 255, g / 255, b / 255]
35  const max = Math.max(rr, gg, bb)
36  const min = Math.min(rr, gg, bb)
37  const l = (max + min) / 2
38  const d = max - min
39  let h = 0
40  let s = 0
41  if (d !== 0) {
42    s = d / (1 - Math.abs(2 * l - 1))
43    if (max === rr) h = 60 * (((gg - bb) / d) % 6)
44    else if (max === gg) h = 60 * ((bb - rr) / d + 2)
45    else h = 60 * ((rr - gg) / d + 4)
46  }
47  if (h < 0) h += 360
48
49  return { h, s, l }
50}
51
52/** Hue in degrees, saturation and lightness in percent, rounded. */
53export function toHsl(rgb: Rgb): { h: number; s: number; l: number } {
54  const { h, s, l } = hslOf(rgb)
55
56  return { h: Math.round(h) % 360, s: Math.round(s * 100), l: Math.round(l * 100) }
57}
58
59/** HSL (h degrees, s and l as 0-1) back to rounded RGB. */
60export function fromHsl(h: number, s: number, l: number): Rgb {
61  const c = (1 - Math.abs(2 * l - 1)) * s
62  const x = c * (1 - Math.abs(((h / 60) % 2) - 1))
63  const m = l - c / 2
64  const [r, g, b] = h < 60 ? [c, x, 0] : h < 120 ? [x, c, 0] : h < 180 ? [0, c, x] : h < 240 ? [0, x, c] : h < 300 ? [x, 0, c] : [c, 0, x]
65  const to = (v: number) => Math.min(255, Math.max(0, Math.round((v + m) * 255)))
66
67  return { r: to(r), g: to(g), b: to(b) }
68}
69
70/** WCAG relative luminance (sRGB). */
71export function luminance({ r, g, b }: Rgb): number {
72  const lin = (v: number) => {
73    const c = v / 255
74
75    return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
76  }
77
78  return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b)
79}
80
81export function contrast(a: Rgb, b: Rgb): number {
82  const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x) as [number, number]
83
84  return (hi + 0.05) / (lo + 0.05)
85}
86
87/** Two decimals, rounded down, so a ratio shown as 4.50 always passes 4.5. */
88export const ratioText = (ratio: number): string => (Math.floor(ratio * 100 + 1e-9) / 100).toFixed(2)
89
90/**
91 * The nearest shade of `fg` (same hue and saturation, lightness moved in 0.5 % steps) that reaches `target` against
92 * `bg`, trying lighter and darker and keeping the smaller change. Undefined when neither direction reaches it.
93 */
94export function nearestPassing(fg: Rgb, bg: Rgb, target: number): { rgb: Rgb; direction: 'lighter' | 'darker' } | undefined {
95  const { h, s, l } = hslOf(fg)
96  const find = (step: number): { rgb: Rgb; dist: number } | undefined => {
97    for (let k = 1; k <= 200; k++) {
98      const next = l + (step * k) / 200
99      if (next < 0 || next > 1) break
100      const rgb = fromHsl(h, s, next)
101      if (contrast(rgb, bg) >= target) return { rgb, dist: k }
102    }
103    const edge = fromHsl(h, s, step > 0 ? 1 : 0)
104
105    return contrast(edge, bg) >= target ? { rgb: edge, dist: 201 } : undefined
106  }
107  const up = find(1)
108  const down = find(-1)
109  if (up === undefined && down === undefined) return undefined
110  if (down === undefined || (up !== undefined && up.dist <= down.dist)) return { rgb: up!.rgb, direction: 'lighter' }
111
112  return { rgb: down.rgb, direction: 'darker' }
113}
114
115export function describe(rgb: Rgb): ColorLabColor {
116  const { h, s, l } = toHsl(rgb)
117
118  return { hex: toHex(rgb), rgb: `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`, hsl: `hsl(${h}, ${s}%, ${l}%)` }
119}
120
121/** The whole answer for `fg` on `bg`. */
122export function analyse(fg: Rgb, bg: Rgb): ColorLabReport {
123  const ratio = contrast(fg, bg)
124  const fix = ratio < 4.5 ? nearestPassing(fg, bg, 4.5) : undefined
125
126  return {
127    fg: describe(fg),
128    bg: describe(bg),
129    ratio,
130    ratioText: ratioText(ratio),
131    checks: [
132      { name: 'AA normal text', need: 4.5, isPass: ratio >= 4.5 },
133      { name: 'AA large text', need: 3, isPass: ratio >= 3 },
134      { name: 'AAA normal text', need: 7, isPass: ratio >= 7 },
135      { name: 'AAA large text', need: 4.5, isPass: ratio >= 4.5 },
136    ],
137    fix: fix === undefined ? null : { ...describe(fix.rgb), direction: fix.direction, ratioText: ratioText(contrast(fix.rgb, bg)) },
138  }
139}
140
141/** The answer as plain text (for the transcript where no pane is drawn). */
142export function reportText(r: ColorLabReport): string {
143  const lines = [
144    `contrast-check · ${r.fg.hex} on ${r.bg.hex}: contrast ${r.ratioText}:1`,
145    `  text        ${r.fg.hex}  ${r.fg.rgb}  ${r.fg.hsl}`,
146    `  background  ${r.bg.hex}  ${r.bg.rgb}  ${r.bg.hsl}`,
147    ...r.checks.map(c => `  ${c.isPass ? 'pass' : 'fail'}  ${c.name} (needs ${c.need}:1)`),
148  ]
149  if (r.fix !== null) lines.push(`  nearest ${r.fix.direction} shade that passes AA normal: ${r.fix.hex} (${r.fix.ratioText}:1)`)
150
151  return lines.join('\n')
152}
153
types/index.d.ts 28 lines
1/** One colour in the three spellings contrast-check shows. */
2export type ColorLabColor = { hex: string; rgb: string; hsl: string }
3
4/** One WCAG 2.x level: the ratio it needs and whether the pair reaches it. */
5export type ColorLabCheck = { name: string; need: number; isPass: boolean }
6
7/** The answer to `/contrast-check <fg> [bg]`. */
8export type ColorLabReport = {
9  fg: ColorLabColor
10  bg: ColorLabColor
11  /** The contrast ratio, unrounded. */
12  ratio: number
13  /** The ratio to two decimals, rounded down. */
14  ratioText: string
15  checks: ColorLabCheck[]
16  /** The nearest lighter or darker shade of fg that passes AA normal text; null when fg already passes. */
17  fix: (ColorLabColor & { direction: 'lighter' | 'darker'; ratioText: string }) | null
18}
19
20declare module 'claude-code' {
21  interface PluginState {
22    'contrast-check': {
23      /** The last answer, or null before the first `/contrast-check`. */
24      report: ColorLabReport | null
25    }
26  }
27}
28