Computing contrast colors without dart-sass

Back in 2021 I wrote about dynamic CSS color themes with similar contrasts, where I used a SCSS function to automatically adjust colors to meet the WCAG 7:1 contrast ratio against both a light and a dark background. The idea was that you specify the intent — the hue you want — and the build system derives the actual color that achieves sufficient contrast.

That worked well, but it required dart-sass. Hugo’s built-in LibSass was removed in Hugo 0.128, leaving dart-sass as the only SCSS transpiler. Getting dart-sass into the Nix build required a symlinkJoin bundle that wrapped Hugo with dart-sass on its PATH — not terrible, but an extra moving part in both devshell.nix and packages/default.nix.

The thing is: the contrast computation only needs to run once, when the colors change. Not at every build. So I moved the computation out of SCSS and into a small Python script, storing the results as plain hex values in config.yaml. Hugo then reads those values like any other config param.

The computation

The WCAG contrast formula requires converting sRGB values to linear light values first, using this linearisation:

def _lin(c):
    c /= 255
    return c / 12.92 if c < 0.04045 else ((c + 0.055) / 1.055) ** 2.4

From there, relative luminance is a weighted sum of the three channels:

def _lum(r, g, b):
    return 0.2126 * _lin(r) + 0.7152 * _lin(g) + 0.0722 * _lin(b)

The contrast ratio between two colors is then:

def _contrast(bg, fg):
    lb, lf = _lum(*bg) + 0.05, _lum(*fg) + 0.05
    return max(lb, lf) / min(lb, lf)

To find a color that meets a target contrast, the script iterates in HSL space — darkening or lightening the foreground until the contrast lands in the target range (7.0–7.1):

def contrast_color(fg_hex, bg_hex, min_contrast=7.0):
    fg = list(_parse(fg_hex))
    bg = list(_parse(bg_hex))
    max_contrast = min_contrast + 0.1
    is_dark_bg = _lum(*bg) < 0.5
    c = _contrast(bg, fg)
    for _ in range(300):
        if min_contrast <= c <= max_contrast:
            break
        hue, lit, sat = colorsys.rgb_to_hls(
            fg[0] / 255, fg[1] / 255, fg[2] / 255
        )
        delta = abs(min_contrast - c) * 0.01
        if (is_dark_bg and c > max_contrast) or \
           (not is_dark_bg and c < min_contrast):
            lit = max(0.0, lit - delta)
        else:
            lit = min(1.0, lit + delta)
        r2, g2, b2 = colorsys.hls_to_rgb(hue, lit, sat)
        fg = [r2 * 255, g2 * 255, b2 * 255]
        c = _contrast(bg, fg)
    return _hex(*fg)

This is the same algorithm that was in the old SCSS, just in Python. The script is wrapped as a Nix derivation using pkgs.writers.writePython3Bin with ruamel-yaml as its only dependency.

The config structure

config.yaml now has two sections under params:

params:
  # Edit these, then run: just compute-colors
  colors:
    light-background-color: '#F4F4F4'
    light-link-intent: '#00B0FB'      # hue you want
    light-link-visited-intent: '#FF047E'
    dark-background-color: '#171717'
    dark-link-intent: '#00B0FB'
    dark-link-visited-intent: '#FF147E'
    # ...

  # Auto-generated by compute-colors (do not edit)
  style:
    light-background-color: '#F4F4F4'
    light-link-default-color: '#00597F'   # adjusted for 7:1 contrast
    light-link-visited-color: '#A50050'
    dark-link-default-color: '#00ADF6'    # same hue, different lightness
    dark-link-visited-color: '#FF70B0'
    # ...

The colors section is what you edit. The style section is what Hugo reads. Running just compute-colors rewrites params.style in place using ruamel-yaml, which preserves comments and formatting in the rest of the file.

CSS without SCSS

Without dart-sass, the stylesheet is a plain CSS file processed as a Hugo template via resources.ExecuteAsTemplate. It injects site.Params.style values with Go template syntax:

{{ $s := site.Params.style -}}
:root {
    --background-color: {{ index $s "light-background-color" }};
    --link-default-color: {{ index $s "light-link-default-color" }};
    --font-size: {{ index $s "font-size" }}px;
}

@media only screen and (max-width: {{ mul (index $s "page-content-width-factor") (index $s "font-size") }}px) {
    /* ... */
}

CSS native nesting (supported in Chrome 112+, Firefox 117+, Safari 17.2+) replaces SCSS nesting, so the structure of the stylesheet is unchanged. FontAwesome switched from SCSS to the pre-compiled CSS files from the same release zip, served as static files.

Validation in CI

To make sure the computed colors are never out of sync with the intent colors, the script has a --validate flag:

$ compute-colors --validate config.yaml
ERROR: params.style in config.yaml is out of date.
Run: just compute-colors
  light-foreground-color: expected '#525252', got '#AABBCC'

This runs in the Nix build before Hugo, so a deploy with stale colors fails fast. It also runs as an explicit step in CI via GitHub Actions.

The end result is the same adaptive color system as before, but with one fewer build-time dependency and the computed values committed to the repo so they’re auditable in diffs.