diff options
| author | Craig Jennings <c@cjennings.net> | 2026-07-11 23:36:51 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-07-11 23:36:51 -0500 |
| commit | 440877965e8879f3c9567cf923b7c65c0eee227c (patch) | |
| tree | 86d10cc52d87aef2e3f5234a35ba327bf81479d3 | |
| parent | 18d4b33e9338fac2a316f51a479574db07d7cedc (diff) | |
| download | archsetup-440877965e8879f3c9567cf923b7c65c0eee227c.tar.gz archsetup-440877965e8879f3c9567cf923b7c65c0eee227c.zip | |
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.
| -rw-r--r-- | docs/prototypes/gen_tokens.py | 180 | ||||
| -rw-r--r-- | docs/prototypes/panel-widget-gallery.html | 109 | ||||
| -rw-r--r-- | docs/prototypes/tokens-waybar.css | 34 | ||||
| -rw-r--r-- | docs/prototypes/tokens.el | 41 | ||||
| -rw-r--r-- | docs/prototypes/tokens.json | 46 | ||||
| -rw-r--r-- | tests/gallery-tokens/test_gen_tokens.py | 268 |
6 files changed, 638 insertions, 40 deletions
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 @@ <title>Panel widget gallery — dupre instrument console (interactive)</title> <style> :root{ - --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917; - --gold:#e2a038; --gold-hi:#ffbe54; --silver:#bfc4d0; --cream:#f3e7c5; - --steel:#969385; --dim:#7c838a; --slate:#424f5e; --slate-hi:#54677d; - --wash:#2c2f32; --pass:#74932f; --fail:#cb6b4d; +/* @tokens:start */ + /* generated from tokens.json by gen_tokens.py — edit tokens.json, then run: python3 gen_tokens.py */ + --ground:#151311; + --panel:#100f0f; + --well:#0a0c0d; + --raise:#1a1917; + --silver:#bfc4d0; + --cream:#f3e7c5; + --steel:#969385; + --dim:#7c838a; + --slate:#424f5e; + --slate-hi:#54677d; + --wash:#2c2f32; + --pass:#74932f; + --fail:#cb6b4d; + --phos:#7fe0a0; + --phos-dim:#1c3626; + --vfd:#63e6c8; + --sevgrn:#57d357; + --sevred:#e2543f; + --sevoff:#1a1613; + --jewel-r:#ff5b45; + --jewel-a:#ffb43a; + --jewel-g:#6fce33; + --gold:#e2a038; + --gold-hi:#ffbe54; + --amber-grad-top:#f2c76a; + --amber-grad-mid:#d29638; + --amber-grad-bot:#8f671f; + --amber-edge:#7d5c16; + --amber-warn:#f0b552; + --glow-hi:255,190,84; + --glow-lo:226,160,56; --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace; - --phos:#7fe0a0; --phos-dim:#1c3626; --vfd:#63e6c8; --sevgrn:#57d357; --sevred:#e2543f; - --sevoff:#1a1613; --jewel-r:#ff5b45; --jewel-a:#ffb43a; --jewel-g:#6fce33; + --pulse-rate:1s; +/* @tokens:end */ } *{box-sizing:border-box;margin:0;padding:0} html{background:var(--ground)} @@ -43,10 +72,10 @@ h2::after{content:"";height:1px;background:var(--wash);flex:1} /* ---- shared primitives ---- */ .lamp{width:9px;height:9px;border-radius:50%;background:var(--pass);box-shadow:0 0 6px 1px rgba(116,147,47,.55)} -.lamp.gold{background:var(--gold);box-shadow:0 0 6px 1px rgba(226,160,56,.6)} +.lamp.gold{background:var(--gold);box-shadow:0 0 6px 1px rgba(var(--glow-lo),.6)} .lamp.red{background:var(--fail);box-shadow:0 0 6px 1px rgba(203,107,77,.55)} .lamp.off{background:var(--wash);box-shadow:none} -.lamp.busy{background:var(--gold);animation:pulse 1s ease-in-out infinite} +.lamp.busy{background:var(--gold);animation:pulse var(--pulse-rate) ease-in-out infinite} /* standard pulse: 1s ease-in-out — the norm for every pulsing / flashing element */ @keyframes pulse{50%{opacity:.25}} @@ -64,7 +93,7 @@ h2::after{content:"";height:1px;background:var(--wash);flex:1} border-radius:8px;padding:8px 12px;box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 2px 3px rgba(0,0,0,.4)} .key:hover{color:var(--gold);border-color:var(--gold)} .key:active{transform:translateY(1px)} -.key.on{color:var(--panel);background:linear-gradient(180deg,#f2c76a,var(--gold));border-color:var(--gold-hi);font-weight:700} +.key.on{color:var(--panel);background:linear-gradient(180deg,var(--amber-grad-top),var(--gold));border-color:var(--gold-hi);font-weight:700} .key.red{color:var(--cream);background:linear-gradient(180deg,#d98a6f,var(--fail));border-color:var(--fail)} .key.off{opacity:.4} @@ -78,22 +107,22 @@ h2::after{content:"";height:1px;background:var(--wash);flex:1} /* fader */ .fader{width:150px;height:16px;position:relative;cursor:pointer;touch-action:none} .fader .slot{position:absolute;top:6px;left:0;right:0;height:4px;border-radius:2px;background:#0d0f10;border:1px solid #231f18;overflow:hidden} -.fader .fill{position:absolute;top:0;left:0;bottom:0;background:linear-gradient(90deg,#8f671f,var(--gold))} +.fader .fill{position:absolute;top:0;left:0;bottom:0;background:linear-gradient(90deg,var(--amber-grad-bot),var(--gold))} .fader .cap{position:absolute;top:1px;width:7px;height:14px;border-radius:2px;margin-left:-3.5px; - background:linear-gradient(180deg,#f2c76a,#d29638);border:1px solid #7d5c16;box-shadow:0 1px 2px rgba(0,0,0,.5)} + background:linear-gradient(180deg,var(--amber-grad-top),var(--amber-grad-mid));border:1px solid var(--amber-edge);box-shadow:0 1px 2px rgba(0,0,0,.5)} /* vertical fader */ .vfader{width:16px;height:64px;position:relative;cursor:pointer;touch-action:none} .vfader .slot{position:absolute;left:6px;top:0;bottom:0;width:4px;border-radius:2px;background:#0d0f10;border:1px solid #231f18;overflow:hidden} -.vfader .fill{position:absolute;left:0;right:0;bottom:0;background:linear-gradient(0deg,#8f671f,var(--gold))} +.vfader .fill{position:absolute;left:0;right:0;bottom:0;background:linear-gradient(0deg,var(--amber-grad-bot),var(--gold))} .vfader .cap{position:absolute;left:1px;height:7px;width:14px;border-radius:2px;margin-top:-3.5px; - background:linear-gradient(90deg,#d29638,#f2c76a);border:1px solid #7d5c16} + background:linear-gradient(90deg,var(--amber-grad-mid),var(--amber-grad-top));border:1px solid var(--amber-edge)} /* rotary knob */ .knob{width:52px;height:52px;border-radius:50%;position:relative;cursor:ns-resize;touch-action:none; background:radial-gradient(circle at 40% 35%,#2a2622,#141210);border:1px solid #3a352c; box-shadow:inset 0 2px 3px rgba(255,255,255,.05),0 3px 6px rgba(0,0,0,.5)} .knob .ind{position:absolute;left:50%;top:5px;width:2px;height:16px;background:var(--gold-hi); - margin-left:-1px;transform-origin:50% 21px;border-radius:1px;box-shadow:0 0 5px rgba(255,190,84,.6)} + margin-left:-1px;transform-origin:50% 21px;border-radius:1px;box-shadow:0 0 5px rgba(var(--glow-hi),.6)} /* needle gauge */ .gauge{width:96px;cursor:ns-resize;touch-action:none} @@ -101,7 +130,7 @@ h2::after{content:"";height:1px;background:var(--wash);flex:1} .gauge .arc{position:absolute;inset:0 0 -48px 0;border:2px solid var(--wash);border-radius:50%} .gauge .tk{position:absolute;left:50%;bottom:0;width:1.5px;height:8px;background:var(--steel);transform-origin:50% 48px} .gauge .ndl{position:absolute;left:50%;bottom:0;width:2px;height:40px;background:var(--gold-hi); - transform-origin:50% 100%;transform:rotate(0deg);border-radius:2px;box-shadow:0 0 6px rgba(255,190,84,.5); + transform-origin:50% 100%;transform:rotate(0deg);border-radius:2px;box-shadow:0 0 6px rgba(var(--glow-hi),.5); transition:transform .12s cubic-bezier(.3,1.3,.5,1)} .gauge .hub{position:absolute;left:50%;bottom:-4px;width:8px;height:8px;margin-left:-4px;border-radius:50%;background:var(--gold)} .gauge .gv{color:var(--cream);text-align:center;font-size:12px;font-weight:700;margin-top:5px;font-variant-numeric:tabular-nums} @@ -129,7 +158,7 @@ h2::after{content:"";height:1px;background:var(--wash);flex:1} /* linear progress / fuel bar */ .bar{width:160px;height:12px;background:#0d0f10;border:1px solid #231f18;border-radius:6px;overflow:hidden;position:relative;cursor:pointer;touch-action:none} -.bar>span{position:absolute;left:0;top:0;bottom:0;background:linear-gradient(90deg,#8f671f,var(--gold));border-radius:6px} +.bar>span{position:absolute;left:0;top:0;bottom:0;background:linear-gradient(90deg,var(--amber-grad-bot),var(--gold));border-radius:6px} .bar.warn>span{background:linear-gradient(90deg,#a35a3f,var(--fail))} /* radial ring */ @@ -163,7 +192,7 @@ h2::after{content:"";height:1px;background:var(--wash);flex:1} .seg{display:flex;border:1px solid #33302b;border-radius:8px;overflow:hidden} .seg button{font:inherit;font-size:11px;color:var(--silver);background:#191715;border:0;border-right:1px solid #33302b;padding:7px 11px;cursor:pointer} .seg button:last-child{border-right:0} -.seg button.on{background:linear-gradient(180deg,#f2c76a,var(--gold));color:var(--panel);font-weight:700} +.seg button.on{background:linear-gradient(180deg,var(--amber-grad-top),var(--gold));color:var(--panel);font-weight:700} /* engraved section label */ .engrave{width:180px;color:var(--steel);font-size:.62rem;letter-spacing:.3em;text-transform:uppercase;cursor:pointer; @@ -189,15 +218,15 @@ h2::after{content:"";height:1px;background:var(--wash);flex:1} .rotsel{position:relative;width:118px;height:74px} .rotsel>.knob{position:absolute;left:50%;top:20px;margin-left:-26px;cursor:pointer} .rotsel .pos{position:absolute;font-size:9px;color:var(--dim);transform:translate(-50%,-50%);letter-spacing:.02em} -.rotsel .pos.on{color:var(--gold-hi);text-shadow:0 0 6px rgba(255,190,84,.55)} +.rotsel .pos.on{color:var(--gold-hi);text-shadow:0 0 6px rgba(var(--glow-hi),.55)} /* slide-rule tuner dial */ .tuner{width:180px;height:46px;position:relative;border-radius:6px;overflow:hidden;cursor:pointer; background:linear-gradient(180deg,#191510,#0b0908);border:1px solid #2a251c; - box-shadow:inset 0 0 20px rgba(226,160,56,.12),inset 0 1px 0 rgba(255,255,255,.03)} + box-shadow:inset 0 0 20px rgba(var(--glow-lo),.12),inset 0 1px 0 rgba(255,255,255,.03)} .tuner .tick{position:absolute;top:6px;width:1px;height:11px;background:var(--steel);transform:translateX(-50%)} .tuner .mk{position:absolute;bottom:8px;transform:translateX(-50%);color:var(--steel);font-size:10px} -.tuner .mk.on{color:var(--gold-hi);text-shadow:0 0 6px rgba(255,190,84,.6)} +.tuner .mk.on{color:var(--gold-hi);text-shadow:0 0 6px rgba(var(--glow-hi),.6)} .tuner:focus-visible{outline:2px solid var(--gold);outline-offset:2px} .tuner .ndl{position:absolute;top:3px;bottom:3px;width:2px;margin-left:-1px;border-radius:1px; background:var(--fail);box-shadow:0 0 7px rgba(203,107,77,.85);transition:left .25s} @@ -222,7 +251,7 @@ h2::after{content:"";height:1px;background:var(--wash);flex:1} font-size:12px;letter-spacing:.06em;color:var(--dim)} .rocker .top{top:0;border-bottom:1px solid #0c0b0a;background:linear-gradient(180deg,#211d19,#181513)} .rocker .bot{bottom:0;background:linear-gradient(180deg,#141210,#100e0c);box-shadow:inset 0 3px 5px rgba(0,0,0,.55)} -.rocker.on .top{background:linear-gradient(180deg,#f2c76a,var(--gold));color:var(--panel);font-weight:700;box-shadow:0 0 10px rgba(255,190,84,.35)} +.rocker.on .top{background:linear-gradient(180deg,var(--amber-grad-top),var(--gold));color:var(--panel);font-weight:700;box-shadow:0 0 10px rgba(var(--glow-hi),.35)} .rocker.on .bot{color:var(--steel)} .rocker:not(.on) .top{color:var(--steel)} .rocker:not(.on) .bot{background:linear-gradient(180deg,#2a1512,#1c0f0d);color:var(--fail);box-shadow:inset 0 3px 5px rgba(0,0,0,.55),0 0 8px rgba(203,107,77,.25)} @@ -233,13 +262,13 @@ h2::after{content:"";height:1px;background:var(--wash);flex:1} background:radial-gradient(circle at 45% 40%,#211d18,#0d0b09)} .reel::before{content:"";position:absolute;inset:9px;border-radius:50%;border:1px solid #4a443a;background:#161310} .reel i{position:absolute;left:50%;top:50%;width:2px;height:11px;background:var(--steel);margin:-5.5px 0 0 -1px;transform-origin:50% 5.5px} -.reel i:nth-child(1){background:var(--gold-hi);box-shadow:0 0 4px rgba(255,190,84,.55)} +.reel i:nth-child(1){background:var(--gold-hi);box-shadow:0 0 4px rgba(var(--glow-hi),.55)} .reel i:nth-child(2){transform:rotate(120deg)}.reel i:nth-child(3){transform:rotate(240deg)} .transport{display:flex;gap:5px} .tbtn{font:inherit;font-size:12px;color:var(--silver);cursor:pointer;width:30px;height:26px;display:grid;place-items:center; background:linear-gradient(180deg,#23211e,#191715);border:1px solid #33302b;border-bottom-color:#0c0b0a;border-radius:6px} .tbtn:hover{color:var(--gold);border-color:var(--gold)} -.tbtn.on{color:var(--panel);background:linear-gradient(180deg,#f2c76a,var(--gold));border-color:var(--gold-hi)} +.tbtn.on{color:var(--panel);background:linear-gradient(180deg,var(--amber-grad-top),var(--gold));border-color:var(--gold-hi)} .tbtn.rec.on{color:var(--cream);background:linear-gradient(180deg,#d98a6f,var(--fail));border-color:var(--fail)} /* radio bank */ @@ -249,7 +278,7 @@ h2::after{content:"";height:1px;background:var(--wash);flex:1} background:linear-gradient(180deg,#211d19,#161310);box-shadow:inset 0 1px 0 rgba(255,255,255,.04)} .preset+.preset{margin-left:3px} .preset:hover{color:var(--silver)} -.preset.on{color:var(--panel);background:linear-gradient(180deg,#f2c76a,var(--gold));font-weight:700; +.preset.on{color:var(--panel);background:linear-gradient(180deg,var(--amber-grad-top),var(--gold));font-weight:700; box-shadow:inset 0 2px 4px rgba(0,0,0,.4)} /* concentric dual knob */ @@ -261,13 +290,13 @@ h2::after{content:"";height:1px;background:var(--wash);flex:1} .dualknob .inner{width:34px;height:34px;border-radius:50%;position:absolute;left:15px;top:15px;cursor:ns-resize;touch-action:none; background:radial-gradient(circle at 40% 35%,#37322a,#1a1713);border:1px solid #4a443a;box-shadow:0 2px 4px rgba(0,0,0,.5)} .dualknob .inner .ind{position:absolute;left:50%;top:3px;width:2px;height:11px;background:var(--gold-hi);margin-left:-1px; - transform-origin:50% 14px;border-radius:1px;box-shadow:0 0 5px rgba(255,190,84,.6)} + transform-origin:50% 14px;border-radius:1px;box-shadow:0 0 5px rgba(var(--glow-hi),.6)} /* rotary encoder + LED ring */ .encoder{position:relative;width:66px;height:66px;display:grid;place-items:center;cursor:ns-resize;touch-action:none} .encoder .led{position:absolute;width:5px;height:5px;border-radius:50%;background:var(--wash); left:50%;top:50%;margin:-2.5px} -.encoder .led.on{background:var(--gold);box-shadow:0 0 5px 1px rgba(226,160,56,.7)} +.encoder .led.on{background:var(--gold);box-shadow:0 0 5px 1px rgba(var(--glow-lo),.7)} .encoder .knob{width:40px;height:40px;cursor:ns-resize} .encoder .knob .ind{transform-origin:50% 15px;top:4px;height:12px} @@ -279,9 +308,9 @@ h2::after{content:"";height:1px;background:var(--wash);flex:1} .keylock .barrel{position:absolute;left:50%;top:50%;width:5px;height:5px;margin:-2.5px;border-radius:1px;background:#0a0908; box-shadow:0 0 2px rgba(0,0,0,.8)} .keylock .bit{position:absolute;left:50%;top:50%;width:3px;height:15px;margin:-15px 0 0 -1.5px;background:var(--gold-hi); - border-radius:1px;transform-origin:50% 100%;box-shadow:0 0 5px rgba(255,190,84,.55);transition:transform .2s} + border-radius:1px;transform-origin:50% 100%;box-shadow:0 0 5px rgba(var(--glow-hi),.55);transition:transform .2s} .keylock .kpos{position:absolute;font-size:9px;color:var(--dim);letter-spacing:.05em;transform:translate(-50%,-50%)} -.keylock .kpos.on{color:var(--gold-hi);text-shadow:0 0 6px rgba(255,190,84,.55)} +.keylock .kpos.on{color:var(--gold-hi);text-shadow:0 0 6px rgba(var(--glow-hi),.55)} /* crossfader */ .xfader{width:160px;height:22px;position:relative;cursor:pointer;touch-action:none} @@ -289,7 +318,7 @@ h2::after{content:"";height:1px;background:var(--wash);flex:1} .xfader .detent{position:absolute;top:2px;left:50%;width:1px;height:18px;background:var(--steel);margin-left:-.5px;opacity:.7} .xfader .end{position:absolute;top:11px;font-size:9px;color:var(--steel);transform:translateY(-50%)} .xfader .cap{position:absolute;top:3px;width:9px;height:16px;border-radius:2px;margin-left:-4.5px;transition:left .05s; - background:linear-gradient(180deg,#f2c76a,#d29638);border:1px solid #7d5c16;box-shadow:0 1px 2px rgba(0,0,0,.5)} + background:linear-gradient(180deg,var(--amber-grad-top),var(--amber-grad-mid));border:1px solid var(--amber-edge);box-shadow:0 1px 2px rgba(0,0,0,.5)} /* thumbwheel */ .thumbw{display:flex;align-items:center;gap:10px} @@ -306,7 +335,7 @@ h2::after{content:"";height:1px;background:var(--wash);flex:1} .dipsw{width:12px;height:26px;background:#0a1220;border-radius:2px;position:relative;cursor:pointer;border:1px solid #1c2c42} .dipsw i{position:absolute;left:1px;right:1px;height:11px;border-radius:1px;background:linear-gradient(180deg,#eae4d2,#b4ad98); bottom:1px;transition:bottom .12s,top .12s} -.dipsw.on i{bottom:auto;top:1px;background:linear-gradient(180deg,#f2c76a,var(--gold))} +.dipsw.on i{bottom:auto;top:1px;background:linear-gradient(180deg,var(--amber-grad-top),var(--gold))} /* jog / shuttle */ .jog{position:relative;width:74px;height:74px;cursor:ns-resize;touch-action:none} @@ -342,7 +371,7 @@ h2::after{content:"";height:1px;background:var(--wash);flex:1} .crossm .face{position:relative;height:56px;overflow:hidden;cursor:ns-resize;touch-action:none} .crossm .arc{position:absolute;inset:2px 4px -56px;border:2px solid var(--wash);border-radius:50%} .crossm .nA{position:absolute;left:14px;bottom:2px;width:2px;height:52px;background:var(--gold-hi);transform-origin:50% 100%; - border-radius:2px;box-shadow:0 0 5px rgba(255,190,84,.5);transition:transform .12s cubic-bezier(.3,1.2,.5,1)} + border-radius:2px;box-shadow:0 0 5px rgba(var(--glow-hi),.5);transition:transform .12s cubic-bezier(.3,1.2,.5,1)} .crossm .nB{position:absolute;right:14px;bottom:2px;width:2px;height:52px;background:var(--fail);transform-origin:50% 100%; border-radius:2px;box-shadow:0 0 5px rgba(203,107,77,.5);transition:transform .12s cubic-bezier(.3,1.2,.5,1)} .crossm .lbl{display:flex;justify-content:space-between;color:var(--steel);font-size:9px;margin-top:2px} @@ -362,7 +391,7 @@ h2::after{content:"";height:1px;background:var(--wash);flex:1} box-shadow:inset 0 2px 5px rgba(0,0,0,.6)} .bourdon svg{position:absolute;inset:0} .bourdon .ndl{position:absolute;left:50%;top:50%;width:2px;height:32px;margin:-32px 0 0 -1px;background:var(--gold-hi); - transform-origin:50% 100%;border-radius:2px;box-shadow:0 0 5px rgba(255,190,84,.55);transition:transform .12s cubic-bezier(.3,1.2,.5,1)} + transform-origin:50% 100%;border-radius:2px;box-shadow:0 0 5px rgba(var(--glow-hi),.55);transition:transform .12s cubic-bezier(.3,1.2,.5,1)} .bourdon .hub{position:absolute;left:50%;top:50%;width:8px;height:8px;margin:-4px;border-radius:50%;background:var(--gold)} .bourdon .cap{position:absolute;left:0;right:0;bottom:12px;text-align:center;font-size:8px;letter-spacing:.16em;color:var(--steel)} @@ -374,7 +403,7 @@ h2::after{content:"";height:1px;background:var(--wash);flex:1} .strip svg{position:absolute;inset:0;width:100%;height:100%} .strip polyline{fill:none;stroke:var(--gold);stroke-width:1.4} .strip .pen{position:absolute;right:2px;width:6px;height:6px;margin:-3px;border-radius:50%;background:var(--gold-hi); - box-shadow:0 0 6px rgba(255,190,84,.8);transition:top .08s linear} + box-shadow:0 0 6px rgba(var(--glow-hi),.8);transition:top .08s linear} /* correlation */ .corr{width:150px;cursor:pointer;touch-action:none} @@ -382,7 +411,7 @@ h2::after{content:"";height:1px;background:var(--wash);flex:1} .corr .arc{position:absolute;inset:0 0 -44px;border:2px solid var(--wash);border-radius:50%} .corr .zero{position:absolute;left:50%;top:2px;width:1px;height:10px;background:var(--gold);margin-left:-.5px} .corr .ndl{position:absolute;left:50%;bottom:0;width:2px;height:38px;background:var(--gold-hi);transform-origin:50% 100%; - border-radius:2px;box-shadow:0 0 5px rgba(255,190,84,.5);transition:transform .1s ease-out} + border-radius:2px;box-shadow:0 0 5px rgba(var(--glow-hi),.5);transition:transform .1s ease-out} .corr .lbl{display:flex;justify-content:space-between;color:var(--steel);font-size:9px;margin-top:2px} /* battery */ @@ -423,8 +452,8 @@ h2::after{content:"";height:1px;background:var(--wash);flex:1} .annun{display:grid;grid-template-columns:repeat(3,1fr);gap:3px} .acell{font-size:8.5px;letter-spacing:.08em;text-align:center;color:var(--dim);padding:6px 4px;border-radius:3px;cursor:pointer; background:#141210;border:1px solid #262320;line-height:1.2} -.acell.warn{color:var(--panel);background:linear-gradient(180deg,#f0b552,var(--gold));font-weight:700;box-shadow:0 0 8px rgba(226,160,56,.4)} -.acell.fault{color:var(--cream);background:linear-gradient(180deg,#d98a6f,var(--fail));font-weight:700;box-shadow:0 0 8px rgba(203,107,77,.4);animation:pulse 1s ease-in-out infinite} +.acell.warn{color:var(--panel);background:linear-gradient(180deg,var(--amber-warn),var(--gold));font-weight:700;box-shadow:0 0 8px rgba(var(--glow-lo),.4)} +.acell.fault{color:var(--cream);background:linear-gradient(180deg,#d98a6f,var(--fail));font-weight:700;box-shadow:0 0 8px rgba(203,107,77,.4);animation:pulse var(--pulse-rate) ease-in-out infinite} /* jewel */ .jewel{width:24px;height:24px;border-radius:50%;position:relative;cursor:pointer; @@ -457,13 +486,13 @@ h2::after{content:"";height:1px;background:var(--wash);flex:1} .clock .mh{position:absolute;left:50%;top:50%;width:2px;height:28px;margin:-28px 0 0 -1px;background:var(--silver); transform-origin:50% 100%;border-radius:2px} .clock .sh{position:absolute;left:50%;top:50%;width:1px;height:30px;margin:-30px 0 0 -.5px;background:var(--gold-hi); - transform-origin:50% 100%;box-shadow:0 0 4px rgba(255,190,84,.5)} + transform-origin:50% 100%;box-shadow:0 0 4px rgba(var(--glow-hi),.5)} .clock .pin{position:absolute;left:50%;top:50%;width:6px;height:6px;margin:-3px;border-radius:50%;background:var(--gold)} /* frequency-dial scale */ .freqscale{width:184px;height:44px;position:relative;border-radius:5px;overflow:hidden;cursor:pointer;touch-action:none; background:linear-gradient(180deg,#191510,#0b0908);border:1px solid #2a251c; - box-shadow:inset 0 0 16px rgba(226,160,56,.1)} + box-shadow:inset 0 0 16px rgba(var(--glow-lo),.1)} .freqscale .tick{position:absolute;top:5px;width:1px;background:var(--steel);transform:translateX(-50%)} .freqscale .mk{position:absolute;bottom:6px;transform:translateX(-50%);color:var(--steel);font-size:9px} .freqscale .band{position:absolute;bottom:2px;left:6px;color:var(--gold);font-size:8px;letter-spacing:.18em} @@ -476,8 +505,8 @@ h2::after{content:"";height:1px;background:var(--wash);flex:1} .patch .row+.row{margin-top:9px} .patch .jack{width:13px;height:13px;border-radius:50%;cursor:pointer;background:radial-gradient(circle at 40% 35%,#3a352c,#0a0908 70%); border:1px solid #4a443a;box-shadow:inset 0 1px 2px rgba(0,0,0,.8)} -.patch .jack.hot{background:radial-gradient(circle at 40% 35%,#7d5c16,#1a1408 70%)} -.patch .jack.sel{border-color:var(--gold-hi);box-shadow:0 0 6px 1px rgba(255,190,84,.7)} +.patch .jack.hot{background:radial-gradient(circle at 40% 35%,var(--amber-edge),#1a1408 70%)} +.patch .jack.sel{border-color:var(--gold-hi);box-shadow:0 0 6px 1px rgba(var(--glow-hi),.7)} .patch svg{position:absolute;inset:0;pointer-events:none;width:100%;height:100%} .patch svg path{fill:none;stroke:var(--gold);stroke-width:2.4;opacity:.85;stroke-linecap:round} diff --git a/docs/prototypes/tokens-waybar.css b/docs/prototypes/tokens-waybar.css new file mode 100644 index 0000000..d18ba23 --- /dev/null +++ b/docs/prototypes/tokens-waybar.css @@ -0,0 +1,34 @@ +/* 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). */ +@define-color ground #151311; +@define-color panel #100f0f; +@define-color well #0a0c0d; +@define-color raise #1a1917; +@define-color silver #bfc4d0; +@define-color cream #f3e7c5; +@define-color steel #969385; +@define-color dim #7c838a; +@define-color slate #424f5e; +@define-color slate_hi #54677d; +@define-color wash #2c2f32; +@define-color pass #74932f; +@define-color fail #cb6b4d; +@define-color phos #7fe0a0; +@define-color phos_dim #1c3626; +@define-color vfd #63e6c8; +@define-color sevgrn #57d357; +@define-color sevred #e2543f; +@define-color sevoff #1a1613; +@define-color jewel_r #ff5b45; +@define-color jewel_a #ffb43a; +@define-color jewel_g #6fce33; +@define-color gold #e2a038; +@define-color gold_hi #ffbe54; +@define-color amber_grad_top #f2c76a; +@define-color amber_grad_mid #d29638; +@define-color amber_grad_bot #8f671f; +@define-color amber_edge #7d5c16; +@define-color amber_warn #f0b552; +@define-color glow_hi #ffbe54; +@define-color glow_lo #e2a038; diff --git a/docs/prototypes/tokens.el b/docs/prototypes/tokens.el new file mode 100644 index 0000000..1fb6e52 --- /dev/null +++ b/docs/prototypes/tokens.el @@ -0,0 +1,41 @@ +;;; gallery-tokens.el --- generated from tokens.json -*- lexical-binding: t; -*- +;;; Commentary: +;; Generated by gen_tokens.py — do not edit by hand; edit tokens.json. +;;; Code: +(defconst gallery-tokens + '((ground . "#151311") + (panel . "#100f0f") + (well . "#0a0c0d") + (raise . "#1a1917") + (silver . "#bfc4d0") + (cream . "#f3e7c5") + (steel . "#969385") + (dim . "#7c838a") + (slate . "#424f5e") + (slate-hi . "#54677d") + (wash . "#2c2f32") + (pass . "#74932f") + (fail . "#cb6b4d") + (phos . "#7fe0a0") + (phos-dim . "#1c3626") + (vfd . "#63e6c8") + (sevgrn . "#57d357") + (sevred . "#e2543f") + (sevoff . "#1a1613") + (jewel-r . "#ff5b45") + (jewel-a . "#ffb43a") + (jewel-g . "#6fce33") + (gold . "#e2a038") + (gold-hi . "#ffbe54") + (amber-grad-top . "#f2c76a") + (amber-grad-mid . "#d29638") + (amber-grad-bot . "#8f671f") + (amber-edge . "#7d5c16") + (amber-warn . "#f0b552") + (glow-hi . "#ffbe54") + (glow-lo . "#e2a038") + (mono . "\"BerkeleyMono Nerd Font\",\"Berkeley Mono\",monospace") + (pulse-rate . "1s")) + "Design tokens for the panel widget gallery.") +(provide 'gallery-tokens) +;;; gallery-tokens.el ends here diff --git a/docs/prototypes/tokens.json b/docs/prototypes/tokens.json new file mode 100644 index 0000000..6974805 --- /dev/null +++ b/docs/prototypes/tokens.json @@ -0,0 +1,46 @@ +{ + "_note": "Single source of truth for the panel widget gallery's design tokens. Edit here, then run: python3 gen_tokens.py (regenerates the :root block in panel-widget-gallery.html plus tokens-waybar.css and tokens.el). The amber hue is defined once; its glow rgb triples are derived by the generator, so retuning amber is a one-line change here.", + "palette": { + "ground": "#151311", + "panel": "#100f0f", + "well": "#0a0c0d", + "raise": "#1a1917", + "silver": "#bfc4d0", + "cream": "#f3e7c5", + "steel": "#969385", + "dim": "#7c838a", + "slate": "#424f5e", + "slate-hi": "#54677d", + "wash": "#2c2f32", + "pass": "#74932f", + "fail": "#cb6b4d", + "phos": "#7fe0a0", + "phos-dim": "#1c3626", + "vfd": "#63e6c8", + "sevgrn": "#57d357", + "sevred": "#e2543f", + "sevoff": "#1a1613", + "jewel-r": "#ff5b45", + "jewel-a": "#ffb43a", + "jewel-g": "#6fce33" + }, + "amber": { + "gold": "#e2a038", + "gold-hi": "#ffbe54", + "amber-grad-top": "#f2c76a", + "amber-grad-mid": "#d29638", + "amber-grad-bot": "#8f671f", + "amber-edge": "#7d5c16", + "amber-warn": "#f0b552" + }, + "glow": { + "glow-hi": "gold-hi", + "glow-lo": "gold" + }, + "font": { + "mono": "\"BerkeleyMono Nerd Font\",\"Berkeley Mono\",monospace" + }, + "timing": { + "pulse-rate": "1s" + } +} diff --git a/tests/gallery-tokens/test_gen_tokens.py b/tests/gallery-tokens/test_gen_tokens.py new file mode 100644 index 0000000..414194e --- /dev/null +++ b/tests/gallery-tokens/test_gen_tokens.py @@ -0,0 +1,268 @@ +"""Tests for docs/prototypes/gen_tokens.py. + +gen_tokens.py is the single-source token generator for the panel widget +gallery. It reads docs/prototypes/tokens.json (the neutral source of truth +for the design tokens) and emits three target representations from it: + + - web CSS custom properties (:root { --gold:#e2a038; ... }) + - waybar GTK CSS (@define-color gold #e2a038; ...) + - Emacs elisp (an alist for the future svg.el renderer) + +The three differ on purpose: CSS uses hyphenated --vars and stores glow +colors as bare "r,g,b" triples (so rgba(var(--glow-hi),.5) works); GTK CSS +has no custom properties, so it uses @define-color with underscore names +and resolves glows to their source hex (GTK uses alpha(@color,a)); elisp +uses a hyphenated-symbol alist of hex strings. One source, three emitters +is the whole reason the generator earns its keep across the three targets. + +These tests import the REAL gen_tokens module from docs/prototypes/ (not a +copy) and exercise its functions directly against fixture token dicts, plus +one integration test that runs main() against temp copies. + +Run from repo root: + make test-unit + (or python3 -m unittest tests.gallery-tokens.test_gen_tokens) +""" + +import importlib.util +import json +import os +import tempfile +import unittest + + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +GEN = os.path.join(REPO_ROOT, "docs", "prototypes", "gen_tokens.py") +TOKENS_JSON = os.path.join(REPO_ROOT, "docs", "prototypes", "tokens.json") + + +def _load_module(): + """Import the real gen_tokens.py by path (no importable package name).""" + spec = importlib.util.spec_from_file_location("gen_tokens", GEN) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +gt = _load_module() + + +# A small deterministic fixture — not the real tokens.json, so these tests +# don't drift when the palette is retuned. +FIXTURE = { + "palette": {"ground": "#151311", "wash": "#2c2f32", "slate-hi": "#54677d"}, + "amber": {"gold": "#e2a038", "gold-hi": "#ffbe54", "amber-grad-top": "#f2c76a"}, + "glow": {"glow-hi": "gold-hi", "glow-lo": "gold"}, + "font": {"mono": '"Berkeley Mono",monospace'}, + "timing": {"pulse-rate": "1s"}, +} + + +class HexToTriple(unittest.TestCase): + def test_normal_values(self): + self.assertEqual(gt.hex_to_triple("#ffbe54"), "255,190,84") + self.assertEqual(gt.hex_to_triple("#e2a038"), "226,160,56") + + def test_boundary_black_and_white(self): + self.assertEqual(gt.hex_to_triple("#000000"), "0,0,0") + self.assertEqual(gt.hex_to_triple("#ffffff"), "255,255,255") + + def test_boundary_three_digit_shorthand(self): + self.assertEqual(gt.hex_to_triple("#fff"), "255,255,255") + self.assertEqual(gt.hex_to_triple("#0f0"), "0,255,0") + + def test_boundary_no_leading_hash(self): + self.assertEqual(gt.hex_to_triple("e2a038"), "226,160,56") + + def test_error_bad_chars(self): + with self.assertRaises(ValueError): + gt.hex_to_triple("#gggggg") + + def test_error_bad_length(self): + with self.assertRaises(ValueError): + gt.hex_to_triple("#12") + + +class ResolveColor(unittest.TestCase): + def test_finds_in_amber(self): + self.assertEqual(gt.resolve_color(FIXTURE, "gold"), "#e2a038") + + def test_finds_in_palette(self): + self.assertEqual(gt.resolve_color(FIXTURE, "wash"), "#2c2f32") + + def test_missing_raises(self): + with self.assertRaises(KeyError): + gt.resolve_color(FIXTURE, "nonexistent") + + +class EmitWebCss(unittest.TestCase): + def setUp(self): + self.css = gt.emit_web_css(FIXTURE) + + def test_solid_colors_are_hyphenated_vars(self): + self.assertIn("--gold:#e2a038;", self.css) + self.assertIn("--slate-hi:#54677d;", self.css) + self.assertIn("--amber-grad-top:#f2c76a;", self.css) + + def test_glow_stored_as_rgb_triple(self): + # so rgba(var(--glow-hi),.5) resolves; derived from the source hex + self.assertIn("--glow-hi:255,190,84;", self.css) + self.assertIn("--glow-lo:226,160,56;", self.css) + + def test_font_and_timing(self): + self.assertIn("--pulse-rate:1s;", self.css) + self.assertIn('--mono:"Berkeley Mono",monospace;', self.css) + + +class EmitWaybarGtk(unittest.TestCase): + def setUp(self): + self.gtk = gt.emit_waybar_gtk(FIXTURE) + + def test_uses_define_color_not_custom_props(self): + self.assertIn("@define-color gold #e2a038;", self.gtk) + self.assertNotIn("--gold", self.gtk) + self.assertNotIn(":root", self.gtk) + + def test_glow_resolved_to_source_hex(self): + # GTK has no bare triples; it uses alpha(@color,a), so glow -> hex + self.assertIn("@define-color glow_hi #ffbe54;", self.gtk) + self.assertIn("@define-color glow_lo #e2a038;", self.gtk) + + def test_names_use_underscores_not_hyphens(self): + self.assertIn("@define-color slate_hi #54677d;", self.gtk) + self.assertNotIn("slate-hi", self.gtk) + self.assertNotIn("amber-grad-top", self.gtk) + + +class EmitElisp(unittest.TestCase): + def setUp(self): + self.el = gt.emit_elisp(FIXTURE) + + def test_alist_of_hex_with_hyphenated_symbols(self): + self.assertIn('(gold . "#e2a038")', self.el) + self.assertIn('(slate-hi . "#54677d")', self.el) + + def test_glow_is_hex_for_svg(self): + # svg.el / librsvg wants a real color, not a bare triple + self.assertIn('(glow-hi . "#ffbe54")', self.el) + + def test_timing_included(self): + self.assertIn('(pulse-rate . "1s")', self.el) + + +class ReplaceBetweenMarkers(unittest.TestCase): + START = "/* @tokens:start */" + END = "/* @tokens:end */" + + def _wrap(self, inner): + return f"a\n{self.START}\n{inner}\n{self.END}\nb\n" + + def test_replaces_inner_block(self): + src = self._wrap("OLD") + out = gt.replace_between_markers(src, self.START, self.END, "NEW") + self.assertIn("NEW", out) + self.assertNotIn("OLD", out) + # markers and surrounding text survive + self.assertIn(self.START, out) + self.assertIn(self.END, out) + self.assertTrue(out.startswith("a\n")) + self.assertTrue(out.rstrip().endswith("b")) + + def test_idempotent(self): + src = self._wrap("OLD") + once = gt.replace_between_markers(src, self.START, self.END, "NEW") + twice = gt.replace_between_markers(once, self.START, self.END, "NEW") + self.assertEqual(once, twice) + + def test_missing_marker_raises(self): + with self.assertRaises(ValueError): + gt.replace_between_markers("no markers here", self.START, self.END, "NEW") + + def test_start_marker_on_final_line_without_newline(self): + # Defensive branch: no newline after the start marker. The guard must + # not let text[:si-of-newline+1] collapse to "" and wipe the prefix. + src = f"keep\n{self.START} {self.END}" + out = gt.replace_between_markers(src, self.START, self.END, "X") + self.assertTrue(out.startswith("keep\n")) + self.assertIn(self.END, out) + self.assertIn("X", out) + + +class RealTokensJson(unittest.TestCase): + """The committed tokens.json must stay well-formed and complete.""" + + def setUp(self): + with open(TOKENS_JSON) as f: + self.tokens = json.load(f) + + def test_has_required_sections(self): + for section in ("palette", "amber", "glow", "font", "timing"): + self.assertIn(section, self.tokens) + + def test_amber_defines_gold_and_glow_sources_resolve(self): + self.assertIn("gold", self.tokens["amber"]) + self.assertIn("gold-hi", self.tokens["amber"]) + for _name, source in self.tokens["glow"].items(): + # every glow source must resolve to a real color + gt.resolve_color(self.tokens, source) + + def test_pulse_rate_present(self): + self.assertIn("pulse-rate", self.tokens["timing"]) + + +class MainIntegration(unittest.TestCase): + """main() rewrites the html :root and writes the waybar + elisp files.""" + + def setUp(self): + self.tmp = tempfile.mkdtemp(prefix="gen-tokens-test-") + self.html = os.path.join(self.tmp, "gallery.html") + self.tokens_path = os.path.join(self.tmp, "tokens.json") + self.waybar = os.path.join(self.tmp, "tokens-waybar.css") + self.elisp = os.path.join(self.tmp, "tokens.el") + with open(self.tokens_path, "w") as f: + json.dump(FIXTURE, f) + with open(self.html, "w") as f: + f.write( + "<style>\n:root{\n" + "/* @tokens:start */\n" + "STALE\n" + "/* @tokens:end */\n" + "}\n</style>\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() |
