"""Derive a full color palette from a GNOME accent. The palette is a single-hue ramp of 7 stops in OKLCH: - All stops share the anchor's hue - Lightness descends in uniform steps, with the anchor centered => Choosing the light end also determines the dark end by mirroring - Chroma follows a bell over lightness, peaking at the anchor In addition, 5 accent hues with 2 shades each are derived for the terminals' ANSI colors. The resulting values are recorded in the palette table in ./README.md and this script is the source of truth for all colors. Usage: `python3 derive-palette.py` (requires `pip install coloraide` before) """ import coloraide ANCHOR = "#2190A4" # GNOME's teal (i.e., libadwaita's "--accent-teal") L_LIGHT = 0.933 # The one chosen parameter # Tail values of the chroma bell at the ramp's two ends # where the dark tail is held high enough to keep the darkest stop # appear in the chosen accent's hue C_LIGHT, C_DARK = 0.017, 0.055 # Canonical hue angles (in OKLCH) # for the terminal's semantic colors ACCENT_HUES = { "red": 25, "yellow": 95, "green": 145, "blue": 260, "magenta": 330, } # The anchor's lightness, chroma, and hue seed everything below a = coloraide.Color(ANCHOR).convert("oklch") AL, AC, H = a["lightness"], a["chroma"], a["hue"] step = (L_LIGHT - AL) / 3 def hexify(L, C, h=H): """Convert an OKLCH color to an upper-case sRGB hex string. Colors outside the sRGB gamut are fitted by reducing chroma while preserving lightness and hue; in this palette that only affects the darkest stops, where sRGB cannot hold the full chroma. """ return ( coloraide.Color("oklch", [L, C, h]) .convert("srgb") .fit(method="oklch-chroma") .to_string(hex=True, upper=True) ) def bell(L): """Return the ramp's chroma at lightness `L`. Chroma follows a parabola over lightness: it peaks at the anchor (i.e., `AC`) and falls off to `C_LIGHT` at the light end and `C_DARK` at the dark end of the ramp. So, both ends still appear in the chosen accent's hue without ever exceeding the anchor's saturation. """ end = C_LIGHT if L >= AL else C_DARK t = (L - AL) / (3 * step) return AC - (AC - end) * t * t def lift(h): """Return the lightness at which hue `h` reads on the dark base. Starting from the anchor's lightness, raise it in steps of 0.01 until the color reaches a WCAG 2.1 contrast of 4.5:1 against the ramp's darkest stop (i.e., `dark_base`, defined below but before the first call). The accents' lightness is thereby solved from the contrast requirement rather than chosen by hand. """ L = AL while ( coloraide.Color(hexify(L, AC, h)).contrast( dark_base, method="wcag21", ) < 4.5 ): L += 0.01 return L ramp = [ hexify(AL + k * step, AC if k == 0 else bell(AL + k * step)) for k in range(3, -4, -1) ] dark_base = coloraide.Color(ramp[-1]) accents = {} for n, h in ACCENT_HUES.items(): L = lift(h) # Solve only once per hue accents[n] = ( hexify(L, AC, h), hexify(L + 0.10, AC, h), ) print("ramp:", *ramp) for n, (norm, bright) in accents.items(): print(f"{n}: {norm} {bright}")