Watch
1
0
Fork
You've already forked dotfiles
0
dotfiles/.config/gnome-settings/derive-palette.py
2026-08-15 04:34:26 +02:00

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}")