#!/usr/bin/env python3 """Single-source design-token generator for the panel widget gallery. tokens.json is the neutral source of truth for the gallery's design tokens (palette, the amber family, glows, font, timing). This script reads it and emits three target representations so the same look and feel travels across every place the widgets get built: web CSS :root { --gold:#e2a038; --glow-hi:255,190,84; ... } waybar GTK @define-color gold #e2a038; (GTK has no custom properties) Emacs (defconst gallery-tokens '((gold . "#e2a038") ...)) for svg.el The three differ on purpose, which is the whole reason a generator earns its keep instead of hand-maintaining three copies: - CSS custom props are hyphenated (--glow-hi) and store glow colors as bare "r,g,b" triples so rgba(var(--glow-hi),.5) resolves with a variable alpha. - GTK CSS has no custom properties; it uses @define-color with underscore names and resolves glows to their source hex (GTK does alpha(@color,a)). - Elisp uses a hyphenated-symbol alist of hex strings, which svg.el / librsvg consume directly (a bare triple is not a color there). The amber hue is defined once, in tokens.json. Both its solid form (--gold) and its glow form (--glow-hi, the rgb triple) are derived here, so retuning the amber is a one-line edit to the source plus a regenerate. Usage: python3 gen_tokens.py # regenerate all three targets in place The web CSS is written into panel-widget-gallery.html between the `/* @tokens:start */` and `/* @tokens:end */` markers; the waybar and elisp targets are written to tokens-waybar.css and tokens.el beside it. """ import json import os HERE = os.path.dirname(os.path.abspath(__file__)) TOKENS_START = "/* @tokens:start */" TOKENS_END = "/* @tokens:end */" # sections whose values are plain colors (searched by resolve_color, and the # ones that become @define-color / alist color entries) COLOR_SECTIONS = ("palette", "amber") def hex_to_triple(h): """'#ffbe54' -> '255,190,84'. Accepts 3- or 6-digit hex, '#' optional.""" h = h.lstrip("#") if len(h) == 3: h = "".join(c * 2 for c in h) if len(h) != 6: raise ValueError(f"expected 3- or 6-digit hex, got {h!r}") try: r, g, b = (int(h[i:i + 2], 16) for i in (0, 2, 4)) except ValueError: raise ValueError(f"non-hex digits in {h!r}") return f"{r},{g},{b}" def resolve_color(tokens, key): """Return the hex for a color named in palette or amber; KeyError if absent.""" for section in COLOR_SECTIONS: if key in tokens.get(section, {}): return tokens[section][key] raise KeyError(key) def _el_str(v): """Quote a value as an elisp string literal, escaping backslashes/quotes.""" return '"' + v.replace("\\", "\\\\").replace('"', '\\"') + '"' def emit_web_css(tokens): """The inner :root block: hyphenated --vars, glows as rgb triples.""" lines = [] for section in COLOR_SECTIONS: for k, v in tokens.get(section, {}).items(): lines.append(f"--{k}:{v};") for name, source in tokens.get("glow", {}).items(): lines.append(f"--{name}:{hex_to_triple(resolve_color(tokens, source))};") for k, v in tokens.get("font", {}).items(): lines.append(f"--{k}:{v};") for k, v in tokens.get("timing", {}).items(): lines.append(f"--{k}:{v};") return "\n".join(" " + line for line in lines) def emit_waybar_gtk(tokens): """GTK @define-color declarations; underscore names, glows resolved to hex.""" def gtk(name): return name.replace("-", "_") lines = [ "/* generated from tokens.json by gen_tokens.py — do not edit by hand.", " GTK CSS has no custom properties; reference these as @name, and use", " alpha(@name, 0.5) where the web build uses rgba(var(--name),.5). */", ] for section in COLOR_SECTIONS: for k, v in tokens.get(section, {}).items(): lines.append(f"@define-color {gtk(k)} {v};") for name, source in tokens.get("glow", {}).items(): lines.append(f"@define-color {gtk(name)} {resolve_color(tokens, source)};") return "\n".join(lines) def emit_elisp(tokens): """An alist of (name . hex) plus timing, for the future svg.el renderer.""" pairs = [] for section in COLOR_SECTIONS: for k, v in tokens.get(section, {}).items(): pairs.append(f"({k} . {_el_str(v)})") for name, source in tokens.get("glow", {}).items(): pairs.append(f"({name} . {_el_str(resolve_color(tokens, source))})") for k, v in tokens.get("font", {}).items(): pairs.append(f"({k} . {_el_str(v)})") for k, v in tokens.get("timing", {}).items(): pairs.append(f"({k} . {_el_str(v)})") body = "\n ".join(pairs) return ( ";;; gallery-tokens.el --- generated from tokens.json -*- lexical-binding: t; -*-\n" ";;; Commentary:\n" ";; Generated by gen_tokens.py — do not edit by hand; edit tokens.json.\n" ";;; Code:\n" "(defconst gallery-tokens\n '(" + body + ")\n" ' "Design tokens for the panel widget gallery.")\n' "(provide 'gallery-tokens)\n" ";;; gallery-tokens.el ends here\n" ) def replace_between_markers(text, start, end, block): """Replace the text between the start and end marker lines with block. The marker lines themselves survive; only their interior is swapped. Idempotent: re-running with the same block is a no-op. Raises ValueError if either marker is missing or out of order. """ si = text.find(start) ei = text.find(end) if si == -1 or ei == -1 or ei < si: raise ValueError("token markers not found (or out of order)") start_line_end = text.find("\n", si) if start_line_end == -1: start_line_end = si + len(start) end_line_start = text.rfind("\n", 0, ei) + 1 return text[:start_line_end + 1] + block + "\n" + text[end_line_start:] def main(tokens_path=None, html_path=None, waybar_path=None, elisp_path=None): """Regenerate all three targets from tokens.json.""" tokens_path = tokens_path or os.path.join(HERE, "tokens.json") html_path = html_path or os.path.join(HERE, "panel-widget-gallery.html") waybar_path = waybar_path or os.path.join(HERE, "tokens-waybar.css") elisp_path = elisp_path or os.path.join(HERE, "tokens.el") with open(tokens_path) as f: tokens = json.load(f) note = (" /* generated from tokens.json by gen_tokens.py — " "edit tokens.json, then run: python3 gen_tokens.py */") block = note + "\n" + emit_web_css(tokens) with open(html_path) as f: html = f.read() html = replace_between_markers(html, TOKENS_START, TOKENS_END, block) with open(html_path, "w") as f: f.write(html) with open(waybar_path, "w") as f: f.write(emit_waybar_gtk(tokens) + "\n") with open(elisp_path, "w") as f: f.write(emit_elisp(tokens)) return {"html": html_path, "waybar": waybar_path, "elisp": elisp_path} if __name__ == "__main__": # pragma: no cover out = main() print("regenerated:", ", ".join(f"{k}={v}" for k, v in out.items()))