Contrast

WCAG contrast enforcement with automatic correction that preserves your design intent.

WCAG Standards

Nightfall enforces WCAG 2.1 contrast requirements:

  • WCAG AA — The default target. Requires 4.5:1 contrast ratio for normal text and 3:1 for large text (18px+ or 14px+ bold).
  • WCAG AAA — The stricter standard. Requires 7:1 for normal text and 4.5:1 for large text. Use --contrast aaa to target this level.

How Contrast Is Checked

During generation, Nightfall checks every foreground color against the background it was originally observed on. The color graph from the scan phase tells Nightfall which colors sit on which surfaces:

# Contrast checking during generation
# [contrast] --text-heading on --bg-page:     15.2:1 ✓ AAA
# [contrast] --text-body on --bg-page:        10.1:1 ✓ AAA
# [contrast] --text-body on --bg-surface:      9.8:1 ✓ AAA
# [contrast] --text-muted on --bg-page:        4.7:1 ✓ AA
# [contrast] --text-muted on --bg-elevated:    3.8:1 ✗ FAIL
# [contrast] → Auto-fixing --text-muted...

Auto-Fix Algorithm

When a color pair fails contrast, Nightfall adjusts the foreground color's lightness in OKLCH space with the minimum change needed:

contrast-fix.js
function autoFixContrast(foreground, background, targetRatio) {
  const fgOklch = toOKLCH(foreground);
  const bgOklch = toOKLCH(background);

  // Determine direction: lighten or darken the foreground?
  // On dark backgrounds, lighten. On light backgrounds, darken.
  const direction = bgOklch.l < 0.5 ? +1 : -1;

  // Binary search for the minimum lightness adjustment
  let lo = 0, hi = direction === +1 ? (1 - fgOklch.l) : fgOklch.l;
  let bestL = fgOklch.l;

  for (let i = 0; i < 20; i++) {
    const mid = (lo + hi) / 2;
    const testL = fgOklch.l + mid * direction;
    const ratio = contrastRatio(
      { ...fgOklch, l: testL },
      bgOklch
    );

    if (ratio >= targetRatio) {
      bestL = testL;
      hi = mid; // Try less adjustment
    } else {
      lo = mid; // Need more adjustment
    }
  }

  // Hue and chroma are unchanged
  return { l: bestL, c: fgOklch.c, h: fgOklch.h };
}

Minimal Perceptual Change

The auto-fix always produces the smallest possible visual change:

  • Only lightness changes — Hue and chroma are never modified. The color keeps its character.
  • Binary search precision — 20 iterations of binary search find the exact threshold to within 0.001 L units.
  • Direction-aware — On dark backgrounds, text gets lighter. On light backgrounds, text gets darker. Always moving away from the background.

Configuration

# Target WCAG AA (default)
npx nightfall-css generate --format css-variables --contrast aa

# Target WCAG AAA
npx nightfall-css generate --format css-variables --contrast aaa

# Disable auto-fix (just report failures)
npx nightfall-css generate --format css-variables --no-auto-fix
nightfall.config.js
module.exports = {
  contrast: {
    target: 'aa',        // 'aa' or 'aaa'
    autoFix: true,       // Auto-adjust failing pairs
    reportOnly: false,   // If true, warn but don't fix
    ignore: [            // Skip contrast check for these pairs
      'border-*',        // Borders don't need text contrast
      'shadow-*',        // Shadows don't need contrast
    ]
  }
};

Large Text Exception

WCAG allows lower contrast for large text (18px regular or 14px bold). Nightfall detects text size from the scan data and applies the appropriate threshold:

# Large text gets a lower threshold
# [contrast] --text-heading (24px) on --bg-surface: 3.2:1 ✓ AA (large text: 3:1)
# [contrast] --text-body (16px) on --bg-surface:    3.2:1 ✗ FAIL (normal text: 4.5:1)