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.