aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/prototypes/gen_tokens.py180
-rw-r--r--docs/prototypes/panel-widget-gallery.html109
-rw-r--r--docs/prototypes/tokens-waybar.css34
-rw-r--r--docs/prototypes/tokens.el41
-rw-r--r--docs/prototypes/tokens.json46
-rw-r--r--tests/gallery-tokens/test_gen_tokens.py268
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()