diff options
| author | Craig Jennings <c@cjennings.net> | 2026-07-11 23:50:13 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-07-11 23:50:13 -0500 |
| commit | b137223e76c75217b594bc5e46e7625b10e31b0f (patch) | |
| tree | 67ff4b35bf65b91447e7524bd2315013f8848804 | |
| parent | 440877965e8879f3c9567cf923b7c65c0eee227c (diff) | |
| download | archsetup-b137223e76c75217b594bc5e46e7625b10e31b0f.tar.gz archsetup-b137223e76c75217b594bc5e46e7625b10e31b0f.zip | |
feat(gallery): add svg.el renderer proving the Emacs target, fix gauge ticks
gallery-widget.el is the proof-of-concept Emacs renderer: it reads the generated tokens.el and renders the needle gauge (card 10) as SVG via svg.el. Rasterized through librsvg (the renderer Emacs itself uses), it matches the web card side by side: same arc, ticks, glowing amber needle, half-dome hub, and readout from the same tokens. That closes the riskiest unknown in the three-target plan, so the component pattern can now scale widget by widget.
The comparison surfaced a real gallery bug: the web gauge's ticks were invisible. Their transform-origin put the rotation center 40px below the dial, so the rotated ticks landed outside the clipped area. I moved the ticks to the arc's top edge with the origin at the pivot, and all three now show at -60/0/+60.
The parseability test caught a second bug: the mono token's font stack carries double quotes, which broke the font-family XML attribute. The renderer swaps them to single quotes.
Tests are ERT (tests/gallery-widgets/), covering token resolution, angle math (normal/boundary/error), and the rendered document's structure. make test-unit now runs the elisp suites alongside the python ones, and make test-elisp runs them alone.
| -rw-r--r-- | Makefile | 22 | ||||
| -rw-r--r-- | docs/prototypes/README.org | 20 | ||||
| -rw-r--r-- | docs/prototypes/gallery-widget.el | 166 | ||||
| -rw-r--r-- | docs/prototypes/panel-widget-gallery.html | 2 | ||||
| -rw-r--r-- | tests/gallery-widgets/test-gallery-widget.el | 114 |
5 files changed, 320 insertions, 4 deletions
@@ -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 |
