Skip to Content
Accessibility & i18nContrast checking

Contrast checking (WCAG 2.1)

FrankenTUI computes WCAG 2.1 contrast ratios directly from the theme’s packed RGBA color values. The ratios drive:

  • the live WCAG checker on the accessibility_panel demo screen,
  • the high-contrast theme toggle that flips palettes when ratios drop below AA,
  • and the automated contrast tests that gate regressions during theme work.

The math is the standard WCAG 2.1 §1.4.3 formula: linearize sRGB, compute relative luminance, then take (lighter + 0.05) / (darker + 0.05). The implementation lives at crates/ftui-demo-showcase/src/screens/accessibility_panel.rs:87-106, with the usage elsewhere in the same screen.

The formula

Given two colors in sRGB, each stored as PackedRgba (the render cell color type):

Step 1 — normalize each channel to [0, 1]

let r = (rgba.r() as f32) / 255.0; let g = (rgba.g() as f32) / 255.0; let b = (rgba.b() as f32) / 255.0;

Step 2 — linearize gamma

sRGB is gamma-encoded. WCAG requires computing luminance against linear light:

fn linearize(v: f32) -> f32 { if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) } }

The two-piece function matches the sRGB transfer curve exactly.

Step 3 — relative luminance

Weighted by the CIE Y coefficients:

fn luminance(c: PackedRgba) -> f32 { let r = linearize(c.r() as f32 / 255.0); let g = linearize(c.g() as f32 / 255.0); let b = linearize(c.b() as f32 / 255.0); 0.2126 * r + 0.7152 * g + 0.0722 * b }

Step 4 — the ratio

let l1 = luminance(fg); let l2 = luminance(bg); let (hi, lo) = if l1 >= l2 { (l1, l2) } else { (l2, l1) }; let ratio = (hi + 0.05) / (lo + 0.05);

(x + 0.05) pushes pure black off zero so the ratio is bounded. The result is a unitless float in [1.0, 21.0]1.0 is no contrast, 21.0 is pure black on pure white.

The WCAG rating thresholds

fn wcag_rating(ratio: f32) -> (&'static str, Style) { if ratio >= 7.0 { ("AAA", Style::new().fg(theme::accent::SUCCESS)) } else if ratio >= 4.5 { ("AA", Style::new().fg(theme::accent::INFO)) } else if ratio >= 3.0 { ("AA Large", Style::new().fg(theme::accent::WARNING)) } else { ("Fail", Style::new().fg(theme::accent::ERROR)) } }
RatioRatingMeaning
≥ 7.0AAANormal text, enhanced level.
≥ 4.5AANormal text, minimum level.
≥ 3.0AA LargeText ≥ 18 pt / 14 pt bold.
< 3.0FailDoes not meet WCAG contrast.

The thresholds are the ones the WCAG 2.1 specification defines. The palette mapping (SUCCESS, INFO, WARNING, ERROR) is the same semantic color scheme used across the rest of the demo and can be re-skinned without touching the math.

The accessibility panel screen

The accessibility_panel screen wires the math up to a live demonstration. The layout is two columns:

  • Left — toggles, driven by single-key shortcuts:
    • hHigh Contrast theme toggle.
    • mReduced Motion (disables animations).
    • lLarge Text scaling.
    • Shift+A — open a compact a11y overlay.
  • Right — WCAG checker, listing every foreground-background pair in the current theme, their computed ratio, and the rating badge.

Toggling high-contrast flips the palette and the checker instantly recomputes. A failing pair shown as Fail is a visible bug — the theme has to be fixed.

Telemetry: A11yTelemetryEvent

Each toggle pushes an A11yTelemetryEvent onto a per-screen queue (MAX_EVENTS = 6) so the demo can display the last few events inline:

struct A11yTelemetryEvent { kind: A11yEventKind, tick: u64, high_contrast: bool, reduced_motion: bool, large_text: bool, }

Nothing about the events is WCAG-specific — they are just the screen’s way of showing that toggle changes propagate through the runtime’s state and back out to the accessibility tree.

Where to compute contrast in your own code

The linearization and luminance math is not a public ftui-a11y API yet; it lives in the demo screen because that is the only in-tree consumer. If you are writing a theme and want to gate it on contrast:

  1. Borrow the three short functions (linearize, luminance, contrast_ratio) into your own module, or
  2. Add them to ftui-style as part of theme validation.

A proper home on ftui-style is the right long-term answer — see the discussion on Style — color. Until then, the demo screen is the canonical reference.

Example — manual check of a theme pair

use ftui_render::cell::PackedRgba; fn contrast(fg: PackedRgba, bg: PackedRgba) -> f32 { // … the four steps from above … } let text_on_bg = contrast( PackedRgba::from_u32(0xFFE6E1E5), // on-surface PackedRgba::from_u32(0xFF1C1B1F), // surface ); assert!(text_on_bg >= 4.5, "theme violates AA for normal text");

If you want this kind of check in CI, add it as a unit test next to the theme definition — that way a future palette edit that regresses contrast fails the test instead of shipping a failing theme.

Pitfalls

  • Do not use the raw sRGB channels for luminance. The gamma decode step is load-bearing. Skipping it yields a value that is plausible but systematically wrong — dark colors read as too-contrasting, light colors as too-flat.
  • Do not average the two colors instead of taking the ratio. WCAG is a ratio, not a delta. |L1 − L2| and (L1 + 0.05) / (L2 + 0.05) do not correlate well near the middle of the range.
  • Do not round the ratio before thresholding. 3.0 - epsilon is failing; rounding to 3.0 pretends it is passing. Compare the float against the threshold directly.
  • Do not confuse “AAA” with accessibility. AAA contrast is necessary, not sufficient: a screen can pass AAA while being unreadable for a thousand other reasons (no headings, no focus ring, assertive live region spam). Contrast is one axis in a wider accessibility tree story.

See also