119 lines
3.2 KiB
Python
119 lines
3.2 KiB
Python
"""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}")
|