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.

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.
/contrast-check <fg> [bg] reads each colour as #rgb, #rrggbb or rgb(r, g, b). The background defaults to #ffffff./contrast-check #777 grey text on white /contrast-check #1a2b3c #f5f5f5 /contrast-check rgb(200, 30, 30) #000
Close the pane, or /plugin disable contrast-check. Nothing runs until you call it.
{ plugin: 'contrast-check', key: 'report' }: the last answer (fg, bg, ratio, ratioText, checks, fix), or null. Nothing is kept in $.store.
red), no alpha, no hsl() input.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.
hooks/register.tsx 90 lines1// 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}
90hooks/lib.ts 153 lines1// 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}
153types/index.d.ts 28 lines1/** 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