Check colour contrast the way WCAG 2.x defines it (with APCA Lc alongside), fix failing colours without changing their hue, and build OKLCH palettes whose text colours are already checked. One small package, standard library only.
from contrast_palette import parse_color, contrast_ratio, apca_contrast, fix, generate
fg, bg = parse_color("#2b6a9f"), parse_color("#bfe8d4")
contrast_ratio(fg, bg) # 4.286... (fails AA for normal text, needs 4.5)
apca_contrast(fg, bg) # 59.41... (APCA Lc, text on background)
fix(fg, bg, wcag=4.5).hex # '#27669b' same hue, just dark enough
generate(fg, "azul")[600].text # readable text colour for the 600 swatch- WCAG 2.x: relative luminance and contrast ratio, with verdicts for AA and AAA (normal and large text) and 3:1 for UI components.
- APCA Lc: the APCA-W3 0.1.9 formula (SAPC-APCA, github.com/Myndex/apca-w3), with its published constants and reference values in the tests. APCA is a candidate method for a future WCAG 3; it is not a WCAG 2 requirement, so pass/fail here is always the WCAG 2 ratio unless you add
--apca. - Any CSS colour:
#rgb,#rrggbb,#rrggbbaa(alpha is composited over the background),rgb()/rgba(),hsl(),hwb(),oklch(),oklab(),color(srgb ...),color-mix()and all 148 named colours. - OKLab / OKLCH (Bjorn Ottosson), with out-of-gamut colours brought into sRGB by reducing chroma at constant lightness and hue.
fix: keeps the OKLCH hue, binary-searches the lightness for the nearest colour that meets a WCAG ratio (default 4.5) and/or an APCA Lc, and reduces chroma when needed to stay in gamut. The returned hex code is re-checked after rounding.palette: 11 steps, 50 to 950, in OKLCH from one brand colour. The input is kept exactly at its nearest step. Each swatch gets a text colour (the palette's own 950/50 when it reaches 4.5:1, otherwise black/white) with its ratio and Lc.check-css: a stylesheet (colour and background per rule, custom properties andvar()resolved) or a whole HTML page (the colour and painted background behind every visible run of text, large text detected from font size and weight).- Outputs: text, Markdown, JSON, CSS custom properties, design tokens (W3C DTCG
$type/$value) and a self-contained HTML swatch page (inline CSS, no JavaScript).
My two sites, demarkstudio.ca and marvin.demarkstudio.ca. fetch downloads each page and every same-host stylesheet it links, in order, one second apart (five requests), and the checks run on the saved copies:
$ python -m contrast_palette fetch https://demarkstudio.ca/en/ --out .cache/sites
GET https://demarkstudio.ca/en/ -> .cache\sites\demarkstudio.ca_en.html (76122 chars)
GET https://demarkstudio.ca/static/dist/sitio-c5ce2cfe5edd.css -> .cache\sites\demarkstudio.ca_static_dist_sitio-c5ce2cfe5edd.css (74133 chars)
GET https://demarkstudio.ca/static/dist/borrador-721475c70300.css -> .cache\sites\demarkstudio.ca_static_dist_borrador-721475c70300.css (19571 chars)
$ python -m contrast_palette fetch https://marvin.demarkstudio.ca/ --out .cache/sites
GET https://marvin.demarkstudio.ca/ -> .cache\sites\marvin.demarkstudio.ca.html (25251 chars)
GET https://marvin.demarkstudio.ca/styles.css -> .cache\sites\marvin.demarkstudio.ca_styles.css (7476 chars)
demarkstudio.ca passes: every visible text run on the page, with the colour and the background the browser really paints:
$ python -m contrast_palette check-css .cache/sites/demarkstudio.ca_en.html --css .cache/sites/demarkstudio.ca_static_dist_sitio-c5ce2cfe5edd.css --css .cache/sites/demarkstudio.ca_static_dist_borrador-721475c70300.css --colors --fix
.cache/sites/demarkstudio.ca_en.html + .cache/sites/demarkstudio.ca_static_dist_sitio-c5ce2cfe5edd.css + .cache/sites/demarkstudio.ca_static_dist_borrador-721475c70300.css (light scheme)
296 visible text run(s), 55 distinct pair(s); 0 on image/gradient backgrounds not checked
most used colours:
#f8fbfd x241
#43556a x126 (chroma 0.04, hue 253)
#16232e x98
#2b6a9f x66 (chroma 0.11, hue 247)
#eaf3f9 x36
#ffffff x13
#bfe8d4 x6 (chroma 0.05, hue 164)
#5b6d80 x5 (chroma 0.04, hue 250)
0 failing pair(s)
A lesson from building it: the first version fetched only the first stylesheet, and reported the "Ask the advisor" button as blue #2b6a9f on the pastel green band (4.29:1, FAIL). The page's second stylesheet overrides that button to #16232e (11.95:1), which is what Chromium computes too. A false alarm on a live site is worse than none, so fetch now takes every same-host stylesheet and applies them in document order, with a test for exactly this case.
Checked rule by rule, the same stylesheet also has two icon rules below 4.5:1. They are icons, not text, and pass the 3:1 UI threshold:
$ python -m contrast_palette check-css .cache/sites/demarkstudio.ca_static_dist_sitio-c5ce2cfe5edd.css --fix
.cache/sites/demarkstudio.ca_static_dist_sitio-c5ce2cfe5edd.css
745 rule(s), 23 custom propert(ies) on :root, 35 colour/background pair(s), 6 skipped
fg bg WCAG need APCA Lc result fix where
#16a34a #dcfce7 3.00:1 4.5 +53.3 FAIL #008237 .exito .icono-grande
#4f6df5 #e8ecfe 3.69:1 4.5 +58.6 FAIL #435ee4 .tarjeta .icono
2 of 2 pair(s) fail
$ python -m contrast_palette check-css .cache/sites/demarkstudio.ca_static_dist_sitio-c5ce2cfe5edd.css --level ui
.cache/sites/demarkstudio.ca_static_dist_sitio-c5ce2cfe5edd.css
745 rule(s), 23 custom propert(ies) on :root, 35 colour/background pair(s), 6 skipped
0 failing pair(s)
marvin.demarkstudio.ca passes in both colour schemes:
$ python -m contrast_palette check-css .cache/sites/marvin.demarkstudio.ca.html --css .cache/sites/marvin.demarkstudio.ca_styles.css --colors
.cache/sites/marvin.demarkstudio.ca.html + .cache/sites/marvin.demarkstudio.ca_styles.css (light scheme)
188 visible text run(s), 37 distinct pair(s); 0 on image/gradient backgrounds not checked
most used colours:
#ffffff x125
#56616c x84
#1f7a57 x53 (chroma 0.10, hue 163)
#111820 x51
#f7f5f0 x45
#dff1e8 x18
0 failing pair(s)
$ python -m contrast_palette check-css .cache/sites/marvin.demarkstudio.ca.html --css .cache/sites/marvin.demarkstudio.ca_styles.css --scheme dark --colors
.cache/sites/marvin.demarkstudio.ca.html + .cache/sites/marvin.demarkstudio.ca_styles.css (dark scheme)
188 visible text run(s), 37 distinct pair(s); 0 on image/gradient backgrounds not checked
most used colours:
#16212c x123
#a3b0bc x84
#7fd1ae x53 (chroma 0.10, hue 165)
#eef1f3 x51
#0f1720 x47
#1b3a30 x18 (chroma 0.04, hue 171)
0 failing pair(s)
Palettes from the brand colours, as CSS, tokens and HTML (examples/):
$ python -m contrast_palette palette "#2b6a9f" "#2a7a5a" --name azul --name verde --css examples/demarkstudio-palette.css --json examples/demarkstudio-tokens.json --html examples/demarkstudio-swatches.html --title "demarkstudio.ca: brand palette and fixes"
wrote examples/demarkstudio-palette.css
wrote examples/demarkstudio-tokens.json
wrote examples/demarkstudio-swatches.html
$ python -m contrast_palette palette "#2b6a9f" --name azul
azul: base #2b6a9f (oklch 0.509 0.106 247.3) anchored at 600
50 #f0f8ff oklch(97.5% 0.013 244.3) text #131f2b (950 ) 15.55:1 Lc +98.7
100 #daedff oklch(93.8% 0.032 246.7) text #131f2b (950 ) 13.94:1 Lc +91.5
200 #b7dcff oklch(88.0% 0.062 247.1) text #131f2b (950 ) 11.68:1 Lc +80.7
300 #8fc3f4 oklch(79.9% 0.089 247.5) text #131f2b (950 ) 8.96:1 Lc +66.4
400 #65a3db oklch(69.6% 0.105 247.2) text #131f2b (950 ) 6.21:1 Lc +49.5
500 #4485be oklch(59.9% 0.110 247.3) text #000000 (black) 5.34:1 Lc +37.6
600 #2b6a9f oklch(50.9% 0.106 247.3) text #f0f8ff (50 ) 5.34:1 Lc -78.3 <- base
700 #1d5584 oklch(43.7% 0.097 247.8) text #f0f8ff (50 ) 7.29:1 Lc -86.2
800 #154268 oklch(36.9% 0.082 247.9) text #f0f8ff (50 ) 9.73:1 Lc -92.6
900 #13324c oklch(30.8% 0.060 246.7) text #f0f8ff (50 ) 12.33:1 Lc -97.2
950 #131f2b oklch(23.4% 0.029 249.1) text #f0f8ff (50 ) 15.55:1 Lc -100.6
$ python -m contrast_palette palette "#1f7a57" --name accent --css examples/marvin-palette.css --json examples/marvin-tokens.json --html examples/marvin-swatches.html --title "marvin.demarkstudio.ca: accent palette"
wrote examples/marvin-palette.css
wrote examples/marvin-tokens.json
wrote examples/marvin-swatches.html
$ python -m pytest -q -p no:cacheprovider --import-mode=importlib contrast-palette
94 passed in 6.88s
Note the 500 swatch: black text passes WCAG (5.34:1) while APCA rates it low (Lc 37.6), and white text there scores better on APCA (Lc -72.0) but fails WCAG (3.93:1). Mid-tone blues are where the two methods disagree most; the tool follows WCAG 2 and shows Lc so you can see it.
python -m contrast_palette check "#777 on #fff" "#888, #fff" [--file pairs.txt] [--large] [--level aa|aa-large|aaa|aaa-large|ui] [--apca 60] [--fix] [--format text|md|json|html] [-o FILE]
python -m contrast_palette fix COLOR --bg BG [--wcag 4.5] [--apca 60]
python -m contrast_palette palette COLOR [COLOR ...] [--name NAME ...] [--fix "FG on BG"] [--css F] [--json F] [--html F] [--format text|md|css|json|html]
python -m contrast_palette check-css FILE_OR_URL [--css FILE ...] [--scheme light|dark] [--all] [--colors] [--fix] [--save-dir DIR]
python -m contrast_palette fetch URL --out DIR
python -m contrast_palette info COLOR [--bg BG]
check and check-css exit with 1 when a pair fails, so they can run in CI. Given a URL, check-css makes one GET for the page and, one second later, one GET for its first same-host stylesheet, with the User-Agent contrast-palette/1.0 (+https://github.com/dreadmoreeee/contrast-palette). Installed with pip install ., the command is also available as contrast-palette.
- The page check is a model of a browser, not a browser: a 1280 px screen, simple selectors (tag, class, id, attribute,
:root,:not(), descendant and child combinators) and no JavaScript.:hover/:focusstates are checked only by the stylesheet mode. Text over images or gradients is counted and reported, not guessed. - Semi-transparent colours are composited in sRGB like browsers; a translucent background with nothing behind it is placed on white.
- Luminance uses the sRGB threshold 0.04045 (WCAG 2 prints 0.03928; the results are identical for 8-bit colours).
- APCA values are for reference and design decisions; they do not show WCAG 2 conformance.
Marvin Palencia, founder of DeMark Studio, Miramichi, New Brunswick, Canada. Portfolio: marvin.demarkstudio.ca
MIT License.