Functional Weave
Code in Python

charts.contrast

WCAG 2.x contrast ratio between two colours, and which text colour reads best on a background.

1.0.0 · published 2026-10-03 by charlie · Anterra

Pinned by 22 tests, run in TypeScript, Python and Rust.contrastRatio 13 · readableTextColor 9

What it does

`contrastRatio` is the WCAG 2.x contrast ratio between two colours, from 1 (identical) to 21 (black on white). `readableTextColor` picks, from the candidates you offer (usually black and white), the one that contrasts most with a background: the label colour for a bar, a pie slice or a heatmap cell.

## The formula

The functions

A group: 2 functions that work together, each in its own file, each pinned by its own tests in TypeScript, Python and Rust. A project can install only the ones it calls.

  1. contrast_ratio (foreground: string, background: string) -> float
  2. readable_text_color (background: string, candidates: string[]) -> string

Once installed, your code imports each one from the group's module.

contrast_ratio throws on bad input 13 tests

def contrast_ratio(foreground: str, background: str) -> float
foregroundstringhex colour; the order of the two does not matter
backgroundstringhex colour
returnsfloat1 to 21, rounded to 6 decimal places; WCAG AA needs 4.5 for body text

For example

  • contrast_ratio(#000000, #ffffff) → 21 black on white is the maximum, 21
  • contrast_ratio(#ffffff, #000000) → 21 the order of the colours does not matter
  • contrast_ratio(#ffffff, #FFF) → 1 a colour against itself is 1
from fune.charts.contrast import contrast_ratio  # charts.contrast@^1
impl/python/contrast_ratio.py · 21 lines · open · raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

from .charts_color_parse_hex import parse_hex
from .charts_color_srgb_to_linear import srgb_to_linear
from .math_round_float import round_float  ← from math.round-float ^1.0.0 · built alongside by fune


def relative_luminance(hex: str) -> float:  # noqa: A002
    """WCAG 2.x relative luminance: linear-light R, G, B weighted 0.2126, 0.7152, 0.0722."""
    c = parse_hex(hex)
    return 0.2126 * srgb_to_linear(c.r) + 0.7152 * srgb_to_linear(c.g) + 0.0722 * srgb_to_linear(c.b)


def luminance_contrast(a: str, b: str) -> float:
    """(lighter + 0.05) / (darker + 0.05), unrounded."""
    la = relative_luminance(a)
    lb = relative_luminance(b)
    return (la + 0.05) / (lb + 0.05) if la > lb else (lb + 0.05) / (la + 0.05)


def contrast_ratio(foreground: str, background: str) -> float:
    """The WCAG 2.x contrast ratio, 1 to 21, rounded to 6 places."""
    return round_float(luminance_contrast(foreground, background), 6)

readable_text_color throws on bad input 9 tests

def readable_text_color(background: str, candidates: Sequence[str]) -> str
backgroundstring
candidatesstring[]hex colours to choose from, usually ["#000000", "#ffffff"]
returnsstringthe candidate with the highest contrast, the first on a tie, exactly as given

For example

  • readable_text_color(#0072b2, #000000, #ffffff) → #ffffff white text on dark blue
  • readable_text_color(#e69f00, #000000, #ffffff) → #000000 black text on orange
  • readable_text_color(#808080, #ffffff, #000000) → #000000 black on mid grey #808080, which a 50% lightness rule gets wrong
from fune.charts.contrast import readable_text_color  # charts.contrast@^1
impl/python/readable_text_color.py · 17 lines · open · raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

from typing import Sequence

from .charts_contrast_contrast_ratio import luminance_contrast  ← contrastRatio, another function of this group · built into the same file, even by a slim install


def readable_text_color(background: str, candidates: Sequence[str]) -> str:
    """The candidate that contrasts most with the background; ties go to the earlier one."""
    if len(candidates) == 0:
        raise ValueError("candidates must not be empty")
    best = candidates[0]
    best_ratio = luminance_contrast(best, background)
    for candidate in candidates[1:]:
        ratio = luminance_contrast(candidate, background)
        if ratio > best_ratio:
            best = candidate
            best_ratio = ratio
    return best

Install

fune build

With that line in your source, in a Python project (language python in fune.project), fune build resolves it and its 2 dependencies, pins them in fune.lock, downloads only the Python package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. Or pin a range in fune.project and build in one step:

fune add charts.contrast

That builds the whole group. To build only what you call, and whatever it uses inside the group:

fune add charts.contrast --only contrastRatio
Download for Python charts.contrast-1.0.0-python.fune · 9,745 bytes sha256 6b74e81402679ae224feb50ff4276c50a9ecc75fdca2aeaec5b47513a9aca089

The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./charts.contrast-1.0.0-python.fune, or fetch it from a terminal with fune pull charts.contrast@1.0.0:python.

The whole function, every language, is one file too: charts.contrast-1.0.0.fune, 13,998 bytes, sha256 8df0a1cc226f71b52191a6179462ce49d8e1b679809122fe20ee84efe740755b. It installs into a project of any language.

Customise it in your app

The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.

before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.

# fune: before charts.contrast.contrastRatio
# fune: before charts.contrast.readableTextColor

after — your function gets the result and the arguments, and returns the final result.

# fune: after charts.contrast.contrastRatio
# fune: after charts.contrast.readableTextColor

replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.

# fune: replace charts.color in charts.contrast
# fune: replace math.round-float in charts.contrast

step — your function runs at a numbered point inside a function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show charts.contrast --steps.

# fune: step charts.contrast.<fn> after <n|label>

Tests

A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.

contrastRatio 13 tests

CaseArgumentsExpected
black on white is the maximum, 21 #000000, #ffffff → 21
the order of the colours does not matter #ffffff, #000000 → 21
a colour against itself is 1 #ffffff, #FFF → 1
#777777 on white just fails AA at 4.48 #777777, #ffffff → 4.478
#767676 on white just passes AA at 4.54 #767676, #ffffff → 4.542
#595959 on white passes AAA at 7.0 #595959, #ffffff → 7.005
pure red on white is under 4, despite looking strong #ff0000, #ffffff → 3.998
mid grey against black #808080, #000000 → 5.317
mid grey against white is lower: luminance is linear light, not channel numbers #808080, #ffffff → 3.949
Okabe-Ito orange against black #e69f00, #000000 → 9.324
Show the other 3 tests
CaseArgumentsExpected
Okabe-Ito blue against white #0072b2, #ffffff → 5.185
a near-black on the straight part of the sRGB curve #0a0a0a, #000 → 1.061
a bad colour is an error #12345, #ffffff → error: is not a hex colour

readableTextColor 9 tests

CaseArgumentsExpected
white text on dark blue #0072b2, #000000, #ffffff → #ffffff
black text on orange #e69f00, #000000, #ffffff → #000000
black on mid grey #808080, which a 50% lightness rule gets wrong #808080, #ffffff, #000000 → #000000
black on vermillion (5.4 against 3.9 for white) #d55e00, #ffffff, #000000 → #000000
white on black #000, #000000, #ffffff → #ffffff
a tie goes to the first candidate, returned as given #ffffff, #000, #000000 → #000
one candidate is returned whatever its contrast #ffffff, #eeeeee → #eeeeee
three candidates, the dark grey wins on yellow #f0e442, #ffffff, #333333, #0072b2 → #333333
no candidates is an error #ffffff, → error: candidates must not be empty

More from the author

Relative luminance L = 0.2126 R + 0.7152 G + 0.0722 B, where R, G and B are the channels converted to linear light by the sRGB transfer function (`charts.color`'s `srgbToLinear`). The ratio is (L1 + 0.05) / (L2 + 0.05) with L1 the lighter of the two, so the order of the arguments does not matter.

WCAG 2.0's text gives the linearisation threshold as 0.03928; the sRGB standard, and WCAG 2.2's note, say 0.04045. No 8-bit channel lies between them (10/255 = 0.0392 and 11/255 = 0.0431), so for hex colours the two agree exactly.

Because luminance is linear light, intuition from the channel numbers misleads: pure red on white is only 4.0:1, and mid grey `#808080` contrasts more with black (5.3:1) than with white (3.9:1), so a "lightness below 50% means white text" rule picks the wrong colour.

## Rounding and thresholds

`contrastRatio` is rounded to 6 decimal places. WCAG says the ratio must not be rounded up to meet a threshold: `#777777` on white is 4.478089, which fails AA (4.5 for body text, 3 for large text; AAA needs 7 and 4.5). Compare the result directly; do not round it to one place first. `readableTextColor` compares unrounded ratios, and a tie goes to the earlier candidate, which is returned exactly as written.

Sources: W3C, Web Content Accessibility Guidelines (WCAG) 2.2, definitions of "contrast ratio" and "relative luminance", and Success Criteria 1.4.3 and 1.4.6 (https://www.w3.org/TR/WCAG22/); IEC 61966-2-1 (sRGB).

Files

PathBytes
README.md1,789
impl/python/contrast_ratio.py874
impl/python/readable_text_color.py630
impl/rust/contrast_ratio.rs1,094
impl/rust/readable_text_color.rs980
impl/typescript/contrast_ratio.ts1,156
impl/typescript/readable_text_color.ts751
vectors.json3,034