From 440877965e8879f3c9567cf923b7c65c0eee227c Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Sat, 11 Jul 2026 23:36:51 -0500 Subject: refactor(gallery): extract design tokens to one source for 3 targets The colors, glows, gradient ramp, and pulse rate were hardcoded literals scattered across ~40 sites in the gallery. Retuning the amber meant a find-and-replace through the whole file. I pulled them into tokens.json as the single source. gen_tokens.py reads that source and emits three targets: web CSS custom properties (into the gallery :root, between markers), waybar GTK @define-color declarations, and an elisp alist for the future svg.el renderer. The three genuinely differ (CSS --vars with rgb-triple glows, GTK @define-color with underscore names, elisp hex alist), which is why one generator beats three hand-maintained copies. The amber hue is defined once, and its glow triples are derived from the hex, so retuning it means editing one line and rerunning the generator. The gallery render is unchanged. I verified it pixel-identical against the prior commit with reduced-motion forced and the live clock masked (diff of 0). Tests cover hex conversion, the three emitters, marker replacement, and idempotency: 27 tests, 100% line coverage, wired into make test-unit. --- docs/prototypes/gen_tokens.py | 180 ++++++++++++++++++++ docs/prototypes/panel-widget-gallery.html | 109 +++++++----- docs/prototypes/tokens-waybar.css | 34 ++++ docs/prototypes/tokens.el | 41 +++++ docs/prototypes/tokens.json | 46 +++++ tests/gallery-tokens/test_gen_tokens.py | 268 ++++++++++++++++++++++++++++++ 6 files changed, 638 insertions(+), 40 deletions(-) create mode 100644 docs/prototypes/gen_tokens.py create mode 100644 docs/prototypes/tokens-waybar.css create mode 100644 docs/prototypes/tokens.el create mode 100644 docs/prototypes/tokens.json create mode 100644 tests/gallery-tokens/test_gen_tokens.py diff --git a/docs/prototypes/gen_tokens.py b/docs/prototypes/gen_tokens.py new file mode 100644 index 0000000..f966451 --- /dev/null +++ b/docs/prototypes/gen_tokens.py @@ -0,0 +1,180 @@ +#!/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())) diff --git a/docs/prototypes/panel-widget-gallery.html b/docs/prototypes/panel-widget-gallery.html index 5f1dcf3..1d95488 100644 --- a/docs/prototypes/panel-widget-gallery.html +++ b/docs/prototypes/panel-widget-gallery.html @@ -6,13 +6,42 @@ Panel widget gallery — dupre instrument console (interactive) \n" + ) + + def tearDown(self): + import shutil + shutil.rmtree(self.tmp, ignore_errors=True) + + def test_main_writes_all_three_targets(self): + gt.main( + tokens_path=self.tokens_path, + html_path=self.html, + waybar_path=self.waybar, + elisp_path=self.elisp, + ) + with open(self.html) as f: + html = f.read() + self.assertIn("--gold:#e2a038;", html) + self.assertNotIn("STALE", html) + self.assertTrue(os.path.exists(self.waybar)) + self.assertTrue(os.path.exists(self.elisp)) + with open(self.waybar) as f: + self.assertIn("@define-color gold #e2a038;", f.read()) + with open(self.elisp) as f: + self.assertIn('(gold . "#e2a038")', f.read()) + + def test_main_is_idempotent(self): + gt.main(tokens_path=self.tokens_path, html_path=self.html, + waybar_path=self.waybar, elisp_path=self.elisp) + with open(self.html) as f: + first = f.read() + gt.main(tokens_path=self.tokens_path, html_path=self.html, + waybar_path=self.waybar, elisp_path=self.elisp) + with open(self.html) as f: + second = f.read() + self.assertEqual(first, second) + + +if __name__ == "__main__": + unittest.main() -- cgit v1.2.3