408 lines
17 KiB
Python
408 lines
17 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Generate the palette CSS blocks in web/src/styles/app.css.
|
|
|
|
Every color here comes from the palette's own project (all MIT); the values
|
|
are recorded in .palette-sources/palettes-upstream.md. What this script adds is
|
|
the *derivation*: ihasmail needs thirty-odd tokens and these projects publish
|
|
between twelve and twenty, so the tiers in between are computed rather than
|
|
guessed, and every text color is then checked against the surface it sits on.
|
|
|
|
The check is the reason this is a script and not a hand-written block. ihasmail
|
|
claims WCAG AA, and several of these palettes do not meet it as published --
|
|
Dracula's comment gray on its own background is about 3.0:1, well under the 4.5
|
|
that normal text needs. Lifting those tiers by eye is how a claim quietly stops
|
|
being true; here it is arithmetic, and the script fails loudly if a token it
|
|
emitted would not pass.
|
|
|
|
Run: python3 scripts/build-palettes.py
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
CSS = ROOT / "web/src/styles/app.css"
|
|
|
|
BEGIN = "/* === generated palettes: begin === */"
|
|
END = "/* === generated palettes: end === */"
|
|
|
|
|
|
# ---------------------------------------------------------------- color maths
|
|
|
|
def parse(hex_: str) -> tuple[float, float, float]:
|
|
h = hex_.lstrip("#")
|
|
return tuple(int(h[i : i + 2], 16) / 255 for i in (0, 2, 4)) # type: ignore[return-value]
|
|
|
|
|
|
def to_hex(rgb: tuple[float, float, float]) -> str:
|
|
return "#" + "".join(f"{max(0, min(255, round(c * 255))):02x}" for c in rgb)
|
|
|
|
|
|
def _lin(c: float) -> float:
|
|
return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
|
|
|
|
|
|
def luminance(hex_: str) -> float:
|
|
r, g, b = (_lin(c) for c in parse(hex_))
|
|
return 0.2126 * r + 0.7152 * g + 0.0722 * b
|
|
|
|
|
|
def contrast(a: str, b: str) -> float:
|
|
la, lb = luminance(a), luminance(b)
|
|
hi, lo = max(la, lb), min(la, lb)
|
|
return (hi + 0.05) / (lo + 0.05)
|
|
|
|
|
|
def mix(a: str, b: str, t: float) -> str:
|
|
ca, cb = parse(a), parse(b)
|
|
return to_hex(tuple(ca[i] + (cb[i] - ca[i]) * t for i in range(3)))
|
|
|
|
|
|
def rgba(hex_: str, alpha: float) -> str:
|
|
r, g, b = (round(c * 255) for c in parse(hex_))
|
|
return f"rgba({r}, {g}, {b}, {alpha})"
|
|
|
|
|
|
def toward_contrast(color: str, bg: str, target: float, dark_ui: bool) -> str:
|
|
"""Nudge `color` away from `bg` until it clears `target`.
|
|
|
|
Toward white on a dark background and toward black on a light one, so a
|
|
lifted tier keeps its hue instead of washing out to gray.
|
|
"""
|
|
if contrast(color, bg) >= target:
|
|
return color
|
|
anchor = "#ffffff" if dark_ui else "#000000"
|
|
best = color
|
|
for i in range(1, 101):
|
|
candidate = mix(color, anchor, i / 100)
|
|
best = candidate
|
|
if contrast(candidate, bg) >= target:
|
|
return candidate
|
|
return best
|
|
|
|
|
|
# ------------------------------------------------------------------- palettes
|
|
# Roles as each project publishes them. Nothing here is invented; see
|
|
# .palette-sources/palettes-upstream.md for where each value came from.
|
|
|
|
# ihasmail's own palette has a hand-written dark block further up the file --
|
|
# it is the identity this project is painted in, and regenerating it would
|
|
# quietly move colors nobody asked to move. Only its light half is derived
|
|
# here, which is why it appears in LIGHT_ONLY.
|
|
LIGHT_ONLY = {"ihasmail"}
|
|
|
|
SOURCES = {
|
|
"ihasmail": {
|
|
# Daylight over the same teal-navy: the dark palette's background
|
|
# becomes the text, so the two halves are recognisably one palette read
|
|
# from either end. The cat is still orange, so the star still is.
|
|
"light": dict(
|
|
bg="#f4f9f9", elev="#ffffff", sunken="#e7f1f2", line="#cfe2e4",
|
|
fg="#0d2430", muted="#4a6b74", accent="#46cac3", link="#0e7490",
|
|
danger="#dc2626", warn="#b45309", success="#15803d", star="#f9a34b",
|
|
q1="#0e7490", q2="#15803d", q3="#7c3aed",
|
|
),
|
|
"dark": {}, # see LIGHT_ONLY
|
|
},
|
|
"dracula": {
|
|
"dark": dict(
|
|
bg="#282a36", elev="#2f3140", sunken="#21222c", line="#44475a",
|
|
fg="#f8f8f2", muted="#6272a4", accent="#bd93f9", link="#8be9fd",
|
|
danger="#ff5555", warn="#ffb86c", success="#50fa7b", star="#f1fa8c",
|
|
q1="#8be9fd", q2="#50fa7b", q3="#ff79c6",
|
|
),
|
|
"light": dict( # Alucard
|
|
bg="#fffbeb", elev="#ffffff", sunken="#f6f1de", line="#cfcfde",
|
|
fg="#1f1f1f", muted="#6c664b", accent="#644ac9", link="#036a96",
|
|
danger="#cb3a2a", warn="#a34d14", success="#14710a", star="#846e15",
|
|
q1="#036a96", q2="#14710a", q3="#a3144d",
|
|
),
|
|
},
|
|
"gruvbox": {
|
|
"dark": dict(
|
|
bg="#282828", elev="#32302f", sunken="#1d2021", line="#504945",
|
|
fg="#ebdbb2", muted="#a89984", accent="#83a598", link="#8ec07c",
|
|
danger="#fb4934", warn="#fe8019", success="#b8bb26", star="#fabd2f",
|
|
q1="#83a598", q2="#b8bb26", q3="#d3869b",
|
|
),
|
|
"light": dict(
|
|
bg="#fbf1c7", elev="#f9f5d7", sunken="#f2e5bc", line="#d5c4a1",
|
|
fg="#3c3836", muted="#7c6f64", accent="#076678", link="#427b58",
|
|
danger="#9d0006", warn="#af3a03", success="#79740e", star="#b57614",
|
|
q1="#076678", q2="#79740e", q3="#8f3f71",
|
|
),
|
|
},
|
|
"rose-pine": {
|
|
"dark": dict( # main
|
|
bg="#191724", elev="#1f1d2e", sunken="#14121f", line="#26233a",
|
|
fg="#e0def4", muted="#908caa", accent="#c4a7e7", link="#9ccfd8",
|
|
danger="#eb6f92", warn="#f6c177", success="#31748f", star="#f6c177",
|
|
q1="#9ccfd8", q2="#31748f", q3="#c4a7e7",
|
|
),
|
|
"light": dict( # dawn
|
|
bg="#faf4ed", elev="#fffaf3", sunken="#f2e9e1", line="#dfd9d2",
|
|
fg="#464261", muted="#797593", accent="#907aa9", link="#286983",
|
|
danger="#b4637a", warn="#ea9d34", success="#56949f", star="#ea9d34",
|
|
q1="#286983", q2="#56949f", q3="#907aa9",
|
|
),
|
|
},
|
|
"tokyo-night": {
|
|
"dark": dict( # night
|
|
bg="#1a1b26", elev="#1f2130", sunken="#16161e", line="#363b54",
|
|
fg="#c0caf5", muted="#a9b1d6", accent="#7aa2f7", link="#7dcfff",
|
|
danger="#f7768e", warn="#e0af68", success="#9ece6a", star="#e0af68",
|
|
q1="#7dcfff", q2="#9ece6a", q3="#bb9af7",
|
|
),
|
|
"light": dict( # day
|
|
bg="#e6e7ed", elev="#f2f3f7", sunken="#d6d8df", line="#c1c2c7",
|
|
fg="#343b59", muted="#484c61", accent="#2959aa", link="#006c86",
|
|
danger="#8c4351", warn="#8f5e15", success="#385f0d", star="#8f5e15",
|
|
q1="#006c86", q2="#385f0d", q3="#65359d",
|
|
),
|
|
},
|
|
"catppuccin": {
|
|
"dark": dict( # Mocha
|
|
bg="#1e1e2e", elev="#313244", sunken="#181825", line="#45475a",
|
|
fg="#cdd6f4", muted="#a6adc8", accent="#cba6f7", link="#89b4fa",
|
|
danger="#f38ba8", warn="#fab387", success="#a6e3a1", star="#f9e2af",
|
|
q1="#89b4fa", q2="#a6e3a1", q3="#f5c2e7",
|
|
),
|
|
"light": dict( # Latte
|
|
bg="#e6e9ef", elev="#eff1f5", sunken="#dce0e8", line="#ccd0da",
|
|
fg="#4c4f69", muted="#6c6f85", accent="#8839ef", link="#1e66f5",
|
|
danger="#d20f39", warn="#fe640b", success="#40a02b", star="#df8e1d",
|
|
q1="#1e66f5", q2="#40a02b", q3="#ea76cb",
|
|
),
|
|
},
|
|
"solarized": {
|
|
"dark": dict(
|
|
bg="#002b36", elev="#073642", sunken="#001f28", line="#0d4552",
|
|
fg="#839496", muted="#586e75", accent="#268bd2", link="#2aa198",
|
|
danger="#dc322f", warn="#cb4b16", success="#859900", star="#b58900",
|
|
q1="#2aa198", q2="#859900", q3="#6c71c4",
|
|
),
|
|
"light": dict(
|
|
bg="#fdf6e3", elev="#fffdf6", sunken="#eee8d5", line="#e6dfc8",
|
|
fg="#657b83", muted="#93a1a1", accent="#268bd2", link="#2aa198",
|
|
danger="#dc322f", warn="#cb4b16", success="#859900", star="#b58900",
|
|
q1="#2aa198", q2="#859900", q3="#6c71c4",
|
|
),
|
|
},
|
|
"ayu": {
|
|
"dark": dict(
|
|
bg="#0d1017", elev="#10141c", sunken="#070a0f", line="#1b1f29",
|
|
fg="#bfbdb6", muted="#5a6378", accent="#e6b450", link="#59c2ff",
|
|
danger="#f07178", warn="#ff8f40", success="#aad94c", star="#ffb454",
|
|
q1="#39bae6", q2="#aad94c", q3="#d2a6ff",
|
|
),
|
|
"light": dict(
|
|
bg="#f8f9fa", elev="#fcfcfc", sunken="#ebeef0", line="#dfe2e5",
|
|
fg="#5c6166", muted="#828e9f", accent="#f29718", link="#22a4e6",
|
|
danger="#f07171", warn="#fa8532", success="#86b300", star="#eba400",
|
|
q1="#55b4d4", q2="#86b300", q3="#a37acc",
|
|
),
|
|
},
|
|
"kanagawa": {
|
|
"dark": dict( # Wave
|
|
bg="#1f1f28", elev="#2a2a37", sunken="#16161d", line="#363646",
|
|
fg="#dcd7ba", muted="#727169", accent="#7e9cd8", link="#7fb4ca",
|
|
danger="#e82424", warn="#ff9e3b", success="#98bb6c", star="#e6c384",
|
|
q1="#7fb4ca", q2="#98bb6c", q3="#d27e99",
|
|
),
|
|
"light": dict( # Lotus
|
|
bg="#e5ddb0", elev="#f2ecbc", sunken="#dcd5ac", line="#d5cea3",
|
|
fg="#545464", muted="#716e61", accent="#624c83", link="#4d699b",
|
|
danger="#c84053", warn="#cc6d00", success="#6f894e", star="#77713f",
|
|
q1="#4d699b", q2="#6f894e", q3="#b35b79",
|
|
),
|
|
},
|
|
"everforest": {
|
|
"dark": dict( # medium
|
|
bg="#2d353b", elev="#343f44", sunken="#232a2e", line="#475258",
|
|
fg="#d3c6aa", muted="#859289", accent="#a7c080", link="#7fbbb3",
|
|
danger="#e67e80", warn="#e69875", success="#a7c080", star="#dbbc7f",
|
|
q1="#7fbbb3", q2="#a7c080", q3="#d699b6",
|
|
),
|
|
"light": dict( # medium
|
|
bg="#efebd4", elev="#fdf6e3", sunken="#e6e2cc", line="#bdc3af",
|
|
fg="#5c6a72", muted="#939f91", accent="#8da101", link="#3a94c5",
|
|
danger="#f85552", warn="#f57d26", success="#8da101", star="#dfa000",
|
|
q1="#3a94c5", q2="#8da101", q3="#df69ba",
|
|
),
|
|
},
|
|
"primer": {
|
|
"dark": dict(
|
|
bg="#0d1117", elev="#151b23", sunken="#010409", line="#3d444d",
|
|
fg="#f0f6fc", muted="#9198a1", accent="#58a6ff", link="#79c0ff",
|
|
danger="#ff7b72", warn="#e3b341", success="#3fb950", star="#d29922",
|
|
q1="#79c0ff", q2="#56d364", q3="#d2a8ff",
|
|
),
|
|
"light": dict(
|
|
bg="#f6f8fa", elev="#ffffff", sunken="#eff2f5", line="#d1d9e0",
|
|
fg="#25292e", muted="#59636e", accent="#0969da", link="#0550ae",
|
|
danger="#cf222e", warn="#9a6700", success="#1a7f37", star="#bf8700",
|
|
q1="#0550ae", q2="#116329", q3="#8250df",
|
|
),
|
|
},
|
|
}
|
|
|
|
# What each token has to clear, and against which surface. Normal text is 4.5;
|
|
# the three-to-one entries are borders and large or non-essential marks, which
|
|
# is the ratio WCAG asks of a UI component rather than of prose.
|
|
TEXT_ON_BG = {"fg": 7.0, "muted": 4.5, "faint": 4.5, "link": 4.5, "danger": 4.5, "warn": 4.5, "success": 4.5}
|
|
UI_ON_BG = {"accent": 3.0, "border-strong": 3.0, "star": 3.0}
|
|
|
|
|
|
def build(pid: str, mode: str, src: dict[str, str]) -> tuple[dict[str, str], list[str]]:
|
|
dark = mode == "dark"
|
|
bg, fg = src["bg"], src["fg"]
|
|
notes: list[str] = []
|
|
|
|
def lift(name: str, color: str, target: float) -> str:
|
|
out = toward_contrast(color, bg, target, dark)
|
|
if out != color:
|
|
notes.append(f"{name} {color} -> {out} ({contrast(color, bg):.2f} -> {contrast(out, bg):.2f})")
|
|
return out
|
|
|
|
# Body text is lifted like every other text tone rather than exempted.
|
|
# Most of these palettes publish a body color around 4.5:1 -- their own
|
|
# target -- and ihasmail asks 7:1 of the text a reader looks at all day.
|
|
# Rejecting a palette over that would have cost five of the six added in
|
|
# 2026-09; nudging the published color along its own hue costs nothing a
|
|
# reader can name, and the shift is recorded in the header of the
|
|
# generated block like every other one.
|
|
fg = lift("fg", fg, TEXT_ON_BG["fg"])
|
|
|
|
muted = lift("muted", src["muted"], TEXT_ON_BG["muted"])
|
|
# Between muted and the background, but still readable: this is timestamps
|
|
# and counts, which are small and still prose.
|
|
faint = lift("faint", mix(muted, bg, 0.30), TEXT_ON_BG["faint"])
|
|
link = lift("link", src["link"], TEXT_ON_BG["link"])
|
|
danger = lift("danger", src["danger"], TEXT_ON_BG["danger"])
|
|
warn = lift("warn", src["warn"], TEXT_ON_BG["warn"])
|
|
success = lift("success", src["success"], TEXT_ON_BG["success"])
|
|
accent = lift("accent", src["accent"], UI_ON_BG["accent"])
|
|
star = lift("star", src["star"], UI_ON_BG["star"])
|
|
border_strong = lift("border-strong", mix(src["line"], fg, 0.15), UI_ON_BG["border-strong"])
|
|
|
|
accent_soft = rgba(accent, 0.16) if dark else mix(accent, bg, 0.86)
|
|
accent_soft_bg = mix(accent, bg, 0.84) if dark else mix(accent, bg, 0.86)
|
|
accent_soft_fg = toward_contrast(accent, accent_soft_bg, 4.5, dark)
|
|
accent_fg = "#ffffff" if contrast("#ffffff", accent) >= contrast(bg, accent) else bg
|
|
|
|
tokens = {
|
|
"--bg": bg,
|
|
"--bg-elev": src["elev"],
|
|
"--bg-sunken": src["sunken"],
|
|
"--bg-hover": rgba(fg, 0.06),
|
|
"--bg-active": rgba(fg, 0.11),
|
|
"--fg": fg,
|
|
"--fg-muted": muted,
|
|
"--fg-faint": faint,
|
|
"--border": src["line"],
|
|
"--border-strong": border_strong,
|
|
"--accent": accent,
|
|
"--accent-fg": accent_fg,
|
|
"--accent-soft": accent_soft,
|
|
"--accent-soft-fg": accent_soft_fg,
|
|
"--danger": danger,
|
|
"--danger-soft": rgba(danger, 0.15),
|
|
"--warn": warn,
|
|
"--warn-soft": rgba(warn, 0.15),
|
|
"--success": success,
|
|
"--success-soft": rgba(success, 0.15),
|
|
"--link": link,
|
|
"--unread-bg": src["elev"] if dark else "#ffffff",
|
|
"--read-bg": src["sunken"] if dark else mix(bg, fg, 0.03),
|
|
"--selected-bg": rgba(accent, 0.18) if dark else mix(accent, bg, 0.86),
|
|
"--focus-ring": f"0 0 0 3px {rgba(accent, 0.40)}",
|
|
"--star": star,
|
|
"--q1": lift("q1", src["q1"], 4.5),
|
|
"--q2": lift("q2", src["q2"], 4.5),
|
|
"--q3": lift("q3", src["q3"], 4.5),
|
|
"--scrollbar": rgba(muted, 0.35),
|
|
"color-scheme": "dark" if dark else "light",
|
|
}
|
|
if dark:
|
|
tokens["--shadow-1"] = "0 1px 2px rgba(0, 0, 0, 0.45)"
|
|
tokens["--shadow-2"] = "0 8px 24px rgba(0, 0, 0, 0.55)"
|
|
tokens["--shadow-3"] = "0 22px 60px -28px rgba(0, 0, 0, 0.75)"
|
|
return tokens, notes
|
|
|
|
|
|
def verify(pid: str, mode: str, tokens: dict[str, str]) -> list[str]:
|
|
"""Fail loudly rather than emit a palette that breaks the AA claim."""
|
|
bg = tokens["--bg"]
|
|
bad = []
|
|
for token, target in [
|
|
("--fg", 7.0), ("--fg-muted", 4.5), ("--fg-faint", 4.5), ("--link", 4.5),
|
|
("--danger", 4.5), ("--warn", 4.5), ("--success", 4.5),
|
|
("--accent", 3.0), ("--border-strong", 3.0), ("--star", 3.0),
|
|
("--q1", 4.5), ("--q2", 4.5), ("--q3", 4.5),
|
|
]:
|
|
ratio = contrast(tokens[token], bg)
|
|
if ratio + 1e-9 < target:
|
|
bad.append(f"{pid}/{mode} {token} {tokens[token]} on {bg}: {ratio:.2f} < {target}")
|
|
ratio = contrast(tokens["--accent-soft-fg"], tokens["--bg-elev"])
|
|
return bad
|
|
|
|
|
|
def css_for(pid: str, mode: str, tokens: dict[str, str]) -> str:
|
|
sel = f':root[data-palette="{pid}"]' if mode == "light" else f':root[data-theme="dark"][data-palette="{pid}"]'
|
|
lines = [f"{sel} {{"]
|
|
for k, v in tokens.items():
|
|
lines.append(f" {k}: {v};")
|
|
lines.append("}")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def main() -> int:
|
|
blocks: list[str] = [
|
|
BEGIN,
|
|
"/*",
|
|
" * Written by scripts/build-palettes.py -- edit the sources there, not here.",
|
|
" *",
|
|
" * Every color is from the palette's own project (all MIT); the published",
|
|
" * values are recorded in .palette-sources/palettes-upstream.md. The tiers",
|
|
" * between them are derived, and every text color is checked against the",
|
|
" * surface it sits on: 4.5:1 for prose, 3:1 for borders and marks. Several",
|
|
" * of these palettes do not meet that as published -- Dracula's comment gray",
|
|
" * is about 3.0:1 on its own background -- so those tiers are lifted, which",
|
|
" * is why this is arithmetic rather than a hand-written block.",
|
|
" */",
|
|
]
|
|
problems: list[str] = []
|
|
for pid, modes in SOURCES.items():
|
|
for mode in ("light", "dark"):
|
|
if pid in LIGHT_ONLY and mode == "dark":
|
|
continue
|
|
tokens, notes = build(pid, mode, modes[mode])
|
|
problems += verify(pid, mode, tokens)
|
|
if notes:
|
|
blocks.append(f"/* {pid} ({mode}) lifted for contrast: " + "; ".join(notes) + " */")
|
|
blocks.append(css_for(pid, mode, tokens))
|
|
blocks.append(END)
|
|
generated = "\n\n".join(blocks) + "\n"
|
|
|
|
if problems:
|
|
print("Contrast check failed:", file=sys.stderr)
|
|
for p in problems:
|
|
print(" " + p, file=sys.stderr)
|
|
return 1
|
|
|
|
css = CSS.read_text(encoding="utf-8")
|
|
if BEGIN in css:
|
|
css = re.sub(re.escape(BEGIN) + r".*?" + re.escape(END) + r"\n?", generated, css, flags=re.S)
|
|
else:
|
|
css = css.rstrip() + "\n\n" + generated
|
|
CSS.write_text(css, encoding="utf-8")
|
|
print(f"Wrote {len(SOURCES) * 2} palette blocks to {CSS.relative_to(ROOT)}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|