huemill: a theme pipeline that won't write bad contrast to disk

I kept switching themes and finding text I couldn't read. So I built a theme manager that won't write a theme with bad contrast to disk. huemill is public now.

The design choice that makes it work is that palette.json is the seam. One side produces it — from a named Base16-style theme or the current wallpaper — and the other side consumes it, and they never touch. Enforce the invariants once, in the compiler and the test suite, and every adapter downstream (kitty, ghostty, waybar, Firefox, whatever) inherits them without knowing they exist.

The invariant

The thresholds sit at the top of the compiler: MIN_CONTENT_CONTRAST = 4.5 and MIN_BORDER_CONTRAST = 3.0. readable_color returns a preferred foreground if it clears the minimum, otherwise the better of black or white. contrast_color doesn't fall back to black or white for muted text and borders — it shifts lightness until the threshold is met, so muted text on a dark surface stays dark, just lighter enough to pass.

The test suite asserts the artifact before it's written: content/surface contrast ≥ 4.5, border/surface ≥ 3.0, and every on*Container/onSelection pair ≥ 4.5. Break the invariant and the tests fail. If it's on disk, it's readable.

The seam is load-bearing

Write is atomic: flock around a temp file, rename() over the target, so a concurrent reader never sees a half-written palette. The current artifact is version 4 — semantic roles (surface, text, accent, danger) with on*, *Container, and on*Container variants, interaction colors, and a full terminal palette for indices 0 through 255. The terminal expansion derives from the theme colors and credits Jake Stewart's color256, which huemill vendors with its public-domain provenance intact.

Wallpaper mode needs matugen for the initial generation on top of the named-theme path. If ImageMagick's convert is available, huemill records wallpaperLuminance from 0.0 to 1.0; if it isn't, that field is just omitted. Both are optional, and huemill's job is the palette, not your application templates — after writing the artifact it can invoke a theme-apply-all hook your dotfiles provide.

The result is one stable JSON document, consumed everywhere. It doesn't own the application end of the seam; it just makes sure the thing between them is always readable.

Version 0.1.0, MIT, 23 tests. codeberg.org/maxronner/huemill