"""Theme the Run tab: a preset or one brand hex -> the THEME block of run_tab.html. python palette.py sky|teal|"#7a3cff" print the block and a contrast table python palette.py "#7a3cff" --write run_tab/run_tab.html also write it into the page Only the sky gradient and the accent change. ok/warn/bad and the log colours stay fixed (R9). A brand hex that is too light for white button text is darkened until it passes. Exits 1 if a contrast check still fails. """ import re import sys WHITE, BLACK = "#ffffff", "#000000" INK = {"light": "#1c2b33", "dark": "#e6eef4"} PRESETS = { "sky": {"light": {"--sky-top": "#8fc9e8", "--sky-mid": "#bfe3f2", "--sky-bottom": "#eaf6fb", "--accent": "#3d9bd0", "--accent-lite": "#5cb2e2", "--accent-dark": "#16202a"}, "dark": {"--sky-top": "#0f1b26", "--sky-mid": "#13212e", "--sky-bottom": "#182a38", "--accent": "#3d9bd0", "--accent-lite": "#5cb2e2", "--accent-dark": "#2e5d80"}}, "teal": {"light": {"--sky-top": "#8fe8ad", "--sky-mid": "#bff2d0", "--sky-bottom": "#eafbf0", "--accent": "#0d9488", "--accent-lite": "#2dd4bf", "--accent-dark": "#16202a"}, "dark": {"--sky-top": "#0f2617", "--sky-mid": "#132e1c", "--sky-bottom": "#183823", "--accent": "#0d9488", "--accent-lite": "#2dd4bf", "--accent-dark": "#2e5d80"}}, } def rgb(h): return [int(h[i:i + 2], 16) for i in (1, 3, 5)] def mix(a, b, t): """Blend hex colour a toward b by t (0..1).""" return "#" + "".join(f"{round(x + (y - x) * t):02x}" for x, y in zip(rgb(a), rgb(b))) def contrast(a, b): """WCAG 2 contrast ratio between two hex colours.""" def lum(h): c = [v / 255 for v in rgb(h)] c = [x / 12.92 if x <= 0.03928 else ((x + 0.055) / 1.055) ** 2.4 for x in c] return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2] hi, lo = sorted((lum(a), lum(b)), reverse=True) return (hi + 0.05) / (lo + 0.05) def derive(brand): brand = brand.strip().lower() if len(brand) == 4: # #abc -> #aabbcc brand = "#" + "".join(ch * 2 for ch in brand[1:]) accent = brand for _ in range(60): # white text on the Trigger button must stay readable if contrast(WHITE, accent) >= 3: break accent = mix(accent, BLACK, 0.06) base = {"--accent": accent, "--accent-lite": mix(accent, WHITE, 0.2)} return { "light": {"--sky-top": mix(accent, WHITE, 0.55), "--sky-mid": mix(accent, WHITE, 0.72), "--sky-bottom": mix(accent, WHITE, 0.9), **base, "--accent-dark": mix(accent, "#0b1218", 0.85)}, "dark": {"--sky-top": mix(accent, BLACK, 0.85), "--sky-mid": mix(accent, BLACK, 0.82), "--sky-bottom": mix(accent, BLACK, 0.78), **base, "--accent-dark": mix(accent, BLACK, 0.4)}, } def checks(t): rows = [] for mode, m in t.items(): rows += [(mode, "white text on the Trigger button", contrast(WHITE, m["--accent"]), 3), (mode, "ink on the top of the sky", contrast(INK[mode], m["--sky-top"]), 4.5), (mode, "ink on the bottom of the sky", contrast(INK[mode], m["--sky-bottom"]), 4.5)] rows.append(("light", "progress % on the sky", contrast(t["light"]["--accent-dark"], t["light"]["--sky-bottom"]), 4.5)) return rows def block(t): def line(m): r = ",".join(str(v) for v in rgb(m["--accent"])) return "; ".join(f"{k}:{v}" for k, v in m.items()) + f"; --accent-rgb:{r};" return ("/* THEME:START (palette.py --write replaces everything down to THEME:END) */\n" f":root{{\n {line(t['light'])}\n}}\nbody.dark{{\n {line(t['dark'])}\n}}\n/* THEME:END */") if __name__ == "__main__": if len(sys.argv) < 2 or not (sys.argv[1] in PRESETS or sys.argv[1].startswith("#")): sys.exit(__doc__) t = PRESETS.get(sys.argv[1]) or derive(sys.argv[1]) css = block(t) print(css + "\n") rows = checks(t) for mode, what, ratio, need in rows: print(f"{mode:<6} {what:<34} {ratio:5.2f}:1 needs {need}:1 {'PASS' if ratio >= need else 'FAIL'}") if "--write" in sys.argv: page = sys.argv[sys.argv.index("--write") + 1] html = open(page, encoding="utf-8").read() new, n = re.subn(r"/\* THEME:START.*?THEME:END \*/", lambda _: css, html, flags=re.S) if n != 1: sys.exit(f"THEME:START/END markers not found in {page}") open(page, "w", encoding="utf-8").write(new) print(f"\nwrote the theme into {page}") sys.exit(0 if all(r >= n for *_, r, n in rows) else 1)