Skip to content

About

Accessible colours from one brand colour: WCAG and APCA contrast, nearest passing colour in OKLCH, 50-950 palettes, and a check of every text colour on a real page.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

1 Commit

Folders and files

Repository files navigation

contrast-palette

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 and var() 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).

Measured result

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.

CLI

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.

Limitations

  • 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/:focus states 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.

Author

Marvin Palencia, founder of DeMark Studio, Miramichi, New Brunswick, Canada. Portfolio: marvin.demarkstudio.ca

MIT License.

About

Accessible colours from one brand colour: WCAG and APCA contrast, nearest passing colour in OKLCH, 50-950 palettes, and a check of every text colour on a real page.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages