aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Makefile22
-rw-r--r--docs/prototypes/README.org20
-rw-r--r--docs/prototypes/gallery-widget.el166
-rw-r--r--docs/prototypes/panel-widget-gallery.html2
-rw-r--r--tests/gallery-widgets/test-gallery-widget.el114
5 files changed, 320 insertions, 4 deletions
diff --git a/Makefile b/Makefile
index 98927bc..a08ddb5 100644
--- a/Makefile
+++ b/Makefile
@@ -5,7 +5,7 @@
# (https://git.cjennings.net/dotfiles.git). Run them from there:
# cd ~/.dotfiles && make stow|restow|reset|unstow|import <de>
-.PHONY: help deps test-unit test test-keep test-vm-base test-maint package-diff
+.PHONY: help deps test-unit test-elisp test test-keep test-vm-base test-maint package-diff
# Filesystem profile for the VM harness: btrfs (default) or zfs. Selects the
# base image the scripts build/use; exported so create-base-vm.sh + run-test.sh
@@ -24,7 +24,8 @@ help:
@echo ""
@echo "Targets:"
@echo " deps Install VM-testing dependencies"
- @echo " test-unit Run fast unit tests for installer helpers (no VM)"
+ @echo " test-unit Run fast unit tests (python + elisp, no VM)"
+ @echo " test-elisp Run just the elisp (ERT) suites"
@echo " test Run full VM test suite (creates base VM if needed)"
@echo " test-keep Run test and keep VM running for manual testing"
@echo " test-vm-base Create base VM only (runs archangel)"
@@ -51,6 +52,8 @@ deps:
# Run the fast unit-test suites for installer helpers (no VM needed).
# One suite per tests/<name>/ dir; `unittest discover` can't find them
# because the dir names are hyphenated, so each suite is run explicitly.
+# Elisp suites (tests/*/test-*.el, ERT) run in the same pass so one green
+# `make test-unit` covers the whole fast tier.
test-unit:
@fail=0; \
for t in tests/*/test_*.py; do \
@@ -58,6 +61,21 @@ test-unit:
echo "==> $$mod"; \
python3 -m unittest "$$mod" || fail=1; \
done; \
+ for t in tests/*/test-*.el; do \
+ [ -e "$$t" ] || continue; \
+ echo "==> $$t"; \
+ emacs --batch -l ert -l "$$t" -f ert-run-tests-batch-and-exit || fail=1; \
+ done; \
+ exit $$fail
+
+# Run just the elisp (ERT) suites
+test-elisp:
+ @fail=0; \
+ for t in tests/*/test-*.el; do \
+ [ -e "$$t" ] || continue; \
+ echo "==> $$t"; \
+ emacs --batch -l ert -l "$$t" -f ert-run-tests-batch-and-exit || fail=1; \
+ done; \
exit $$fail
# Create base VM for testing (runs archangel only, no archsetup)
diff --git a/docs/prototypes/README.org b/docs/prototypes/README.org
index c9b2fd7..3f2e3be 100644
--- a/docs/prototypes/README.org
+++ b/docs/prototypes/README.org
@@ -16,6 +16,24 @@ point at.
- [[file:2026-07-03-sound-panel-prototype.html][2026-07-03-sound-panel-prototype.html]] — the audio/pulsemixer console; layout
reference for [[file:../specs/2026-07-03-audio-panel-spec.org][the audio-panel spec]].
- [[file:panel-widget-gallery.html][panel-widget-gallery.html]] — the shared instrument-console
- widget kit (lamps, engraved sections, console keys, needle gauges).
+ widget kit (lamps, engraved sections, console keys, needle gauges). The
+ living catalogue: every widget here is the visual + behavioral spec for
+ its reusable-component ports.
+
+* Design tokens (single source, three targets)
+
+[[file:tokens.json][tokens.json]] is the source of truth for the design tokens (palette, amber
+family, glows, pulse rate). [[file:gen_tokens.py][gen_tokens.py]] regenerates all three targets
+(=python3 gen_tokens.py=):
+
+- the =:root= block inside the gallery HTML (web CSS custom properties)
+- [[file:tokens-waybar.css][tokens-waybar.css]] — GTK =@define-color= declarations for the waybar panels
+- [[file:tokens.el][tokens.el]] — an elisp alist for svg.el renderers
+
+[[file:gallery-widget.el][gallery-widget.el]] is the Emacs renderer (proof widget: the needle gauge,
+gallery card 10) — it reads tokens.el and emits the widget as SVG via
+svg.el, so the same look renders inside Emacs. Tests:
+=tests/gallery-tokens/= (generator, unittest) and =tests/gallery-widgets/=
+(renderer, ERT), both in =make test-unit=.
- [[file:2026-07-03-waybar-redesign-prototype.html][2026-07-03-waybar-redesign-prototype.html]] — three directions for sprucing up
waybar in the dupre instrument-console aesthetic (future work).
diff --git a/docs/prototypes/gallery-widget.el b/docs/prototypes/gallery-widget.el
new file mode 100644
index 0000000..61cd78a
--- /dev/null
+++ b/docs/prototypes/gallery-widget.el
@@ -0,0 +1,166 @@
+;;; gallery-widget.el --- svg.el renderers for the panel widget gallery -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Proof-of-concept Emacs target for the panel widget gallery. The web
+;; gallery (panel-widget-gallery.html) is the visual spec; tokens.json is
+;; the shared source of truth, and gen_tokens.py emits tokens.el (the
+;; `gallery-tokens' alist this file reads). Same tokens, same geometry,
+;; different renderer — that's the reuse model: shared values and shared
+;; SVG shapes, per-target code.
+;;
+;; The first widget is the needle gauge (gallery card 10): a semicircular
+;; arc in the wash color, three steel ticks at -60/0/+60 degrees, an amber
+;; needle with a soft glow (a real feGaussianBlur underlay, which librsvg —
+;; Emacs's own SVG renderer — rasterizes), a gold hub, and a value readout.
+;;
+;; Usage:
+;; (gallery-widget-needle-gauge 72) ; => svg dom object
+;; (gallery-widget-svg-string (gallery-widget-needle-gauge 72))
+;; (gallery-widget-write-svg (gallery-widget-needle-gauge 72) "/tmp/g.svg")
+;; In a live Emacs buffer:
+;; (insert-image (svg-image (gallery-widget-needle-gauge 72)))
+
+;;; Code:
+
+(require 'svg)
+(require 'dom)
+
+(load (expand-file-name "tokens.el"
+ (file-name-directory (or load-file-name buffer-file-name)))
+ nil t)
+
+(defun gallery-widget-token (name)
+ "Return the color/value string for token NAME from `gallery-tokens'.
+Signal an error for an unknown NAME rather than silently returning nil —
+a typo'd token must fail loudly, not render black."
+ (or (cdr (assq name gallery-tokens))
+ (error "Unknown gallery token: %s" name)))
+
+;;; --- needle gauge (gallery card 10) ---
+
+(defconst gallery-widget--gauge-min-deg -60.0
+ "Needle angle at value 0, degrees from vertical (negative = left).")
+(defconst gallery-widget--gauge-max-deg 60.0
+ "Needle angle at value 100.")
+
+(defun gallery-widget--needle-angle (value)
+ "Map VALUE (0-100, clamped) onto the gauge's needle angle in degrees."
+ (unless (numberp value)
+ (error "Gauge value must be a number, got %S" value))
+ (let ((v (min 100.0 (max 0.0 (float value)))))
+ (+ gallery-widget--gauge-min-deg
+ (* (/ v 100.0)
+ (- gallery-widget--gauge-max-deg gallery-widget--gauge-min-deg)))))
+
+(defun gallery-widget--polar (cx cy radius angle-deg)
+ "Point at RADIUS from (CX . CY) at ANGLE-DEG from vertical, as (X . Y).
+Positive angles swing right, matching the gauge's needle travel."
+ (let ((rad (* float-pi (/ angle-deg 180.0))))
+ (cons (+ cx (* radius (sin rad)))
+ (- cy (* radius (cos rad))))))
+
+(defun gallery-widget--fmt (n)
+ "Format coordinate N with two decimals for stable, readable SVG output."
+ (format "%.2f" n))
+
+(defun gallery-widget--node (svg tag &rest attrs)
+ "Append a TAG element with ATTRS plist to SVG and return it."
+ (let ((node (dom-node tag
+ (cl-loop for (k v) on attrs by #'cddr
+ collect (cons (intern (substring (symbol-name k) 1))
+ v)))))
+ (svg--append svg node)
+ node))
+
+(defun gallery-widget-needle-gauge (value)
+ "Render the gallery's needle gauge at VALUE (0-100) as an svg.el object.
+Geometry mirrors the web card: 96px-wide semicircular dial, pivot at its
+bottom center, 40px needle sweeping -60..+60 degrees, readout below."
+ (let* ((w 96) (h 64) (cx 48.0) (cy 48.0)
+ (angle (gallery-widget--needle-angle value))
+ (tip (gallery-widget--polar cx cy 40.0 angle))
+ (svg (svg-create w h :viewBox (format "0 0 %d %d" w h))))
+ ;; glow filter: the SVG equivalent of the web card's box-shadow bloom
+ (svg--append
+ svg
+ (dom-node 'defs nil
+ (dom-node 'filter
+ '((id . "gw-glow") (x . "-75%") (y . "-75%")
+ (width . "250%") (height . "250%"))
+ (dom-node 'feGaussianBlur
+ '((in . "SourceGraphic")
+ (stdDeviation . "2"))))))
+ ;; arc: 2px wash stroke, centerline radius 47 (96px circle, 2px border)
+ (gallery-widget--node svg 'path
+ :d "M 1 48 A 47 47 0 0 1 95 48"
+ :fill "none"
+ :stroke (gallery-widget-token 'wash)
+ :stroke-width "2")
+ ;; ticks: 8px steel radials at -60 / 0 / +60
+ (dolist (deg '(-60.0 0.0 60.0))
+ (let ((outer (gallery-widget--polar cx cy 47.0 deg))
+ (inner (gallery-widget--polar cx cy 39.0 deg)))
+ (gallery-widget--node svg 'line
+ :class "tick"
+ :x1 (gallery-widget--fmt (car inner))
+ :y1 (gallery-widget--fmt (cdr inner))
+ :x2 (gallery-widget--fmt (car outer))
+ :y2 (gallery-widget--fmt (cdr outer))
+ :stroke (gallery-widget-token 'steel)
+ :stroke-width "1.5")))
+ ;; needle glow underlay (blurred, translucent), then the crisp needle
+ (gallery-widget--node svg 'line
+ :class "needle-glow"
+ :x1 (gallery-widget--fmt cx)
+ :y1 (gallery-widget--fmt cy)
+ :x2 (gallery-widget--fmt (car tip))
+ :y2 (gallery-widget--fmt (cdr tip))
+ :stroke (gallery-widget-token 'gold-hi)
+ :stroke-width "4"
+ :stroke-linecap "round"
+ :opacity "0.55"
+ :filter "url(#gw-glow)")
+ (gallery-widget--node svg 'line
+ :class "needle"
+ :x1 (gallery-widget--fmt cx)
+ :y1 (gallery-widget--fmt cy)
+ :x2 (gallery-widget--fmt (car tip))
+ :y2 (gallery-widget--fmt (cdr tip))
+ :stroke (gallery-widget-token 'gold-hi)
+ :stroke-width "2"
+ :stroke-linecap "round")
+ ;; hub: half-dome on the dial's bottom edge (the web card clips the
+ ;; lower half of its 8px circle; here the dome is drawn directly)
+ (gallery-widget--node svg 'path
+ :class "hub"
+ :d "M 44 48 A 4 4 0 0 1 52 48 Z"
+ :fill (gallery-widget-token 'gold))
+ ;; value readout, matching the web card's cream bold figure
+ (svg-text svg (format "%d%%" (round value))
+ :x 48 :y 62
+ :fill (gallery-widget-token 'cream)
+ ;; the token's CSS font stack uses double quotes, which would
+ ;; break the XML attribute — SVG accepts single-quoted names
+ :font-family (replace-regexp-in-string
+ "\"" "'" (gallery-widget-token 'mono))
+ :font-size "12"
+ :font-weight "700"
+ :text-anchor "middle")
+ svg))
+
+;;; --- output helpers ---
+
+(defun gallery-widget-svg-string (svg)
+ "Return SVG (an svg.el dom object) serialized as an XML string."
+ (with-temp-buffer
+ (svg-print svg)
+ (buffer-string)))
+
+(defun gallery-widget-write-svg (svg file)
+ "Write SVG to FILE and return FILE."
+ (with-temp-file file
+ (svg-print svg))
+ file)
+
+(provide 'gallery-widget)
+;;; gallery-widget.el ends here
diff --git a/docs/prototypes/panel-widget-gallery.html b/docs/prototypes/panel-widget-gallery.html
index 1d95488..c56a21f 100644
--- a/docs/prototypes/panel-widget-gallery.html
+++ b/docs/prototypes/panel-widget-gallery.html
@@ -128,7 +128,7 @@ h2::after{content:"";height:1px;background:var(--wash);flex:1}
.gauge{width:96px;cursor:ns-resize;touch-action:none}
.gauge .dial{position:relative;height:48px;overflow:hidden}
.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 .tk{position:absolute;left:50%;top:1px;width:1.5px;height:8px;margin-left:-.75px;background:var(--steel);transform-origin:50% 47px}
.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(var(--glow-hi),.5);
transition:transform .12s cubic-bezier(.3,1.3,.5,1)}
diff --git a/tests/gallery-widgets/test-gallery-widget.el b/tests/gallery-widgets/test-gallery-widget.el
new file mode 100644
index 0000000..ec484bb
--- /dev/null
+++ b/tests/gallery-widgets/test-gallery-widget.el
@@ -0,0 +1,114 @@
+;;; test-gallery-widget.el --- ERT tests for gallery-widget.el -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the svg.el proof-of-concept renderer in docs/prototypes/.
+;; gallery-widget.el reads the generated tokens.el (same source of truth as
+;; the web gallery and the waybar CSS) and renders gallery widgets as SVG.
+;; These tests exercise the REAL module (loaded by path, not a copy):
+;; token resolution, needle-angle math (normal/boundary/error), and the
+;; rendered SVG document's structure.
+;;
+;; Run from repo root:
+;; make test-elisp
+;; (or emacs --batch -l ert -l tests/gallery-widgets/test-gallery-widget.el \
+;; -f ert-run-tests-batch-and-exit)
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(defvar test-gallery-widget--root
+ (expand-file-name "../.." (file-name-directory (or load-file-name buffer-file-name)))
+ "Repo root, derived from this test file's location.")
+
+(load (expand-file-name "docs/prototypes/gallery-widget.el" test-gallery-widget--root))
+
+;;; --- token access ---
+
+(ert-deftest gallery-widget-token-resolves-hex ()
+ "Known tokens resolve to hex color strings from the generated alist."
+ (should (string-match-p "\\`#[0-9a-f]\\{6\\}\\'" (gallery-widget-token 'gold)))
+ (should (string-match-p "\\`#[0-9a-f]\\{6\\}\\'" (gallery-widget-token 'glow-hi)))
+ (should (string-match-p "\\`#[0-9a-f]\\{6\\}\\'" (gallery-widget-token 'wash))))
+
+(ert-deftest gallery-widget-token-missing-errors ()
+ "An unknown token name signals an error rather than returning nil."
+ (should-error (gallery-widget-token 'no-such-token)))
+
+;;; --- needle angle math ---
+
+(ert-deftest gallery-widget-needle-angle-normal ()
+ "0..100 maps linearly onto -60..+60 degrees."
+ (should (= (gallery-widget--needle-angle 0) -60.0))
+ (should (= (gallery-widget--needle-angle 50) 0.0))
+ (should (= (gallery-widget--needle-angle 100) 60.0)))
+
+(ert-deftest gallery-widget-needle-angle-boundary-clamps ()
+ "Out-of-range values clamp to the dial's ends instead of overswinging."
+ (should (= (gallery-widget--needle-angle -5) -60.0))
+ (should (= (gallery-widget--needle-angle 150) 60.0)))
+
+(ert-deftest gallery-widget-needle-angle-error-non-number ()
+ "A non-numeric value signals an error."
+ (should-error (gallery-widget--needle-angle "fifty"))
+ (should-error (gallery-widget--needle-angle nil)))
+
+;;; --- rendered SVG structure ---
+
+(defun test-gallery-widget--svg-string (value)
+ "Render the needle gauge at VALUE and return its XML string."
+ (gallery-widget-svg-string (gallery-widget-needle-gauge value)))
+
+(ert-deftest gallery-widget-gauge-renders-svg-document ()
+ "The gauge renders to a parseable SVG document."
+ (let ((xml (test-gallery-widget--svg-string 42)))
+ (should (string-match-p "\\`<svg" xml))
+ (should (with-temp-buffer
+ (insert xml)
+ (libxml-parse-xml-region (point-min) (point-max))))))
+
+(ert-deftest gallery-widget-gauge-has-expected-parts ()
+ "Arc, three ticks, needle, hub, and value text are all present."
+ (let ((xml (test-gallery-widget--svg-string 42)))
+ ;; arc path stroked in the wash token
+ (should (string-match-p (format "path[^>]*stroke=\"%s\"" (gallery-widget-token 'wash)) xml))
+ ;; exactly three ticks
+ (should (= 3 (cl-count-if (lambda (_) t)
+ (split-string xml "class=\"tick\"" t)
+ :start 1)))
+ ;; needle + hub in the amber tokens
+ (should (string-match-p (format "class=\"needle\"[^>]*stroke=\"%s\""
+ (gallery-widget-token 'gold-hi))
+ xml))
+ ;; hub is a half-dome sitting on the dial's bottom edge, like the web card
+ (should (string-match-p (format "class=\"hub\"[^>]*fill=\"%s\""
+ (gallery-widget-token 'gold))
+ xml))
+ (should (string-match-p "class=\"hub\"[^>]*d=\"M 44 48 A 4 4 0 0 1 52 48 Z\"" xml))
+ ;; value readout
+ (should (string-match-p ">42%<" xml))))
+
+(ert-deftest gallery-widget-gauge-has-glow-filter ()
+ "The needle glow is a real SVG blur filter, not a dropped effect."
+ (let ((xml (test-gallery-widget--svg-string 42)))
+ (should (string-match-p "feGaussianBlur" xml))
+ (should (string-match-p "filter=\"url(#" xml))))
+
+(ert-deftest gallery-widget-gauge-needle-tracks-value ()
+ "The needle endpoint lands where the angle math says: left at 0, up at 50, right at 100."
+ ;; value 50 -> vertical: x2 = pivot x (48), y2 = 48 - 40 = 8
+ (let ((xml (test-gallery-widget--svg-string 50)))
+ (should (string-match-p "class=\"needle\"[^>]*x2=\"48.0+\"" xml))
+ (should (string-match-p "class=\"needle\"[^>]*y2=\"8.0+\"" xml)))
+ ;; value 100 -> +60 deg: x2 = 48 + 40*sin60 ~ 82.64
+ (should (string-match-p "x2=\"82.6" (test-gallery-widget--svg-string 100)))
+ ;; value 0 -> -60 deg: x2 = 48 - 40*sin60 ~ 13.36
+ (should (string-match-p "x2=\"13.3" (test-gallery-widget--svg-string 0))))
+
+(ert-deftest gallery-widget-gauge-integer-percent-in-readout ()
+ "The readout shows a rounded integer percent, matching the web card."
+ (should (string-match-p ">67%<" (test-gallery-widget--svg-string 66.6))))
+
+(provide 'test-gallery-widget)
+;;; test-gallery-widget.el ends here