Functional Weave
Code in Rust

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

pub fn contrast_ratio(foreground: &str, background: &str) -> f64
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
fune!(charts.contrast@^1);  // then call contrast_ratio(…)
impl/rust/contrast_ratio.rs · 33 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.

use super::funejson::Value;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::charts_color_parse_hex::parse_hex;
use super::charts_color_srgb_to_linear::srgb_to_linear;
use super::math_round_float::round_float;  ← from math.round-float ^1.0.0 · built alongside by fune

/// WCAG 2.x relative luminance: linear-light R, G, B weighted 0.2126, 0.7152, 0.0722.
pub fn relative_luminance(hex: &str) -> f64 {
    let c = parse_hex(hex);
    0.2126 * srgb_to_linear(c.r) + 0.7152 * srgb_to_linear(c.g) + 0.0722 * srgb_to_linear(c.b)
}

/// (lighter + 0.05) / (darker + 0.05), unrounded.
pub fn luminance_contrast(a: &str, b: &str) -> f64 {
    let la = relative_luminance(a);
    let lb = relative_luminance(b);
    if la > lb {
        (la + 0.05) / (lb + 0.05)
    } else {
        (lb + 0.05) / (la + 0.05)
    }
}

/// The WCAG 2.x contrast ratio, 1 to 21, rounded to 6 places.
///
/// # Panics
/// Panics if either colour is not a hex colour.
pub fn contrast_ratio(foreground: &str, background: &str) -> f64 {
    round_float(luminance_contrast(foreground, background), 6)
}

pub fn fune_vector(args: &[Value]) -> Value {
    Value::Float(contrast_ratio(args[0].as_str(), args[1].as_str()))
}

readable_text_color throws on bad input 9 tests

pub fn readable_text_color(background: &str, candidates: &[String]) -> String
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
fune!(charts.contrast@^1);  // then call readable_text_color(…)
impl/rust/readable_text_color.rs · 27 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.

use super::funejson::Value;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::charts_contrast_contrast_ratio::luminance_contrast;  ← contrastRatio, another function of this group · built into the same file, even by a slim install

/// The candidate that contrasts most with the background; ties go to the earlier one.
///
/// # Panics
/// Panics if there are no candidates or a colour is not a hex colour.
pub fn readable_text_color(background: &str, candidates: &[String]) -> String {
    if candidates.is_empty() {
        panic!("candidates must not be empty");
    }
    let mut best = &candidates[0];
    let mut best_ratio = luminance_contrast(best, background);
    for candidate in &candidates[1..] {
        let ratio = luminance_contrast(candidate, background);
        if ratio > best_ratio {
            best = candidate;
            best_ratio = ratio;
        }
    }
    best.clone()
}

pub fn fune_vector(args: &[Value]) -> Value {
    let candidates: Vec<String> = args[1].as_arr().iter().map(|v| v.as_str().to_string()).collect();
    Value::str(&readable_text_color(args[0].as_str(), &candidates))
}

Install

fune build

With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 2 dependencies, pins them in fune.lock, downloads only the Rust 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. A crate’s build.rs runs it before every compile. 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 Rust charts.contrast-1.0.0-rust.fune · 10,307 bytes sha256 e20dafcce9e90e26589609ef387a55f6bc98ee52d8ee89569f2716e534f1c14f

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

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