diff options
| -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 |
