"""Derive a custom dashboard palette from one brand hex, then check text contrast. Usage: python palette.py "#7a3cff" Prints the accent tokens for the body{} (light) and body.dark{} blocks of dashboard.html, which keeps the Tebra Sky base for everything else, then a contrast table for light and dark. A brand colour that is too light or too dark is nudged until text on it is readable. Exits 1 if any check still fails. """ import sys WHITE, BLACK = "#ffffff", "#000000" # Tebra Sky base colours the accents sit on. Keep in step with dashboard.html. BASE = {"light": {"bg": "#eaf6fb", "ink": "#1c2b33"}, "dark": {"bg": "#182a38", "ink": "#e6eef4"}} LOG_TEXT = "#f68c82" # dimmest log line colour; the log box background is --accent-dark 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 checks(t): """(mode, what, ratio, needed) for every text pair the accents affect.""" rows = [] for mode, m in t.items(): bg, ink = BASE[mode]["bg"], BASE[mode]["ink"] rows += [(mode, "white button text on --accent", contrast(WHITE, m["--accent"]), 3), (mode, "white button text on --accent-2", contrast(WHITE, m["--accent-2"]), 3), (mode, "--accent text on the page", contrast(m["--accent"], bg), 3), (mode, "ink on --accent-soft buttons", contrast(ink, m["--accent-soft"]), 4.5), (mode, "log text on --accent-dark", contrast(LOG_TEXT, m["--accent-dark"]), 4.5)] return rows def nudge(colour, fix): """Step colour toward whatever fix() returns (WHITE/BLACK) until it returns None.""" for _ in range(60): toward = fix(colour) if toward is None: break colour = mix(colour, toward, 0.06) return colour def derive(brand): brand = brand.strip().lower() if len(brand) == 4: # #abc -> #aabbcc brand = "#" + "".join(ch * 2 for ch in brand[1:]) out = {} for mode, lift in [("light", 0.15), ("dark", 0.10)]: bg, ink = BASE[mode]["bg"], BASE[mode]["ink"] dark_page = mode == "dark" def fix_accent(c): if contrast(c, bg) < 3: # accent text must stand out from the page return WHITE if dark_page else BLACK if contrast(WHITE, mix(c, WHITE, lift)) < 3: # white text on the button return BLACK return None a = nudge(brand, fix_accent) soft = mix(a, BLACK, 0.35) if dark_page else mix(a, WHITE, 0.82) out[mode] = { "--accent": a, "--accent-2": mix(a, WHITE, lift), "--accent-dark": nudge(mix(a, BLACK, 0.45 if dark_page else 0.75), lambda c: BLACK if contrast(LOG_TEXT, c) < 4.5 else None), "--accent-soft": nudge(soft, lambda c: None if contrast(ink, c) >= 4.5 else BLACK if dark_page else WHITE), "--accent-soft-2": mix(a, BLACK, 0.6) if dark_page else mix(a, WHITE, 0.62), } return out if __name__ == "__main__": if len(sys.argv) != 2 or not sys.argv[1].startswith("#"): sys.exit(__doc__) t = derive(sys.argv[1]) for mode, block in [("light", "body{"), ("dark", "body.dark{")]: print(f"/* {mode}: replace these tokens in the {block} block */") print(block + " " + "; ".join(f"{k}:{v}" for k, v in t[mode].items()) + "; }") print() 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'}") sys.exit(0 if all(r >= n for *_, r, n in rows) else 1)